MessagingMessaging APIs

Transactional Push

Send transactional push notifications to your users

Overview

Flameup's transactional push API sends a notification to a user's device. Use it for notifications triggered by user actions like order confirmations, password resets, and account alerts.

Transactional email is not sent through the public API key — send it via campaigns or the Flameup dashboard. See Campaigns.

Push Notifications

Required permission: push:send, which also covers batch send and token validation. It can never be granted to a public (pk_live_) key — sending renders a template against the recipient's stored record. The user must have a registered device token (see Devices API), and the {workspace_id} in the URL must be your API key's workspace.

Send Push Notification

Endpoint: POST /api/v1/workspaces/{workspace_id}/push/send

This sends to exactly one device: the first of the person's active tokens. There is no way on this route to pick a platform or fan out to every device a person has. The fields below — content, targeting and template context — are the complete set the endpoint honours.

async function sendPush(externalId, title, body, options = {}) {
  const response = await fetch(
    `https://api.flameup.ai/api/v1/workspaces/${WORKSPACE_ID}/push/send`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${API_KEY}`
      },
      body: JSON.stringify({
        external_id: externalId,
        title: title,
        body: body,
        ...options
      })
    }
  );

  return response.json();
}

// Example: Order confirmation
await sendPush('user_12345',
  'Order Confirmed!',
  'Your order #ORD-12345 has been confirmed. Total: $99.99',
  {
    action_url: 'myapp://orders/ORD-12345'
  }
);

Request Body

FieldTypeRequiredDescription
external_idstringYesYour own user id — the same userId you send to /identify and /track. Not the Flameup person UUID
titlestringYesNotification title. Supports {{...}} template variables
bodystringYesNotification body text. Supports {{...}} template variables
image_urlstringNoURL for rich notification image
action_urlstringNoDeep link URL when notification is tapped
custom_dataobjectNoCustom key-value payload for your app, delivered on the FCM data field (reserved FCM keys are stripped)
variablesobjectNoValues for {{...}} placeholders in title/body
campaign_idstringNoUUID of a campaign to load into the template context. Exposes {{campaign.id}}, {{campaign.name}}, {{campaign.type}}, {{campaign.status}} and {{campaign.settings.*}} to title/body
event_idstringNoUUID of an event to load into the template context. Exposes {{event.id}}, {{event.trigger}}, {{event.timestamp}} and {{event.parameters.*}} — parameters are also flattened, so {{event.plan}} resolves as well as {{event.parameters.plan}}
tagsarrayNoFree-form tags stored on the send record
There are no platform or delivery options on this route. platform, priority, ttl, and the ios_*/android_* fields you may find in older examples are accepted in the JSON but silently dropped — delivery always uses the platform the device was registered with, sound default, and normal priority. Do not build logic on fields this endpoint ignores. campaign_id and event_id are the exception — they are honoured, but only as template context: they change the rendered title/body, never the delivery. A value that is not a UUID, or that names a campaign or event in another workspace, is ignored without an error — the send still goes out, just with those {{campaign.*}}/{{event.*}} placeholders unresolved.

Response

Always 200 when the request itself was processed — check the status field:

{
  "message_id": "550e8400-e29b-41d4-a716-446655440000",
  "status": "sent",
  "sent_at": "2024-01-15T10:30:00Z",
  "provider": "fcm",
  "provider_id": "projects/myapp/messages/abc123"
}
FieldDescription
message_idFlameup message ID (a skipped-<unix> placeholder for skipped sends)
statussent, skipped, or policy_suppressed — see below
sent_atTimestamp when handled
providerfcm when sent, none when skipped
provider_idExternal message ID from provider
errorReason, present when skipped (e.g. no active device tokens, person unsubscribed)
statusMeaning
sentFCM accepted the message. This does not confirm it reached the handset — there is no delivery read-back today
skippedNothing was delivered, but nothing went wrong: the person has no active device token, their token is suppressed, or their own consent status (unsubscribed/suppressed) blocks the send. Still 200 — these are normal states, not errors. error says which
policy_suppressedThe workspace send policy blocked it — quiet hours or a frequency cap

sent is as much as the API can tell you. FCM has no status-check API, so there is no endpoint that can report delivery beyond what the send response already said.

Send policy

A workspace can carry a send policy (dashboard → workspace settings, settings.send_policy) that constrains push delivery — and it applies to this API, not just to campaigns. When the policy blocks a send, the response is still 200, with "status": "policy_suppressed". The suppressed message is dropped, not queued — it is not retried when the window reopens.

Three rules can suppress a transactional send:

  • Quiet hours — a local-time window (e.g. 22:00–08:00 in the workspace's policy timezone) during which nothing is pushed. Never bypassable.
  • Daily cap (max_pushes_per_day) — a per-user cap that resets at local midnight in the policy timezone.
  • Rolling-window cap (max_pushes_per_window over window_hours) — a per-user cap over a sliding window of 1–168 hours, counted in absolute time with no calendar reset. Configured as both-or-neither with window_hours.
If your workspace enables quiet hours, an order confirmation sent at 23:00 comes back policy_suppressed and is never delivered. The response does not say which rule suppressed it — that is recorded server-side. If your transactional sends must always go out, keep the send policy off in that workspace, or route them from a workspace without one.

Send to many people at once

Endpoint: POST /api/v1/workspaces/{workspace_id}/push/send/batch

Up to 1000 recipients in one request, each rendered and sent individually.

Unlike single send, this route identifies each recipient by the Flameup person UUID, not your external id — and device_token does not address a device directly: it selects one of that person's active devices. A token that is not an active device of that person yields "status": "skipped" — Flameup never sends to a raw token that isn't bound to the named person.
{
  "title": "Your order shipped",
  "body": "Hi {{name}}, it's on its way",
  "recipients": [
    { "user_id": "550e8400-e29b-41d4-a716-446655440000", "device_token": "…",
      "platform": "android", "variables": { "name": "Jane" } }
  ]
}
FieldRequiredNotes
title, bodyYesTemplate variables supported
recipientsYes1–1000 items
recipients[].user_idYesThe Flameup person UUID (from GET /people/{id} with people:read). A non-UUID value fails that recipient
recipients[].device_tokenYesWhich of that person's active devices to send to
recipients[].platformYesios, android or web
recipients[].variablesNoTemplate variables for that recipient
variablesNoDefaults applied where a recipient sets none
image_url, action_url, tagsNo

Requires push:send.

Response (200) — one result per recipient, in request order, plus a summary. Recipient results use the same status values as single send (sent / skipped / policy_suppressed), with one addition: "failed" when that recipient's send errored (bad UUID, provider failure):

{
  "results": [
    { "message_id": "…", "status": "sent", "provider": "fcm", "sent_at": "…" },
    { "message_id": "", "status": "failed", "error": "failed to send push notification", "sent_at": "…" }
  ],
  "summary": { "total": 2, "sent": 1, "failed": 1, "processed": "2026-08-28T10:30:00Z" }
}

One trap in the summary: summary.sent counts every non-failed result — skipped and policy_suppressed recipients count as "sent" there. To know who actually got a push, walk results and count status === "sent".

Validating tokens

Endpoint: POST .../push/tokens/validate (requires push:send)

Checks a device_token + platform pair and returns {"valid": true|false}. Be aware this is a format check only — token length and shape per platform. It does not ask FCM whether the token is live, so a well-formed revoked token still validates. The reliable liveness signal is a delivery failure, which marks the stored token invalid automatically (see Devices — Token Lifecycle).


Push Notification Examples

// Order confirmation
await sendPush(externalId, 'Order Confirmed!',
  'Your order #ORD-12345 is confirmed. Total: $99.99', {
    action_url: 'myapp://orders/ORD-12345'
});

// Shipping notification
await sendPush(externalId, 'Your order has shipped!',
  'Track your package: 1Z999AA10123456784', {
    action_url: 'myapp://tracking/1Z999AA10123456784'
});

// Delivered notification
await sendPush(externalId, 'Package delivered!',
  'Your order #ORD-12345 has been delivered.', {
    action_url: 'myapp://orders/ORD-12345/review'
});

Error Handling

The single-send route splits outcomes across the HTTP status and the body's status field. A 200 is not proof anything was sent — the no-device case, the suppressed-token case, an unsubscribed or suppressed person, and a policy suppression all come back 200:

HTTPBodyMeaning
200"status": "sent"FCM accepted the message
200"status": "skipped", "error": "no active device tokens"The person has no active device
200"status": "skipped", "error": "device token suppressed"The person's token is suppressed
200"status": "skipped", "error": "person unsubscribed"The person's status is unsubscribed — they opted out, so no channel reaches them
200"status": "skipped", "error": "person suppressed"The person's status is suppressed
200"status": "policy_suppressed"Blocked by the workspace send policy
404{"error": "Person not found"}Unknown external_id in this workspace
500{"error": "failed to send push notification"}FCM not configured for the workspace, or a provider failure

Single send never returns 200 with "status": "failed" — that value exists only in per-recipient batch results.

Handling in Code

async function sendNotification(externalId, title, body, options) {
  const result = await sendPush(externalId, title, body, options);

  if (!result.status) {
    // HTTP-level error body: 404 unknown external_id, 500 provider failure
    console.error('Push request failed:', result.error);
    return null;
  }

  if (result.status !== 'sent') {
    // 'skipped' or 'policy_suppressed' — a 200, but the user got nothing.
    console.warn('Push not delivered:', result.status, result.error);
    // Fall back to another channel
    return null;
  }

  return result;
}

Best Practices

The Push API takes your own user id — the same userId you send to /identify and /track. You do not need to look up or store a Flameup UUID.

// No lookup step. The id you already have is the id the API wants.
await sendPush('user_123', 'Order Confirmed!', 'Your order is on its way');

This changed: /push/send previously required the Flameup person_id (UUID). It now requires external_id, so the single-send route keys on the same identifier as the rest of the API-key surface. The one holdout is batch send, whose recipients[].user_id is still the person UUID.