Qudo.ink

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

EventWhen
card.createdA card is created in the workspace (app or API)
card.issuedSomeone gets their own copy of a card — from a join link, Members, bulk issuing or the API
member.joinedSomeone joins with a scan-to-join link
print_order.createdA print order is requested
print_order.updatedA 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.

support@qudo.ink