Webhooks
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 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.
Payload envelope
{
"id": "66a3f8b1c2d4e5f6a7b8c9d0",
"type": "balance.updated",
"createdAt": "2026-07-14T10:32:05.114Z",
"orgId": "64e2b1a0c9d8e7f6a5b4c3d2",
"livemode": true,
"data": {
"externalCustomerId": "crm_10293",
"passUserId": "66a1e2f3c9d4b5a6f7081920",
"cardId": "64f1c2a9b8e4a2d1c0a1b2c3",
"cardType": "point_card",
"balance": 245,
"change": 25
}
}livemode is false for every event a test key generated, so one endpoint can subscribe to both live and test traffic and tell them apart without a separate URL. Note also: 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 below do this for you. Because a retry can arrive up to 24 hours after the original event, each attempt is signed afresh with a fresh timestamp — verify the signature on every attempt you receive, never cache the first one.
Delivery and retries
Passtastic expects a 2xx response within 5 seconds. The first attempt is made immediately. On failure or timeout it is retried, measured from the first failure: +1 min, +5 min, +30 min, +2 h, +6 h, +12 h, +24 h. If the attempt at +24 h still fails, the delivery is marked failed and stays replayable — by hand from Settings → API & Webhooks → Webhooks, or with POST /v1/webhooks/deliveries/{id}/replay.
Because of retries and replays, the same event can arrive at your endpoint more than once — de-duplicate by the envelope's id before you act on it, rather than assuming exactly-once delivery.
Receive and verify webhooks
Verify the signature against the raw, unparsed request body before trusting the payload — a re-serialized JSON body will not match.
// 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)
if (seenEventIds.has(event.id)) return res.sendStatus(200) // de-dup a retried/replayed delivery
seenEventIds.add(event.id)
// event.type: 'customer.enrolled' | 'balance.updated' | 'reward.redeemed' | 'level.changed'
console.log(event.livemode ? 'live' : 'test', 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()
if event['id'] in seen_event_ids:
return '', 200 # de-dup a retried/replayed delivery
seen_event_ids.add(event['id'])
# event['type']: 'customer.enrolled' | 'balance.updated' | 'reward.redeemed' | 'level.changed'
print('live' if event['livemode'] else 'test', event['type'], event['data'])
return '', 200Base URL https://api.passtastic.io. See Keys and test mode for how to point a webhook endpoint at test traffic only.