WalletPassAPI Docs

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.test event, 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-Signaturet=<unix seconds>,v1=<signature>: check it, below
Wallet-Event-IdThe event's id
Wallet-Event-TypeThe event's type
Wallet-Delivery-Attempt1 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 id never 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.