Send a notification
Sends a broadcast push notification to wallet passes (Apple Wallet + Google Wallet). Scope: notifications:send. and an Idempotency-Key header.
Returns 202 Accepted (never 201) - the broadcast is enqueued, not yet delivered. Stats start at zero and populate as the asynchronous fan-out drains. Poll GET /v1/notifications/{id} or subscribe to the notification.delivery_updated webhook.
Audience
Omit audience (or send {}) for an org-wide broadcast. Four filters (card_ids, customer_ids, external_ids, location_ids) compose as INTERSECTION - see the schema.
Marketing opt-out. API sends are your own processing: they are not filtered by the marketing opt-out a merchant sets for a customer in the VEXiON dashboard, nor by the customer’s marketing_opt_in. Honouring a customer’s objection to marketing is the sender’s responsibility (leave those customers out of customer_ids / external_ids).
At launch: plain text, one language. Send title and body each in a single language with no template variables. More than one language returns 422 multiple_locales_unavailable; a body with {{...}} returns 422 template_variables_unavailable. Nothing is sent in either case. The template and per-recipient language features described here will be switched on in a later release without a change to the request shape.
Scheduling
Supply schedule_at (unix seconds, future, ≤90 days ahead) to queue a future send - status sits at scheduled and the broadcast is cancellable via DELETE until it fires. Rate limits are checked at accept AND at fire time.
How delivery works (what the statuses mean)
Accepted broadcasts are queued and drained by asynchronous worker batches, one per-recipient-per-device dispatch each: Apple Wallet (APNs silent push → the device refetches the pass → iOS renders the banner from the pass’s changeMessage), Google Wallet (object update). Aggregate stats update as batches complete; per-recipient rows are live at GET /v1/notifications/{id}/deliveries. delivered always means provider-acknowledged - no channel can confirm human eyes.
Rate limits
- 1 broadcast per minute per API key
- 50 broadcasts per day per API key
- 10 concurrent in-flight broadcasts per organization
Responses carry X-RateLimit-Daily-Limit / -Remaining / -Reset in addition to the standard minute-bucket headers. On 429, code is rate_limited_per_minute, rate_limited_per_day, or concurrent_broadcasts_exceeded.
Latency (observed, not contractual)
| Audience | p50 | p95 |
|---|---|---|
| ≤500 | 5 s | 30 s |
| ≤10k | 30 s | 2 min |
| ≤50k | 2 min | 10 min |
| >50k | 10 min | 45 min |
For time-sensitive sends (flash sale at 14:00), send 1-2 minutes ahead for audiences under 10k.
Template variables
title and body accept the same template DSL as pass_fields on the Card resource - variables in double braces with Liquid-style filters:
{{first_name | default: "there"}}- identity paths{{card.balance}},{{current_tier_name}},{{stamps_remaining}}- card-mechanics shortcuts{{custom.<key>}}- declared CustomField values (metadatais NOT addressable){{location.name}}- the customer’s signup location (NOT the targeting location)- Filters:
default:,join:,first,upper,lower,date:
Templates render per recipient at fan-out time - a 142-recipient broadcast with {{first_name}} produces 142 distinct payloads. If a variable is missing and has no default:, it renders empty; if the whole message would be empty, that recipient is silently skipped.
Full catalog in the Cards resource (pass fields template variables).
Personalization scope & Apple banner mechanics (important)
title and body render per recipient at fan-out - "You already have {{card.balance}} points, come use them today" produces an individual banner per customer with their live balance.
Card-scope rule (enforced at accept time):
- Identity variables (
{{first_name}},{{organization.name}}, …) - always allowed, any audience - Card-mechanics variables (
{{card.balance}},{{stamps_remaining}},{{card.reward_tier.tier_name}}, …) and custom-field variables ({{custom.<key>}}) - requireaudience.card_idspinned to cards whose type carries that mechanic. No scope →422 template_variable_requires_card_scope. Wrong type (e.g.{{card.balance}}against a stamp card) →422 template_variable_not_supported_by_card. A variable that is EVENT-ONLY (usable only in a transaction-triggered message, e.g.{{transaction.amount}}), acard.<path>never wired to a renderer (e.g.{{points_to_next_tier}}or the retired{{card.tier.name}}spelling - see the Cards resource’s Template DSL “Not available” note), or not a recognised variable at all →422 template_variable_not_supported. That one is about the variable itself, not about your audience: pinningcard_idswill not resolve it. This guarantees every targeted recipient actually HAS the referenced field - no empty-gap banners.
One carrier, every card type (platform guarantee): every generated pass - stamp, reward, discount, coupon, membership, gift card, and custom - always includes the hidden last-message back-field. Broadcasts therefore work uniformly across your whole customer base with zero per-card setup; a custom card with no mechanics still receives banners exactly like any other card.
How the Apple banner physically works: the broadcast rides a hidden last-message back-field; its value change is what fires the lock-screen banner with your full rendered text. Consequences:
- A broadcast cannot flash a front field. iOS highlights a field on the open pass only when that field’s VALUE changes - and broadcasts never change values. Value-change highlights belong to the transaction tier:
Card.(standard balance/stamp fields) andCard.pass_fields[].change_message(custom fields). A broadcast mentioning the balance and a transaction changing the balance are complementary, not interchangeable. - Identical consecutive texts coalesce. If a recipient’s rendered text is byte-identical to the previous broadcast, iOS sees no field change and shows no banner. Vary the copy between sends.
Google Wallet: no field mechanics - the rendered text is delivered as a standard Wallet message, subject to Google’s 3-visible-messages/day class cap.
Common failures
422 validation_failed-title_invalid,body_invalid,audience_invalid,external_id_collision,location_ids_too_many,location_not_found,template_invalid,template_variable_requires_card_scope,template_variable_not_supported_by_card,template_variable_not_supported404 not_found_error-audience.card_idsreferences a card outside your organization429-rate_limited_per_minute,rate_limited_per_day, orconcurrent_broadcasts_exceeded; see rate limits above
Test on one customer first: send with audience: { "customer_ids": ["cus_yours"] }, check the banner on a real device, then send the full broadcast. Broadcast rate limits make a wrong org-wide send expensive to walk back.
Authorizations
Your secret API key as a Bearer token: Authorization: Bearer vxn_sk_.... Keys are created and scoped in the dashboard - see Authentication.
Headers
Stable opaque string up to 255 chars. Same key + same body within 24h replays cached response.
255Body
Send a broadcast. title + body are required localized objects; audience defaults to all active customers when omitted. Templates are validated at write time - syntactically invalid templates (unbalanced braces, unknown filters, forbidden roots like metadata) fail the whole broadcast with 422 template_invalid.
Localized title keyed by language code (en, sk, cs, de). At least one locale required. Per-locale value is plain text, 1-60 characters (Apple Wallet's lock-screen banner truncates around 60). Template variables are NOT supported in the title: it is a single string shared across all recipients, so per-recipient tokens cannot render there and any {{...}} in the title is rejected with 422. Put template variables in the body instead.
Localized body keyed by language code (en, sk, cs, de). At least one locale required. Per-locale value is plain text, 1-240 characters (worst-case room after banner chrome on Apple + Google). Template variables are supported. Card-mechanics variables ({{card.balance}}, {{stamps_remaining}}, ...) require a card-scoped audience (card_ids).
Audience filter. Omit or send {} to broadcast to every active customer. Filters compose as INTERSECTION (AND).
Optional future send time (unix seconds). Bounds: must be in the future, at most 90 days ahead (422 schedule_at_out_of_range). Omit for immediate dispatch. Scheduled broadcasts have status: "scheduled" and can be cancelled until they fire. Rate limits are checked at ACCEPT time and again at FIRE time.
null
Response
Accepted; audience materialized and queued.
A broadcast push notification. Created via POST /v1/notifications, delivered asynchronously to wallet passes (Apple Wallet + Google Wallet). The stored DTO always shows the UNRENDERED template - per-recipient rendered text is not retrievable after the fact.
Notification identifier. Opaque, ntf_ prefix. Immutable.
^ntf_[A-Za-z0-9]+$"ntf_01J9P0G7K9C0RYHXJ2K7A4N5MZ"
Resource type discriminator. Always 'notification'.
"notification""notification"
Localized title keyed by language code (en, sk, cs, de). At least one locale required. Per-locale value is plain text, 1-60 characters (Apple Wallet's lock-screen banner truncates around 60). Template variables are supported. Card-mechanics variables ({{card.balance}}, {{stamps_remaining}}, ...) require a card-scoped audience (card_ids).
Localized body keyed by language code (en, sk, cs, de). At least one locale required. Per-locale value is plain text, 1-240 characters (worst-case room after banner chrome on Apple + Google). Template variables are supported. Card-mechanics variables ({{card.balance}}, {{stamps_remaining}}, ...) require a card-scoped audience (card_ids).
Broadcast lifecycle:
queued- accepted, fan-out imminentscheduled- accepted with a futureschedule_at; cancellable until it firesprocessing- worker batches drainingcompleted- every per-recipient attempt finished (provider ack'd or retries exhausted; does NOT mean the human saw it)partial- finished with failure rate above the warning threshold (~5%)failed- catastrophic failure before deliveries (rare; safe to retry with a fresh Idempotency-Key). Reserved: NOT emitted by v1 today (see note below).cancelled- cancelled while queued/scheduled
failed is declared but NOT emitted by v1 today. status is DERIVED per read from four raw signals (schedule flag + fire time, fan-out start, drain completion, delivery counters); nothing records a crash that happened before any delivery was attempted, so no code path can produce failed. GET /v1/notifications?status=failed is accepted and returns an empty page rather than an error, so branching over the full enum stays safe. The other six are all live, including cancelled, which is backed by a real cancelledAt timestamp set by DELETE /v1/notifications/{id} and takes precedence over every other signal.
queued, scheduled, processing, completed, partial, failed, cancelled "completed"
Aggregate delivery counters, updated as the asynchronous fan-out drains (queue → worker batches → per-device dispatch). Starts at zero on accept. For per-recipient rows, use GET /v1/notifications/{id}/deliveries.
Unix seconds. Time the broadcast was accepted.
1782134400
The audience filter this broadcast was sent with. Null/empty = broadcast to all active customers.
Unix seconds when a scheduled broadcast fires. Null for immediate sends. While in the future, the broadcast is normally status: "scheduled" and cancellable via DELETE. EXCEPTION: if the audience resolved to zero recipients at accept time there is nothing to deliver now or later, so the broadcast is accepted as already finished - status is completed with every counter at 0 and audience_resolved_count: 0, it never appears under ?status=scheduled, and DELETE returns 409 broadcast_not_cancellable even though this value is still in the future. Poll on status, not on schedule_at.
null
Unix seconds when fan-out started - set on EVERY send (immediate and scheduled). Null only before dispatch.
1782134401
Unix seconds. Populated when status reaches completed, partial, or failed. Null while queued/processing.
1782134522
The request id of the request that ACCEPTED this broadcast. Include it in support requests: it identifies the exchange where the fan-out actually happened. On an idempotent replay (a repeat call with the same Idempotency-Key, answered with Idempotent-Replayed: true) this deliberately differs from the Vexion-Request-Id response header: the body keeps the original accept id, while the header carries the id of the replay exchange you are holding.
"req_01J9P0G7K9C0RYHXJ2K7A4N5MZ"