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.
Push Notifications
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'
}
);
import requests
def send_push(external_id, title, body, **options):
response = requests.post(
f'https://api.flameup.ai/api/v1/workspaces/{WORKSPACE_ID}/push/send',
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {API_KEY}'
},
json={
'external_id': external_id,
'title': title,
'body': body,
**options
}
)
return response.json()
# Example: Order confirmation
send_push(
'user_12345',
'Order Confirmed!',
'Your order #ORD-12345 has been confirmed. Total: $99.99',
action_url='myapp://orders/ORD-12345'
)
curl -X POST "https://api.flameup.ai/api/v1/workspaces/your_workspace_id/push/send" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your_api_key" \
-d '{
"external_id": "user_12345",
"title": "Order Confirmed!",
"body": "Your order #ORD-12345 has been confirmed. Total: $99.99",
"action_url": "myapp://orders/ORD-12345"
}'
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | Yes | Your own user id — the same userId you send to /identify and /track. Not the Flameup person UUID |
title | string | Yes | Notification title. Supports {{...}} template variables |
body | string | Yes | Notification body text. Supports {{...}} template variables |
image_url | string | No | URL for rich notification image |
action_url | string | No | Deep link URL when notification is tapped |
custom_data | object | No | Custom key-value payload for your app, delivered on the FCM data field (reserved FCM keys are stripped) |
variables | object | No | Values for {{...}} placeholders in title/body |
campaign_id | string | No | UUID 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_id | string | No | UUID 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}} |
tags | array | No | Free-form tags stored on the send record |
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"
}
| Field | Description |
|---|---|
message_id | Flameup message ID (a skipped-<unix> placeholder for skipped sends) |
status | sent, skipped, or policy_suppressed — see below |
sent_at | Timestamp when handled |
provider | fcm when sent, none when skipped |
provider_id | External message ID from provider |
error | Reason, present when skipped (e.g. no active device tokens, person unsubscribed) |
status | Meaning |
|---|---|
sent | FCM accepted the message. This does not confirm it reached the handset — there is no delivery read-back today |
skipped | Nothing 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_suppressed | The 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_windowoverwindow_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 withwindow_hours.
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.
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" } }
]
}
| Field | Required | Notes |
|---|---|---|
title, body | Yes | Template variables supported |
recipients | Yes | 1–1000 items |
recipients[].user_id | Yes | The Flameup person UUID (from GET /people/{id} with people:read). A non-UUID value fails that recipient |
recipients[].device_token | Yes | Which of that person's active devices to send to |
recipients[].platform | Yes | ios, android or web |
recipients[].variables | No | Template variables for that recipient |
variables | No | Defaults applied where a recipient sets none |
image_url, action_url, tags | No |
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'
});
// New login alert
await sendPush(externalId, 'New Login Detected',
'New sign-in from Chrome on Windows in New York, US', {
action_url: 'myapp://security'
});
// Password changed
await sendPush(externalId, 'Password Changed',
'Your password was changed. If this wasn\'t you, contact support.');
// Payment successful
await sendPush(externalId, 'Payment Received',
'We received your payment of $49.99. Thank you!', {
action_url: 'myapp://receipts/pay_abc123'
});
// Payment failed
await sendPush(externalId, 'Payment Failed',
'Your payment of $49.99 was declined. Please update your card.', {
action_url: 'myapp://billing'
});
// New follower
await sendPush(externalId, 'John Doe is now following you',
'Tap to view their profile', {
action_url: 'myapp://profile/user_456',
image_url: 'https://example.com/avatars/user_456.jpg'
});
// New message
await sendPush(externalId, 'New message from Jane',
'Hey, are you free for lunch?', {
action_url: 'myapp://chat/chat_789'
});
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:
| HTTP | Body | Meaning |
|---|---|---|
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.