> ## Documentation Index
> Fetch the complete documentation index at: https://vexioncards.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# 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 (`metadata` is 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>}}`)** - **require `audience.card_ids`** pinned 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}}`), a `card.<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: pinning `card_ids` will 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) and `Card.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_supported`
- `404 not_found_error` - `audience.card_ids` references a card outside your organization
- `429` - `rate_limited_per_minute`, `rate_limited_per_day`, or `concurrent_broadcasts_exceeded`; see rate limits above
<Note>
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.
</Note>




## OpenAPI

````yaml /openapi.json post /v1/notifications
openapi: 3.1.0
info:
  title: VEXiON cards Public API
  description: >-
    ## Rate limiting


    Two independent layers, checked in this order:


    1. **Per-IP throttle (pre-authentication).** Applied to every `/v1` request
    BEFORE the API key is checked, keyed on the caller's IP address:
       - **600 requests/minute** per IP - a flood ceiling, `429 rate_limit_error` / `rate_limited`.
       - **20 failed authentications per 15 minutes** per IP - a key-guessing ceiling. Only responses that are `401` (missing/invalid/revoked key) count against this ceiling; a `403` from a valid key that simply lacks a scope does not. `429 rate_limit_error` / `too_many_failed_attempts`.

       Both ceilings send `Retry-After` (seconds).
    2. **Per-key, per-bucket limiter (post-authentication).** Documented per
    endpoint (see each operation's `429` response and the `rate_limit` field on
    the API Key resource) - e.g. 60/min default, 120/min for transactions, plus
    the notification-broadcast and lookup-filter buckets described on their own
    endpoints.


    ### Rate-limit response headers


    Every rate-limited response carries the legacy `X-RateLimit-Limit` /
    `X-RateLimit-Remaining` / `X-RateLimit-Reset` family (the last is an **epoch
    timestamp**) AND the IETF standards-track headers, side by side:


    - `RateLimit-Policy: "<bucket>";q=<quota>;w=<window seconds>` - the static
    policy for the bucket that was hit.

    - `RateLimit: "<bucket>";r=<remaining>;t=<seconds until reset>` - live
    state. **`t` is a DURATION (seconds remaining), not the epoch timestamp
    `X-RateLimit-Reset` carries - the two are never the same value.**
  version: 1.0.0
servers:
  - url: https://api.vexioncards.one
    description: Production
  - url: https://migration.api.vexioncards.one
    description: Sandbox
security:
  - apiKey: []
tags:
  - name: Customers
  - name: Cards
  - name: Transactions
  - name: Notifications
  - name: Locations
  - name: Products
  - name: API keys
  - name: Organization
  - name: Webhook endpoints
  - name: Events
  - name: Custom fields
  - name: Team members
  - name: Icons
