Webhooks
Get a signed HTTP request when someone joins, a card is issued or a print order changes.
2 min read
A webhook is a URL on your server that we call when something happens in your workspace. Add one in Settings → Developers → Webhooks, choose the events, and copy the signing secret (shown once).
Events
| Event | When |
|---|---|
card.created | A card is created in the workspace (app or API) |
card.issued | Someone gets their own copy of a card — from a join link, Members, bulk issuing or the API |
member.joined | Someone joins with a scan-to-join link |
print_order.created | A print order is requested |
print_order.updated | A print order changes status (confirmed, printing, shipped…) |
What we send
A POST with a JSON body:
{
"id": "evt_4f2a…",
"type": "member.joined",
"created": "2026-09-27T10:12:03.511Z",
"data": {
"member_id": "…",
"name": "Mia Member",
"email": "mia@example.com",
"role": "Members",
"email_confirmed": true,
"card_id": "…",
"template_id": "…"
}
}
For card.* events, data is the card in the same shape as the API returns. Reply with any 2xx quickly — within 5 seconds. Delivery is best effort; the last status for each endpoint shows in Developers, and Send test sends a sample event.
Check the signature
Every request carries a Qudo-Signature header: t=<unix time>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" with your signing secret. Check it before trusting the event, and reject old timestamps.
import crypto from 'node:crypto';
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
return abs(time.time() - int(parts["t"])) < 300 and hmac.compare_digest(expected, parts["v1"])
Still stuck? Ask a person.
Email us and someone from the team replies — usually within one working day.