Wallet loyalty, wired into your own systems
Enroll customers, award stamps and points, and read balances from your POS, CRM, or app. Passtastic keeps their Apple/Google Wallet pass updated in real time — you call a REST API and listen for webhooks, we handle the wallet plumbing.
Quickstart
Four calls: get a key, enroll a customer, award something, and listen for what happens next.
1. Get an API key
API keys are created by an org admin from Account → API & Webhooks. The raw key is shown once at creation — store it in your own secrets manager, not in source control.
2. 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.
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"
}'{
"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.
3. Award stamps or points
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"
}'{ "balance": { "points": 245 }, "transactionId": "66a1e2f3c9d4b5a6f7081920" }4. 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.
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"]
}'Authentication & keys
Every request is authenticated with a static API key — no OAuth flow, no token refresh.
X-Api-Key: YOUR_API_KEYKeys are created and revoked by an org admin from Account → API & Webhooks. A key is scoped to the org it was created in — there is no cross-org access. Passtastic keys are full-access across all four scopes below; there is currently no scope picker in the UI.
| 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 with a body like { "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 (per org + endpoint) and you get back the first response instead of double-applying the write — safe to retry on a network timeout.
Idempotency-Key: order-4821-earnResolving a customer
The {ref} path segment on earn/redeem/adjust/GET status accepts either your own externalCustomerId directly, or Passtastic's internal id prefixed with pu_ (e.g. pu_66a1e2f3c9d4b5a6f7081920).
Recipes
The four calls most integrations make first.
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.
# 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.
# 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
curl https://api.passtastic.io/api/v1/customers/crm_10293 \
-H "X-Api-Key: YOUR_API_KEY"{
"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.
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 the full signature format in Webhooks below.
// Node / Express — 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
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 '', 200Webhooks
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 counter.
Events
| 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.
Payload envelope
{
"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:
X-Passtastic-Signature: t=1752483125,v1=5f3c9a1e7b2d4c6f8a0b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b7c9d1e3ft is a Unix timestamp (seconds). v1 is hex(HMAC-SHA256(signingSecret, timestamp + "." + rawBody)) — the timestamp, a literal dot, and the exact raw request body, signed with the secret shown once when you created the webhook. Reject anything where the timestamp is more than 5 minutes old (replay protection) — the JS/Python snippets above do this for you.
Delivery & retries
Passtastic expects a 2xx 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 Account → API & Webhooks → Webhooks, where you can also replay any individual delivery by hand.
Endpoint reference
Base URL https://api.passtastic.io. Every path below is prefixed with /api (e.g. https://api.passtastic.io/api/v1/customers).
Customers & loyalty
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| POST | /v1/customers | customers:write | Enroll a customer on a card. Idempotent per externalCustomerId — calling again returns the existing enrollment. |
| GET | /v1/customers/{ref} | customers:read | Read a customer's card, balance, and wallet-install state. |
| POST | /v1/customers/{ref}/earn | loyalty:write | Award stamps or points — flat amount, spend-based, or item-based. |
| POST | /v1/customers/{ref}/redeem | loyalty:write | Redeem the card's fixed reward, or deduct an arbitrary points amount. |
| POST | /v1/customers/{ref}/adjust | loyalty:write | Apply a signed balance correction, or set an absolute points value. |
Webhooks
| Method | Endpoint | Scope | Description |
|---|---|---|---|
| POST | /v1/webhooks | webhooks:manage | Register an endpoint. The signing secret is returned once, in this response only. |
| GET | /v1/webhooks | webhooks:manage | List your webhook endpoints (secrets are never included). |
| GET | /v1/webhooks/{id} | webhooks:manage | Read a single webhook endpoint. |
| PATCH | /v1/webhooks/{id} | webhooks:manage | Update the URL, subscribed events, or status. |
| DELETE | /v1/webhooks/{id} | webhooks:manage | Remove a webhook endpoint. |
| GET | /v1/webhooks/{id}/deliveries | webhooks:manage | Recent delivery attempts for one endpoint (status, response code, timestamp). |
| POST | /v1/webhooks/deliveries/{deliveryId}/replay | webhooks:manage | Re-send a specific delivery to its original endpoint. |
Common errors
| Status | Body / cause |
|---|---|
401 | Missing or invalid X-Api-Key. |
403 | Missing scope(s): <scope> — 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. |
Build with AI
This whole reference is written so an AI coding agent can integrate Passtastic end-to-end from it — paste the docs, get a key, ship the integration.
Copy the full reference
One file: every endpoint, request/response examples, the webhook signature verification code, and the recipes above — self-contained, no crawling required. Paste it into Claude, Cursor, Codex, or any other coding agent as context before you ask it to integrate.
Copies the full API reference (llms-full.txt) to your clipboard.Starter prompt
Paste this into your agent to kick off the integration:
Integrate Passtastic loyalty using this API: https://passtastic.io/llms-full.txt — get a key from Settings → API & Webhooks