paths:
  /v1/notifications:
    post:
      tags:
        - Notifications
      summary: Send a notification
      description: >
        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 (`metadata` is 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>}}`)** - **require `audience.card_ids`** pinned 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}}`), a `card.<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: pinning `card_ids` will 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) and
        `Card.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_supported`

        - `404 not_found_error` - `audience.card_ids` references a card outside
        your organization

        - `429` - `rate_limited_per_minute`, `rate_limited_per_day`, or
        `concurrent_broadcasts_exceeded`; see rate limits above

        <Note>

        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.

        </Note>
      operationId: createNotification
      parameters:
        - name: Idempotency-Key
          in: header
          description: >-
            Stable opaque string up to 255 chars. Same key + same body within
            24h replays cached response.
          required: true
          example: ''
          schema:
            type: string
            maxLength: 255
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NotificationCreate'
            examples:
              Broadcast to all customers:
                summary: Org-wide announcement, no audience filter
                value:
                  title:
                    en: New opening hours
                  body:
                    en: We are now open until 20:00 on weekdays.
              Filter by card:
                summary: Only holders of one card template
                value:
                  title:
                    en: Summer special menu
                  body:
                    en: Try the new lime lemonade for 2.50 EUR.
                  audience:
                    card_ids:
                      - crd_sophcrprtvpmmnwxah2lomnkee
              Filter by locations and card:
                summary: >-
                  Intersection: card holders whose last visit was at 2 specific
                  stores
                value:
                  title:
                    en: Your store has news
                  body:
                    en: Come by this weekend for double stamps.
                  audience:
                    card_ids:
                      - crd_sophcrprtvpmmnwxah2lomnkee
                    location_ids:
                      - loc_01J9NQHF2YP6V0AKD2A2LOC001
                      - loc_01J9NQHF2YP6V0AKD2A2LOC002
              Personalized with template variables:
                summary: Per-recipient rendering at fan-out (tokens in the body only)
                value:
                  title:
                    en: A little something for you
                  body:
                    en: >-
                      Your points are waiting. Collect a few more stamps this
                      week to reach your reward.
                  audience:
                    external_ids:
                      - loyalty-789
                      - loyalty-790
              Scheduled campaign:
                summary: Fires next Friday 09:00 - cancellable until then
                value:
                  title:
                    en: Weekend double stamps!
                  body:
                    en: This Saturday and Sunday every coffee earns 2 stamps.
                  audience:
                    card_ids:
                      - crd_sophcrprtvpmmnwxah2lomnkee
                  schedule_at: 1782893400
              Balance reminder (card-scoped):
                summary: Mechanics variable - audience MUST pin the reward card
                value:
                  title:
                    en: Your points are waiting!
                  body:
                    en: You have points waiting. Come use them this week.
                  audience:
                    card_ids:
                      - crd_2hh2fekk5zazfyyvian4uhepyq
        required: true
      responses:
        '202':
          description: Accepted; audience materialized and queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Notification'
              examples:
                Broadcast accepted:
                  summary: 202 - enqueued, stats at zero
                  value:
                    id: ntf_01J9P0G7K9C0RYHXJ2K7A4N5MZ
                    object: notification
                    title:
                      en: Summer special menu
                    body:
                      en: Try the new lime lemonade for 2.50 EUR.
                    audience:
                      card_ids:
                        - crd_sophcrprtvpmmnwxah2lomnkee
                    status: queued
                    schedule_at: null
                    sent_at: null
                    stats:
                      total: 121
                      audience_resolved_count: 121
                      apple_wallet:
                        total: 98
                        delivered: 0
                        failed: 0
                      google_wallet:
                        total: 23
                        delivered: 0
                        failed: 0
                      delivered: 0
                      failed: 0
                    created_at: 1782134400
                    completed_at: null
                    request_id: req_01J9P0G7K9C0RYHXJ2K7A4N5MZ
          headers: {}
        '400':
          description: Malformed body or missing required field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '401':
          description: Missing, invalid, or revoked API key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '403':
          description: API key lacks the required scope.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '404':
          description: Resource not found in your organization.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '409':
          description: >-
            Idempotency-key reuse, balance constraint violated, or
            already-processed conflict.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
        '413':
          description: >-
            Request body exceeds the 1 MB cap. The body was never parsed, so the
            request had no effect.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error:
                  type: invalid_request_error
                  code: request_too_large
                  message: The request body exceeds the maximum allowed size.
                  doc_url: >-
                    https://vexioncards.mintlify.site/api-reference/errors#request_too_large
                  request_id: req_5f8b2c4a9e1d3
        '422':
          description: Body parsed but semantically invalid. See field_errors[].
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                Template invalid:
                  summary: Unbalanced braces in body
                  value:
                    error:
                      type: invalid_request_error
                      code: template_invalid
                      message: 'body.en: unbalanced template braces at position 14'
                      request_id: req_x1
                External id collision:
                  summary: Two customers share one external_id
                  value:
                    error:
                      type: invalid_request_error
                      code: external_id_collision
                      message: >-
                        external_id 'loyalty-789' resolves to 2 customers -
                        clean up before broadcasting
                      request_id: req_x2
                Too many locations:
                  summary: More than 25 location ids
                  value:
                    error:
                      type: invalid_request_error
                      code: location_ids_too_many
                      message: audience.location_ids accepts at most 25 ids
                      request_id: req_x3
                Mechanics variable without card scope:
                  summary: '{{card.balance}} needs audience.card_ids'
                  value:
                    error:
                      type: invalid_request_error
                      code: template_variable_requires_card_scope
                      message: >-
                        body.en references {{card.balance}} - pin
                        audience.card_ids to reward cards so every recipient has
                        this field.
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Variable not supported by card type:
                  summary: '{{card.balance}} against a stamp card'
                  value:
                    error:
                      type: invalid_request_error
                      code: template_variable_not_supported_by_card
                      message: >-
                        {{card.balance}} is not available on card type 'stamp'
                        (crd_sophcrprtvpmmnwxah2lomnkee). Available:
                        {{stamps_collected}}, {{stamps_remaining}}, ...
                      request_id: req_01HW9Q3K2YJZ7M5RX1P8VTBNC4
                Multiple locales unavailable:
                  summary: Multiple locales unavailable
                  value:
                    error:
                      type: invalid_request_error
                      code: multiple_locales_unavailable
                      message: >-
                        Send body in one language for now. Per-recipient
                        languages are not yet available for notifications.
                      request_id: req_x1
                      param: body
                Template variables unavailable:
                  summary: Template variables unavailable
                  value:
                    error:
                      type: invalid_request_error
                      code: template_variables_unavailable
                      message: >-
                        Template variables such as {{first_name}} are not yet
                        available for notifications. Send plain text.
                      request_id: req_x1
                      param: body
          headers: {}
        '429':
          description: >-
            One of the three broadcast ceilings was exceeded: the per-minute
            limit, the per-day limit (both respect Retry-After), or the
            10-concurrent-in-flight-per-organization cap (no fixed reset time,
            so no Retry-After - wait for an in-flight broadcast to finish or
            check status via GET).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                Per-minute limit:
                  summary: 1 broadcast/min exceeded
                  value:
                    error:
                      type: rate_limit_error
                      code: rate_limited_per_minute
                      message: >-
                        Broadcast rate limit is 1 per minute. Retry after 41
                        seconds.
                      retry_after: 41
                      request_id: req_x4
                Daily limit:
                  summary: 50 broadcasts/day exceeded
                  value:
                    error:
                      type: rate_limit_error
                      code: rate_limited_per_day
                      message: >-
                        Daily broadcast limit of 50 reached. Resets at midnight
                        UTC.
                      retry_after: 27340
                      request_id: req_x5
                Concurrent broadcasts exceeded:
                  summary: 10 in-flight per organization exceeded
                  value:
                    error:
                      type: rate_limit_error
                      code: concurrent_broadcasts_exceeded
                      message: >-
                        This organization already has 10 broadcasts in flight
                        (max 10). Wait for one to finish.
                      request_id: req_x6
          headers: {}
        '500':
          description: Server error. Retry-safe on idempotent calls.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
          headers: {}
      deprecated: false
components:
  schemas:
    NotificationCreate:
      type: object
      description: >-
        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`.
      required:
        - title
        - body
      properties:
        title:
          type: object
          description: >-
            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.
          minProperties: 1
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 60
          example:
            en: Summer special menu
        body:
          type: object
          description: >-
            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).
          minProperties: 1
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 240
          example:
            en: Try the new lime lemonade for 2.50 EUR.
        audience:
          anyOf:
            - $ref: '#/components/schemas/NotificationAudience'
            - type: 'null'
          description: >-
            Audience filter. Omit or send `{}` to broadcast to every active
            customer. Filters compose as INTERSECTION (AND).
        schedule_at:
          type:
            - integer
            - 'null'
          description: >-
            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.
          example: null
      example:
        title:
          en: Summer special menu
        body:
          en: Try the new lime lemonade for 2.50 EUR.
        audience:
          card_ids:
            - crd_sophcrprtvpmmnwxah2lomnkee
    Notification:
      type: object
      description: >-
        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.
      required:
        - id
        - object
        - title
        - body
        - status
        - stats
        - created_at
      properties:
        id:
          type: string
          pattern: ^ntf_[A-Za-z0-9]+$
          description: Notification identifier. Opaque, `ntf_` prefix. Immutable.
          example: ntf_01J9P0G7K9C0RYHXJ2K7A4N5MZ
        object:
          type: string
          const: notification
          description: Resource type discriminator. Always 'notification'.
          example: notification
        title:
          type: object
          description: >-
            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).
          minProperties: 1
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 60
          example:
            en: Summer special menu
        body:
          type: object
          description: >-
            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).
          minProperties: 1
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 240
          example:
            en: Try the new lime lemonade for 2.50 EUR.
        audience:
          anyOf:
            - $ref: '#/components/schemas/NotificationAudience'
            - type: 'null'
          description: >-
            The audience filter this broadcast was sent with. Null/empty =
            broadcast to all active customers.
        status:
          type: string
          enum:
            - queued
            - scheduled
            - processing
            - completed
            - partial
            - failed
            - cancelled
          description: >-
            Broadcast lifecycle:

            - `queued` - accepted, fan-out imminent

            - `scheduled` - accepted with a future `schedule_at`; cancellable
            until it fires

            - `processing` - worker batches draining

            - `completed` - 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.
          example: completed
        schedule_at:
          type:
            - integer
            - 'null'
          description: >-
            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`.
          example: null
        sent_at:
          type:
            - integer
            - 'null'
          description: >-
            Unix seconds when fan-out started - set on EVERY send (immediate and
            scheduled). Null only before dispatch.
          example: 1782134401
        stats:
          $ref: '#/components/schemas/NotificationStats'
        created_at:
          type: integer
          description: Unix seconds. Time the broadcast was accepted.
          example: 1782134400
        completed_at:
          type:
            - integer
            - 'null'
          description: >-
            Unix seconds. Populated when status reaches `completed`, `partial`,
            or `failed`. Null while queued/processing.
          example: 1782134522
        request_id:
          type: string
          description: >-
            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.
          example: req_01J9P0G7K9C0RYHXJ2K7A4N5MZ
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - type
            - code
            - message
          properties:
            type:
              type: string
              enum:
                - invalid_request_error
                - authentication_error
                - permission_error
                - not_found_error
                - rate_limit_error
                - idempotency_error
                - api_error
            code:
              type: string
            message:
              type: string
            retry_after:
              type: integer
              description: >-
                Seconds to wait before retrying. Present on rate-limit (429) and
                idempotency-key-in-use (409) errors; mirrors the `Retry-After`
                HTTP header. Absent on error types that carry no retry guidance.
              example: 5
            param:
              type:
                - string
                - 'null'
            request_id:
              type: string
            doc_url:
              type: string
              format: uri
            field_errors:
              type: array
              description: >-
                Per-field errors on a 422. At most 100 entries: a request with
                more errors gets the first 100.
              items:
                type: object
                properties:
                  param:
                    type: string
                  code:
                    type: string
                  message:
                    type: string
                  expected_currency:
                    type: string
                    description: >-
                      On `currency_mismatch`: the business currency the request
                      must use. Absent on every other field error.
          description: >-
            The error payload: type (category), code (machine-readable), message
            (human-readable), retry_after (seconds, on
            rate-limit/idempotency-in-use errors only), param (offending field),
            request_id, doc_url, field_errors[].
      description: >-
        Standard error envelope. Branch on `error.code`; include
        `error.request_id` in support requests. See the Errors guide.
    NotificationAudience:
      type: object
      description: >-
        Selects which customers receive the broadcast. Four optional id-array
        filters that compose as a logical **INTERSECTION (AND)** - never a
        union. Send an empty object `{}` (or omit `audience` entirely) to
        broadcast to every active customer in the calling organization that has
        at least one installed wallet pass.


        If you need a UNION, send separate broadcasts.
      properties:
        card_ids:
          type: array
          items:
            type: string
            pattern: ^crd_[A-Za-z0-9]+$
          minItems: 1
          maxItems: 50
          description: >-
            Limits the audience to customers holding one of the listed cards.
            1-50 `crd_` ids. Ids of another organization return `404
            not_found_error`.
          example:
            - crd_sophcrprtvpmmnwxah2lomnkee
        customer_ids:
          type: array
          items:
            type: string
            pattern: ^cus_[A-Za-z0-9]+$
          minItems: 1
          maxItems: 1000
          description: >-
            Direct cohort targeting. 1-1000 `cus_` ids. All ids must belong to
            the calling organization - an id of another organization fails the
            broadcast with `400 audience_invalid`. Ids resolving to a customer
            with no installed wallet pass are silently skipped (counted under
            neither delivered nor failed).
        external_ids:
          type: array
          items:
            type: string
          minItems: 1
          maxItems: 1000
          description: >-
            Merchant-side identifiers, 1-1000. Each resolves to a single `cus_`
            within the calling organization. Unknown external ids are silently
            skipped. If two customers share the same `external_id`
            (merchant-side data error), the broadcast fails with `422
            external_id_collision` so you can clean up before double-delivering.
          example:
            - loyalty-789
            - MEMBER-A23B-9942
        location_ids:
          type: array
          items:
            type: string
            pattern: ^loc_[A-Za-z0-9]+$
          minItems: 1
          maxItems: 25
          description: >-
            Target customers ATTRIBUTED to these locations - the location where
            they signed up or last visited. Combines with other audience keys as
            an intersection.
      example:
        card_ids:
          - crd_sophcrprtvpmmnwxah2lomnkee
    NotificationStats:
      type: object
      description: >-
        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`.
      properties:
        total:
          type: integer
          description: Total resolved recipients across all channels.
          example: 142
        audience_resolved_count:
          type: integer
          description: >-
            Recipients that matched the audience after filtering (install filter
            + location attribution). Compare against your expected cohort size.
          example: 142
        apple_wallet:
          type: object
          description: >-
            Apple Wallet channel - APNs silent push; the device refetches the
            pass and iOS renders the changeMessage banner.
          properties:
            total:
              type: integer
              description: Recipients on this channel.
            delivered:
              type: integer
              description: Device-confirmed deliveries (pass re-fetch / API accept).
            failed:
              type: integer
              description: Terminal failures (stale tokens, uninstalled passes).
          example:
            total: 98
            delivered: 96
            failed: 2
        google_wallet:
          type: object
          description: Google Wallet channel - server-side object update + message.
          properties:
            total:
              type: integer
              description: Recipients on this channel.
            delivered:
              type: integer
              description: Device-confirmed deliveries (pass re-fetch / API accept).
            failed:
              type: integer
              description: Terminal failures.
          example:
            total: 23
            delivered: 22
            failed: 1
        delivered:
          type: integer
          description: Sum of delivered across channels.
          example: 138
        failed:
          type: integer
          description: Sum of failed across channels.
          example: 4
      example:
        total: 121
        audience_resolved_count: 121
        apple_wallet:
          total: 98
          delivered: 96
          failed: 2
        google_wallet:
          total: 23
          delivered: 22
          failed: 1
        delivered: 118
        failed: 3
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: >-
        Your secret API key as a Bearer token: `Authorization: Bearer
        vxn_sk_...`. Keys are created and scoped in the dashboard - see
        [Authentication](/api-reference/introduction#authentication).

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.