# Passtastic API — Full Reference > Passtastic is a digital loyalty platform (Apple Wallet / Google Wallet cards) for > small and medium businesses. This file is a single, self-contained reference to the > Passtastic public REST API: enroll customers, award and redeem stamps/points, read > balances, and subscribe to webhooks — so their Apple/Google Wallet pass stays in > sync automatically. It is written for AI coding agents (Claude, Cursor, Codex, etc.) > to ingest in one fetch and use as ground truth for an integration. Everything below > reflects the actual `/v1` contract — do not invent fields, paths, or response shapes > that aren't shown here. - **Base URL:** `https://api.passtastic.io` - **Every path below is prefixed with `/api`** — e.g. `POST /v1/customers` is called as `POST https://api.passtastic.io/api/v1/customers`. - **Auth:** static API key in the `X-Api-Key` header (no OAuth flow, no token refresh). - **Get a key:** an org admin creates one from the Passtastic dashboard, under **Settings → API & Webhooks** (`https://passtastic.io/account?tab=developer`). The raw key is shown once, at creation — store it in a secrets manager, not source control. - **Human docs:** `https://passtastic.io/developers` --- ## 1. Authentication & keys Every request carries: ```http X-Api-Key: YOUR_API_KEY ``` Keys are created and revoked by an **org admin** from **Settings → API & Webhooks**. A key is scoped to the org it was created in — there is no cross-org access. Keys are currently full-access across all four scopes below; there is no scope picker in the dashboard UI yet, but the API enforces scopes server-side regardless. ### Scopes | Scope | Grants | |---|---| | `customers:read` | `GET` a customer's status/balance, or look one up by email/phone. | | `customers:write` | Enroll customers, or `PATCH` an existing one's identifier/custom fields. | | `loyalty:write` | Earn, redeem, and adjust balances. | | `webhooks:manage` | Create, list, update, delete webhook endpoints; view and replay deliveries. | A key missing a required scope gets a `403`: ```json { "message": "Missing scope(s): loyalty:write" } ``` ### Idempotency `POST /v1/customers`, `PATCH /v1/customers/{ref}`, `.../earn`, `.../redeem`, and `.../adjust` all accept an optional `Idempotency-Key` header. Retry the same request with the same key (scoped per org + endpoint) and you get back the **first** response instead of double-applying the write — safe to retry on a network timeout. ```http Idempotency-Key: order-4821-earn ``` ### Identifying customers Most of a merchant's customers signed up on their own, long before your integration existed — so they have no `externalCustomerId`. `POST /v1/customers` (enroll) finds them for you instead of creating a duplicate. It resolves in this order, scoped to the merchant's account: 1. `externalCustomerId`, exactly. 2. `email`, if you sent one and step 1 didn't match. 3. `phone`, if you sent one and steps 1–2 didn't match — **must be E.164** (a leading `+` and country code, e.g. `+34600111222`). A national-format number like `600111222` normalises to nothing and silently never matches — no error. On a match, enroll attaches your `externalCustomerId` to that existing customer and returns them: same `passUserId`, same wallet pass, same balance. Nothing is reset and no second pass is issued. From then on your own id resolves them directly. This same resolution order is used by any call that resolves a customer by email or phone, not just enroll — `GET /v1/customers/lookup` and an `email:`/`phone:` `{ref}` (on `GET`, `PATCH`, `.../earn`, `.../redeem`, `.../adjust`) use it too. If the email or phone matches **more than one** customer, you get `409 AMBIGUOUS_CUSTOMER_MATCH` listing the candidates, and nothing is written: ```json { "statusCode": 409, "error": "Conflict", "message": "AMBIGUOUS_CUSTOMER_MATCH", "matchedBy": "email", "candidates": [ { "passUserId": "66a1e2f3c9d4b5a6f7081920", "contact": "a***z@example.com" }, { "passUserId": "66a1e2f3c9d4b5a6f7081921", "contact": "a***z@example.com" } ] } ``` Passtastic will not guess which person you meant — pick one and address it directly by `pu_`, or set the identifier explicitly with `PATCH /v1/customers/{ref}` (§3). Occasionally the match succeeds but the link can't be made: your `externalCustomerId` is already attached to a *different* customer (`linkSkippedReason: "external_id_taken"`), or the matched customer already carries a *different* `externalCustomerId` of their own (`linkSkippedReason: "already_has_external_id"`). Either way you still get the right customer back, with `"linked": false` and that reason — the stamp/point call still lands on the right person; the identifier link is yours to fix (e.g. via `PATCH`). On `"already_has_external_id"` the `externalCustomerId` returned is the one that record already carried, not the one you sent. Trust it only when `linked` is `true`; otherwise address the customer by `pu_` and repair the link with `PATCH /v1/customers/{ref}`. The `{ref}` path segment — on `GET /v1/customers/{ref}`, `.../earn`, `.../redeem`, `.../adjust`, and `PATCH /v1/customers/{ref}` — accepts any of: - your own `externalCustomerId` directly, - Passtastic's internal id prefixed with `pu_` (e.g. `pu_66a1e2f3c9d4b5a6f7081920`), - `email:alex@example.com`, or - `phone:+34600111222`. `GET /v1/customers/lookup` (§3) is the separate, query-param alternative that skips `{ref}` entirely — prefer it over the `email:`/`phone:` form when you have a choice, since it keeps the address out of URLs and access logs. --- ## 2. Quickstart (four calls) ### 2.1 Enroll a customer `cardId` is the loyalty program (`BusinessCard`) to enroll them on. `externalCustomerId` is your own identifier (CRM contact id, user id, etc.) — Passtastic uses it to resolve the customer on every later call. Send `email` and `phone` too whenever you have them: if this person already signed up on their own — through the merchant's own sign-up page, an import, or the counter — Passtastic matches them by email or phone and attaches your `externalCustomerId` to the card they already carry, instead of issuing a second one. See "Identifying customers" (§1) for the full resolution order. ```bash curl -X POST https://api.passtastic.io/api/v1/customers \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalCustomerId": "crm_10293", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "name": "Alex Rivera", "email": "alex@example.com" }' ``` ```json { "passUserId": "66a1e2f3c9d4b5a6f7081920", "externalCustomerId": "crm_10293", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "result": "created", "matchedBy": null, "linked": true, "installUrl": "https://passtastic.io/get-pass/64f1c2a9b8e4a2d1c0a1b2c3?code=8K3F2Q", "status": "pending_install" } ``` `result` is `"created"` for a brand-new customer, `"matched"` when Passtastic recognised an existing one — both return `201`, so branch on `result`, not the status code. Send them `installUrl` — opening it on a phone adds the card to Apple or Google Wallet. Nothing to build here; the wallet pass is served by Passtastic. Optional body fields: `name`, `phone`, `email`, `initialPoints` (a non-negative number to seed the starting balance, applied only on a fresh `"created"` enrollment), `customFields` (the merchant's own sign-up-form answers — §7). ### 2.2 Award stamps or points ```bash curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/earn \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "points", "from": "spend", "spend": { "amount": 24.50, "currency": "EUR" }, "note": "Order #4821" }' ``` ```json { "balance": { "points": 245 }, "transactionId": "66a1e2f3c9d4b5a6f7081920" } ``` ### 2.3 Subscribe to a webhook So your system finds out about redemptions or level changes that happen from the merchant's scanner, not just the calls you make yourself. ```bash curl -X POST https://api.passtastic.io/api/v1/webhooks \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-system.com/hooks/passtastic", "events": ["balance.updated", "reward.redeemed"] }' ``` ```json { "id": "66a3f8b1c2d4e5f6a7b8c9d0", "url": "https://your-system.com/hooks/passtastic", "events": ["balance.updated", "reward.redeemed"], "signingSecret": "whsec_2f8a1c9e7b3d4f6a0c1e3b5d7f9a1c3e5b7d9f1a3c5e7b9d" } ``` `signingSecret` is returned **once**, in this response only — save it. You'll need it to verify delivery signatures (§5). ### 2.4 Read a customer's balance ```bash curl https://api.passtastic.io/api/v1/customers/crm_10293 \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "externalCustomerId": "crm_10293", "passUserId": "66a1e2f3c9d4b5a6f7081920", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "cardType": "point_card", "balance": { "points": 245 }, "installed": true, "installUrl": "https://passtastic.io/get-pass/64f1c2a9b8e4a2d1c0a1b2c3" } ``` `balance` shape depends on the card: `{ stamps }` for stamp cards, `{ points }` for point cards, `{ points, level }` for level cards. --- ## 3. Endpoint reference — Customers & loyalty ### `POST /v1/customers` — enroll a customer **Scope:** `customers:write`. Matches an existing customer by `externalCustomerId`, then `email`, then `phone`, before creating one — see "Identifying customers" (§1) for the full resolution order, the `409 AMBIGUOUS_CUSTOMER_MATCH` conflict, and `linked: false`. Request body: | Field | Type | Required | Notes | |---|---|---|---| | `externalCustomerId` | string | yes | Your own identifier for this customer. | | `cardId` | string (Mongo id) | yes | The `BusinessCard` (loyalty program) to enroll them on. Must belong to your org. | | `name` | string | no | | | `phone` | string | no | Also used for matching (§1) — must be E.164. | | `email` | string | no | Also used for matching (§1). | | `initialPoints` | number (>= 0) | no | Seed the starting balance. Applied only on a fresh `"created"` enrollment. | | `customFields` | object (string → scalar) | no | Keyed by the card's data-collection field key, case-insensitive. Unknown, reserved (`name`, `email`, `phone`, `status`, `custom`), or non-scalar values are `400`, naming the offending key. | Response `201` — a match returns `201` too, the same as a create; branch on `result`, not the status code: ```json { "passUserId": "66a1e2f3c9d4b5a6f7081920", "externalCustomerId": "crm_10293", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "result": "created", "matchedBy": null, "linked": true, "installUrl": "https://passtastic.io/get-pass/64f1c2a9b8e4a2d1c0a1b2c3?code=8K3F2Q", "status": "pending_install" } ``` `result` is `"created"` or `"matched"`. `matchedBy` is `"external_id"`, `"email"`, `"phone"`, or `null` on a fresh create. `linked` is `false` only when a match succeeded but the identifier couldn't be attached — then a `linkSkippedReason` of `"already_has_external_id"` or `"external_id_taken"` is included (§1). ### `GET /v1/customers/lookup` — find a customer by email or phone **Scope:** `customers:read`. Query with `email` and/or `phone` — no `externalCustomerId` needed. Response shape is identical to `GET /v1/customers/{ref}` below. Passing the same query key twice is rejected, not silently resolved to the first or last value: | Status | Cause | |---|---| | `400` | `EMAIL_MUST_BE_A_SINGLE_VALUE` — `email` given more than once. | | `400` | `PHONE_MUST_BE_A_SINGLE_VALUE` — `phone` given more than once. | | `400` | `EMAIL_OR_PHONE_REQUIRED` — neither `email` nor `phone` supplied. | ```bash curl "https://api.passtastic.io/api/v1/customers/lookup?email=alex@example.com" \ -H "X-Api-Key: YOUR_API_KEY" ``` Response `200`: ```json { "externalCustomerId": "crm_10293", "passUserId": "66a1e2f3c9d4b5a6f7081920", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "cardType": "point_card", "balance": { "points": 245 }, "installed": true, "installUrl": "https://passtastic.io/get-pass/64f1c2a9b8e4a2d1c0a1b2c3" } ``` ### `GET /v1/customers/{ref}` — read status & balance **Scope:** `customers:read`. `{ref}` accepts any of the forms described in "Identifying customers" (§1). Response `200`: ```json { "externalCustomerId": "crm_10293", "passUserId": "66a1e2f3c9d4b5a6f7081920", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "cardType": "point_card", "balance": { "points": 245 }, "installed": true, "installUrl": "https://passtastic.io/get-pass/64f1c2a9b8e4a2d1c0a1b2c3" } ``` ### `PATCH /v1/customers/{ref}` — set the external id or update custom fields **Scope:** `customers:write`. `{ref}` accepts any of the forms from "Identifying customers" (§1) — including `email:`/`phone:`, which makes this the way to attach your `externalCustomerId` to a customer who signed up on their own, without waiting for them to earn or redeem first (e.g. when rolling an integration onto an account that already has customers — §7). | Field | Type | Required | Notes | |---|---|---|---| | `externalCustomerId` | string | no | Send to attach or change the identifier. | | `customFields` | object (string → scalar) | no | Same validation as on enroll — case-insensitive field key; unknown, reserved, or non-scalar values are `400`. | ```bash curl -X PATCH https://api.passtastic.io/api/v1/customers/email:alex@example.com \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "externalCustomerId": "crm_10293" }' ``` ```json { "passUserId": "66a1e2f3c9d4b5a6f7081920", "externalCustomerId": "crm_10293", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "updated": ["externalCustomerId"] } ``` `updated` lists which fields were actually written — just `["externalCustomerId"]` here, or `["customFields", "externalCustomerId"]` when you set both in one call. An `externalCustomerId` already in use by a *different* customer returns `409 EXTERNAL_ID_TAKEN` and writes nothing. ### `POST /v1/customers/{ref}/earn` — award stamps or points **Scope:** `loyalty:write`. Three modes, selected by `type` + `from`: | Field | Type | Required | Notes | |---|---|---|---| | `type` | `"points"` \| `"stamps"` | yes | Which counter to increment. | | `amount` | number (> 0) | no | Flat amount to add. Used when `from` is omitted. | | `from` | `"spend"` \| `"items"` | no | Derive the amount from a sale instead of a flat number. | | `spend` | `{ amount: number, currency: string }` | when `from: "spend"` | `amount` is in the minor currency unit's *major* form (e.g. `24.50` EUR); Passtastic applies the card's configured accrual rate. | | `items` | `[{ key: string, quantity: number }]` | when `from: "items"` | `key` is an item key configured in the card's accrual rules (e.g. `"coffee"` → 1 stamp each). | | `note` | string | no | Freeform note attached to the transaction. | Flat amount: ```bash curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/earn \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "type": "stamps", "amount": 1, "note": "Walk-in visit" }' ``` Spend-based: ```bash curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/earn \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "type": "points", "from": "spend", "spend": { "amount": 24.50, "currency": "EUR" }, "note": "Order #4821" }' ``` Item-based (e.g. "one stamp per coffee"): ```bash curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/earn \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "type": "stamps", "from": "items", "items": [{ "key": "coffee", "quantity": 1 }] }' ``` Response `200` (shape depends on `type`): ```json { "balance": { "points": 245 }, "transactionId": "66a1e2f3c9d4b5a6f7081920" } ``` ```json { "balance": { "stamps": 6 }, "transactionId": "66a1e2f3c9d4b5a6f7081920" } ``` ### `POST /v1/customers/{ref}/redeem` — redeem the reward or deduct points **Scope:** `loyalty:write`. | Field | Type | Required | Notes | |---|---|---|---| | `type` | `"points"` \| `"reward"` | yes | `"points"` deducts an arbitrary amount (point cards only). `"reward"` redeems the card's fixed configured reward (stamp or point cards). | | `amount` | number (> 0) | required when `type: "points"` | Must be a positive number — a missing/zero amount is rejected (`400`) rather than silently applied. | | `note` | string | no | | Deduct an arbitrary points amount: ```bash curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/redeem \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "type": "points", "amount": 50, "note": "Partial redemption" }' ``` ```json { "balance": { "points": 195 }, "transactionId": "66a1e2f3c9d4b5a6f7081920" } ``` Redeem the card's fixed reward (stamp card example): ```bash curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/redeem \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "type": "reward", "note": "Free coffee redeemed at counter" }' ``` ```json { "balance": { "stamps": 0 }, "transactionId": "66a1e2f3c9d4b5a6f7081920" } ``` `type: "reward"` against a card type that doesn't support a fixed reward (e.g. a level card) returns `400 UNSUPPORTED_FOR_CARD_TYPE`. ### `POST /v1/customers/{ref}/adjust` — signed correction or absolute set **Scope:** `loyalty:write`. Exactly **one** of `balanceCorrection` / `setPoints` is required — sending both, or neither, is a `400`. | Field | Type | Required | Notes | |---|---|---|---| | `balanceCorrection` | number, non-zero | exactly one of these two | Signed delta applied to the current balance. Works on stamp, point, and level cards. A `0` value is rejected (`400`) — it's a no-op. | | `setPoints` | number (>= 0) | exactly one of these two | Absolute value to set the points balance to. Point/level cards only — stamp cards `400 UNSUPPORTED_FOR_CARD_TYPE`. | | `note` | string | no | | Signed correction (e.g. correcting an over-award): ```bash curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/adjust \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "balanceCorrection": -10, "note": "Correcting duplicate scan" }' ``` ```json { "balance": { "points": 235 }, "transactionId": "66a1e2f3c9d4b5a6f7081920" } ``` Absolute set (e.g. syncing from an external system of record): ```bash curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/adjust \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "setPoints": 300, "note": "Reconciled from CRM export" }' ``` ```json { "balance": { "points": 300 }, "transactionId": "66a1e2f3c9d4b5a6f7081920" } ``` --- ## 4. Endpoint reference — Webhooks All endpoints below require **scope `webhooks:manage`**. ### `POST /v1/webhooks` — register an endpoint Request: | Field | Type | Required | Notes | |---|---|---|---| | `url` | string (http/https URL) | yes | Endpoint that receives deliveries. | | `events` | string[] | yes, min 1 | Event types to subscribe to (§6), or `["*"]` for all of them. | ```bash curl -X POST https://api.passtastic.io/api/v1/webhooks \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "url": "https://your-system.com/hooks/passtastic", "events": ["*"] }' ``` ```json { "id": "66a3f8b1c2d4e5f6a7b8c9d0", "url": "https://your-system.com/hooks/passtastic", "events": ["*"], "signingSecret": "whsec_2f8a1c9e7b3d4f6a0c1e3b5d7f9a1c3e5b7d9f1a3c5e7b9d" } ``` `signingSecret` is shown **exactly once**, in this response — it is never returned by list/get/update. ### `GET /v1/webhooks` — list your endpoints ```bash curl https://api.passtastic.io/api/v1/webhooks -H "X-Api-Key: YOUR_API_KEY" ``` ```json [ { "id": "66a3f8b1c2d4e5f6a7b8c9d0", "url": "https://your-system.com/hooks/passtastic", "events": ["*"], "status": "active", "createdAt": "2026-07-01T09:12:03.000Z" } ] ``` (`signingSecret` is never included here.) ### `GET /v1/webhooks/{id}` — read one endpoint ```bash curl https://api.passtastic.io/api/v1/webhooks/66a3f8b1c2d4e5f6a7b8c9d0 \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "id": "66a3f8b1c2d4e5f6a7b8c9d0", "url": "https://your-system.com/hooks/passtastic", "events": ["*"], "status": "active", "createdAt": "2026-07-01T09:12:03.000Z" } ``` ### `PATCH /v1/webhooks/{id}` — update URL, events, or status Body fields are all optional — send only what you're changing. | Field | Type | Notes | |---|---|---| | `url` | string (http/https URL) | | | `events` | string[] (min 1) | | | `status` | `"active"` \| `"disabled"` | Disable an endpoint without deleting it. | ```bash curl -X PATCH https://api.passtastic.io/api/v1/webhooks/66a3f8b1c2d4e5f6a7b8c9d0 \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "status": "disabled" }' ``` ```json { "id": "66a3f8b1c2d4e5f6a7b8c9d0", "url": "https://your-system.com/hooks/passtastic", "events": ["*"], "status": "disabled", "createdAt": "2026-07-01T09:12:03.000Z" } ``` ### `DELETE /v1/webhooks/{id}` — remove an endpoint ```bash curl -X DELETE https://api.passtastic.io/api/v1/webhooks/66a3f8b1c2d4e5f6a7b8c9d0 \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "deleted": true } ``` ### `GET /v1/webhooks/{id}/deliveries` — recent delivery attempts ```bash curl https://api.passtastic.io/api/v1/webhooks/66a3f8b1c2d4e5f6a7b8c9d0/deliveries \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json [ { "id": "66a4a0c2d3e4f5a6b7c8d9e0", "subscriptionId": "66a3f8b1c2d4e5f6a7b8c9d0", "eventId": "66a3f8b1c2d4e5f6a7b8c9d1", "eventType": "balance.updated", "status": "success", "attempts": 1, "lastResponseCode": 200, "lastError": null, "createdAt": "2026-07-14T10:32:05.114Z" } ] ``` ### `POST /v1/webhooks/deliveries/{deliveryId}/replay` — resend one delivery ```bash curl -X POST https://api.passtastic.io/api/v1/webhooks/deliveries/66a4a0c2d3e4f5a6b7c8d9e0/replay \ -H "X-Api-Key: YOUR_API_KEY" ``` ```json { "replayed": true, "deliveryId": "66a4a0c2d3e4f5a6b7c8d9e0", "eventType": "balance.updated" } ``` --- ## 5. Webhooks — delivery, payload, and signature verification Subscribe once, and Passtastic pushes an event every time a customer's balance changes — whether it happened through your API call or a scan at the merchant's counter. ### Delivery & retries Passtastic expects a `2xx` response within 5 seconds. A non-2xx or timeout is retried up to 3 attempts total with a short backoff between them. Every attempt — success or failure — is recorded and visible (with the response code) in the dashboard under **Settings → API & Webhooks → Webhooks**, where you can also replay any individual delivery by hand (or via the API — §4). ### Payload envelope Every delivery is a `POST` with this JSON body: ```json { "id": "66a3f8b1c2d4e5f6a7b8c9d0", "type": "balance.updated", "createdAt": "2026-07-14T10:32:05.114Z", "orgId": "64e2b1a0c9d8e7f6a5b4c3d2", "data": { "externalCustomerId": "crm_10293", "passUserId": "66a1e2f3c9d4b5a6f7081920", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "cardType": "point_card", "balance": 245, "change": 25 } } ``` Note: `data.balance` here is a **single number** (the card's primary counter — points or stamps), not the `{ points, stamps, level }` object returned by `GET /v1/customers/{ref}`. ### Signature Every delivery carries an `X-Passtastic-Signature` header: ```http X-Passtastic-Signature: t=1752483125,v1=5f3c9a1e7b2d4c6f8a0b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b7c9d1e3f ``` - `t` — Unix timestamp (seconds) the delivery was signed at. - `v1` — `hex(HMAC-SHA256(signingSecret, "{t}.{rawBody}"))` — the timestamp, a literal dot, and the **exact raw request body** (not re-serialized JSON), signed with the secret shown once when you created the webhook (§4). Reject anything where the timestamp is more than 5 minutes old (replay protection). Always verify against the raw, unparsed request body — re-serializing parsed JSON before hashing will produce a different byte sequence and a false signature mismatch. **Node / Express:** ```javascript // Mount with a raw-body parser so rawBody is the exact bytes Passtastic sent // (JSON.stringify'd server-side, not re-serialized). const crypto = require('crypto') function verifyPasstasticSignature(header, rawBody, secret, toleranceSec = 300) { const parts = Object.fromEntries(header.split(',').map(p => p.split('='))) const timestamp = Number(parts.t) if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex') const a = Buffer.from(parts.v1 || '', 'hex') const b = Buffer.from(expected, 'hex') return a.length === b.length && crypto.timingSafeEqual(a, b) } app.post('/hooks/passtastic', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.header('X-Passtastic-Signature') || '' const rawBody = req.body.toString('utf8') if (!verifyPasstasticSignature(signature, rawBody, process.env.PASSTASTIC_WEBHOOK_SECRET)) { return res.status(401).send('invalid signature') } const event = JSON.parse(rawBody) // event.type: 'customer.enrolled' | 'balance.updated' | 'reward.redeemed' | 'level.changed' console.log(event.type, event.data) res.sendStatus(200) }) ``` **Python / Flask:** ```python import hashlib import hmac import time def verify_passtastic_signature(header, raw_body, secret, tolerance_sec=300): parts = dict(p.split('=', 1) for p in header.split(',')) timestamp = int(parts.get('t', 0)) if not timestamp or abs(time.time() - timestamp) > tolerance_sec: return False expected = hmac.new( secret.encode('utf-8'), f'{timestamp}.{raw_body}'.encode('utf-8'), hashlib.sha256, ).hexdigest() return hmac.compare_digest(parts.get('v1', ''), expected) @app.route('/hooks/passtastic', methods=['POST']) def passtastic_webhook(): signature = request.headers.get('X-Passtastic-Signature', '') raw_body = request.get_data(as_text=True) if not verify_passtastic_signature(signature, raw_body, PASSTASTIC_WEBHOOK_SECRET): return 'invalid signature', 401 event = request.get_json() # event['type']: 'customer.enrolled' | 'balance.updated' | 'reward.redeemed' | 'level.changed' print(event['type'], event['data']) return '', 200 ``` --- ## 6. Webhook event catalog | Event | Fired when | `data` fields | |---|---|---| | `customer.enrolled` | A new customer is created via `POST /v1/customers` (`result: "created"`). Not fired when enroll matched an existing customer (`result: "matched"`) — nobody enrolled. | `externalCustomerId, passUserId, cardId` | | `balance.updated` | A stamp or points balance changes from earn, redeem, or adjust. | `externalCustomerId, passUserId, cardId, cardType, balance, change` | | `reward.redeemed` | A card's fixed reward is redeemed (`type: "reward"`). | `externalCustomerId, passUserId, cardId, cardType, balance, change` | | `level.changed` | A level card's tier changes from a balance correction. | `externalCustomerId, passUserId, cardId, balance, change` | Subscribe to specific events, or pass `["*"]` for all of them. --- ## 7. Recipes ### Earn points from a sale Pass the raw sale — Passtastic applies the card's configured accrual rate. Use `from: "spend"` for a currency amount, or `from: "items"` when your POS tracks earn-eligible items by key (e.g. one stamp per coffee, regardless of price). Omit `from` and pass a flat `amount` to add a specific number of points or stamps directly. ```bash # Item-based (e.g. "one stamp per coffee") curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/earn \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "stamps", "from": "items", "items": [{ "key": "coffee", "quantity": 1 }] }' ``` ### Sync a customer from your CRM or POS Call enroll on every sync or sign-in — it's safe to repeat. A customer who already carries your `externalCustomerId` comes back unchanged (`result: "matched"`, `matchedBy: "external_id"`). One who signed up through the merchant's own page, with no `externalCustomerId` yet, is matched on email or phone instead and linked to your id (§1). Either way you get one customer and one wallet pass — never a duplicate. You don't need to check first, but if you want to, `GET /v1/customers/lookup` (§3) answers the same question without writing anything. On a match, `email`, `phone` and `name` are used to find the customer but are not written to their record; `customFields` are. There is currently no API path that updates a customer's stored contact details — `PATCH /v1/customers/{ref}` accepts only `externalCustomerId` and `customFields`, and `name`/`email`/`phone` are reserved keys that `customFields` itself rejects. ```bash # Safe to repeat. Send email/phone so an existing customer is matched # rather than duplicated. curl -X POST https://api.passtastic.io/api/v1/customers \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalCustomerId": "crm_10293", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "email": "alex@example.com", "phone": "+34600111222" }' ``` ```json { "passUserId": "66a1e2f3c9d4b5a6f7081920", "externalCustomerId": "crm_10293", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "result": "matched", "matchedBy": "email", "linked": true, "installUrl": "https://passtastic.io/get-pass/64f1c2a9b8e4a2d1c0a1b2c3?code=8K3F2Q", "status": "installed" } ``` ### Store the merchant's own sign-up questions If the card asks its customers for extra details — a member number, a national id, a birthday — send them as `customFields`, keyed by the field key from the card's sign-up form (case-insensitive). They're stored exactly as the sign-up page stores them, so they show up in the merchant's customer list, search, and CSV export alongside everyone else's data. A key the card doesn't ask for, one of the reserved keys (`name`, `email`, `phone`, `status`, `custom`), or a non-scalar value is a `400` naming the offending key — not a silent drop. ```bash curl -X POST https://api.passtastic.io/api/v1/customers \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalCustomerId": "crm_10293", "cardId": "64f1c2a9b8e4a2d1c0a1b2c3", "email": "alex@example.com", "customFields": { "member_number": "A-4471" } }' ``` ### Adopt a merchant's existing customers Rolling an integration onto an account that already has customers? You don't have to wait for each of them to transact — look them up by email (or phone) and set your identifier directly with `PATCH` (§3). ```bash curl -X PATCH https://api.passtastic.io/api/v1/customers/email:alex@example.com \ -H "X-Api-Key: YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalCustomerId": "crm_10293" }' ``` An `externalCustomerId` already in use by a different customer returns `409 EXTERNAL_ID_TAKEN` and writes nothing. ### Read a customer's balance ```bash curl https://api.passtastic.io/api/v1/customers/crm_10293 \ -H "X-Api-Key: YOUR_API_KEY" ``` `balance` shape depends on the card: `{ stamps }` for stamp cards, `{ points }` for point cards, `{ points, level }` for level cards. ### Receive and verify webhooks Every delivery is a `POST` with an `X-Passtastic-Signature` header. Verify it against the **raw, unparsed** request body before trusting the payload — see §5 for the full signature format and JS/Python verification code. ### Reconcile a balance from an external system of record Use `adjust` with `setPoints` to force a customer's points balance to an absolute value (e.g. after a nightly CRM reconciliation job), rather than computing and applying a delta yourself: ```bash curl -X POST https://api.passtastic.io/api/v1/customers/crm_10293/adjust \ -H "X-Api-Key: YOUR_API_KEY" -H "Content-Type: application/json" \ -d '{ "setPoints": 300, "note": "Reconciled from CRM export" }' ``` --- ## 8. Common errors | Status | Body / cause | |---|---| | `401` | Missing or invalid `X-Api-Key`. | | `403` | `Missing scope(s): ` — the key isn't authorized for this call. | | `403` | `CARD_NOT_IN_ORG` — the `cardId` doesn't belong to your org. | | `404` | `CARD_NOT_FOUND` / `CUSTOMER_NOT_FOUND` — check the `cardId`/`{ref}`. | | `409` | `AMBIGUOUS_CUSTOMER_MATCH` — a call that resolves a customer by email or phone (enroll, `GET /v1/customers/lookup`, or an `email:`/`phone:` `{ref}`) matched more than one existing customer; nothing written. See "Identifying customers" (§1). | | `409` | `EXTERNAL_ID_TAKEN` — `PATCH /v1/customers/{ref}` tried to set an `externalCustomerId` already used by a different customer; nothing written. | | `400` | `UNSUPPORTED_FOR_CARD_TYPE` — e.g. redeeming a level card's fixed reward, or setting points on a stamp card. | | `400` | `EMAIL_MUST_BE_A_SINGLE_VALUE` / `PHONE_MUST_BE_A_SINGLE_VALUE` — `GET /v1/customers/lookup` called with `email` or `phone` repeated in the query string. | | `400` | `EMAIL_OR_PHONE_REQUIRED` — `GET /v1/customers/lookup` called with neither `email` nor `phone`. | | `400` | `RESERVED_CUSTOM_FIELDS` / `UNKNOWN_CUSTOM_FIELDS` / `INVALID_CUSTOM_FIELD_VALUES` — a `customFields` key is reserved (`name`/`email`/`phone`/`status`/`custom`), unknown to the card, or a non-scalar value; the error names the offending key. | | `400` | Field validation errors (missing/malformed body fields) — standard Nest validation-error shape. | --- ## 9. See also - Curated index: `https://passtastic.io/llms.txt` - Human-readable docs: `https://passtastic.io/developers` - Create or manage API keys and webhooks (Settings → API & Webhooks): `https://passtastic.io/account?tab=developer`