Guides
Webhooks
Hear about installs, saves, changes and expiring credentials as they happen.
A webhook tells your server when something happens to your passes: a holder adds one to Apple Wallet or saves it to Google Wallet, removes it, or a pass changes. Rather than asking the API, you're told, within seconds.
Add an endpoint
In the dashboard, open Webhooks and add your endpoint's URL, choosing which events it gets (or all of them). Endpoints are set up there, by the workspace's owner or an admin, never with an API key.
- The URL must be HTTPS, on a public host.
- You get its signing secret,
whsec_…, once: keep it with your server's secrets. Rotate secret makes a new one, and the old one stops at once. - Send test sends a
webhook.testevent, to check it's working.
A workspace can have up to 20 endpoints.
What arrives
Each event is a POST with a JSON body:
{
"id": "evt_2f6c9a0d1b7e4c55",
"type": "pass.apple.installed",
"createdAt": "2026-10-08T19:31:12.000Z",
"data": {
"serialNumber": "9f904677-e06d-4b0f-8404-878ff1066f88",
"externalId": "crm-1042",
"status": "active",
"appleDevices": 1
}
}With these headers:
| Header | |
|---|---|
Wallet-Signature | t=<unix seconds>,v1=<signature>: check it, below |
Wallet-Event-Id | The event's id |
Wallet-Event-Type | The event's type |
Wallet-Delivery-Attempt | 1 for the first try, then 2, 3… |
Pass events carry the pass's serialNumber, externalId and status, and never its holder's details: get the pass when you need them. Webhook events lists what each event adds.
Check the signature
Anyone can send a request to your URL, so check that each one came from us before acting on it. The signature is an HMAC-SHA256 of the timestamp, a dot and the body, with your signing secret as the key, in hex.
Use the body exactly as it arrived, before parsing it: parsing and writing it out again changes the bytes, and the check fails. Reject a timestamp more than 5 minutes old, so a delivery someone captured can't be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body as a string, exactly as received.
export function verifyWebhook(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return parts.v1?.length === expected.length && timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}import hashlib
import hmac
import time
# raw_body: the request body as bytes, exactly as received.
def verify_webhook(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))<?php
// $rawBody: the request body as a string, exactly as received: file_get_contents("php://input").
function verify_webhook(string $rawBody, string $header, string $secret): bool
{
$parts = [];
foreach (explode(",", $header) as $part) {
[$key, $value] = array_pad(explode("=", trim($part), 2), 2, "");
$parts[$key] = $value;
}
$t = (int) ($parts["t"] ?? 0);
if ($t === 0 || abs(time() - $t) > 300) {
return false;
}
$expected = hash_hmac("sha256", $t . "." . $rawBody, $secret);
return hash_equals($expected, $parts["v1"] ?? "");
}The key is the whole secret, whsec_ included.
Answer quickly
Answer with any 2xx status within 10 seconds, and do the work afterwards, such as on a queue. Anything else counts as a failure: another status, no answer in time, or a redirect, which isn't followed.
A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours: 8 tries over about a day. The dashboard shows each endpoint's recent deliveries, with what your server answered, and Replay sends one again.
Expect repeats, in any order
- The same event can arrive twice, such as when your server did the work but its answer was lost, or when a delivery is replayed from the dashboard. Its
idnever changes, so keep the ids you've handled and skip one you've seen. - Events can arrive out of order. A retry can land after a later event. Use
createdAt, or get the pass for its state now, rather than trusting the order they came in.
Deliveries and their events are kept for 30 days.