Webhooks — Passtastic API
☰
Passtastic
Explore
Product
Pricing
Use Cases
Customer Stories
Help
No account yet? Sign up free

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

EventFired whendata fields
customer.enrolledA new customer is created via POST /v1/customers. Not fired when enroll matched an existing customer (result: "matched") — nobody enrolled.externalCustomerId, passUserId, cardId
balance.updatedA stamp or points balance changes from earn, redeem, or adjust.externalCustomerId, passUserId, cardId, cardType, balance, change
reward.redeemedA card's fixed reward is redeemed (type: "reward").externalCustomerId, passUserId, cardId, cardType, balance, change
level.changedA 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

json
{
  "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:

http
X-Passtastic-Signature: t=1752483125,v1=5f3c9a1e7b2d4c6f8a0b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b7c9d1e3f

t 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.

javascript
// 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
# 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 '', 200

Base URL https://api.passtastic.io. See Keys and test mode for how to point a webhook endpoint at test traffic only.