# 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. | | `customers:write` | Enroll customers. | | `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`, `.../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 ``` ### Resolving a customer The `{ref}` path segment on earn/redeem/adjust/`GET` status accepts **either**: - your own `externalCustomerId` directly (the value you passed at enroll), or - Passtastic's internal id prefixed with `pu_` (e.g. `pu_66a1e2f3c9d4b5a6f7081920`). --- ## 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. Enrollment is **idempotent** per `(org, externalCustomerId)`: calling it again for the same `externalCustomerId` returns the existing enrollment instead of erroring or creating a duplicate pass. ```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", "installUrl": "https://passtastic.io/get-pass/64f1c2a9b8e4a2d1c0a1b2c3?code=8K3F2Q", "status": "pending_install" } ``` 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). ### 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`. Idempotent per `externalCustomerId` — calling again returns the existing enrollment. 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 | | | `email` | string | no | | | `initialPoints` | number (>= 0) | no | Seed the starting balance. | Response `200`: ```json { "passUserId": "66a1e2f3c9d4b5a6f7081920", "externalCustomerId": "crm_10293", "installUrl": "https://passtastic.io/get-pass/64f1c2a9b8e4a2d1c0a1b2c3?code=8K3F2Q", "status": "pending_install" } ``` ### `GET /v1/customers/{ref}` — read status & balance **Scope:** `customers:read`. `{ref}` = `externalCustomerId` or `pu_`. 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" } ``` ### `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` (not on a repeat/idempotent enroll). | `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 by external ID Enroll is idempotent per `(org, externalCustomerId)` — call it from every CRM sync or sign-in without worrying about duplicates. A repeat call for the same `externalCustomerId` returns the existing enrollment (same `passUserId`/`installUrl`) instead of creating a second pass. ```bash # Safe to call again with the same externalCustomerId — returns the # existing enrollment instead of erroring or duplicating the pass. 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" }' ``` ### 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}`. | | `400` | `UNSUPPORTED_FOR_CARD_TYPE` — e.g. redeeming a level card's fixed reward, or setting points on a stamp card. | | `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`