Webhooks
Subscribe to signed Organization events, verify the signature, and replay failed deliveries.
Ulsav delivers signed Organization events to your HTTPS endpoint. An Organization may run up to 20 endpoints, each with its own subscriptions, secret, retries, and delivery history. Endpoint management accepts either an API key or a signed-in Organizer session holding the same capability.
Subscribe
/api/v1/organizations/{organization_id}/webhooksRegister an endpoint in this Organization. The signing secret comes back once, in this response.
webhooks.createcurl -X POST "https://api.ulsav.com/api/v1/organizations/$ULSAV_ORG_ID/webhooks" \
-H "Authorization: Bearer $ULSAV_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Order sync",
"url": "https://example.com/webhooks/ulsav",
"eventTypes": ["order.confirmed", "registration.confirmed"]
}'The response is a flat EndpointDetail; this excerpt shows the signing fields:
{
"id": "019ae4b0-...",
"url": "https://example.com/webhooks/ulsav",
"eventTypes": ["order.confirmed", "registration.confirmed"],
"secret": "whsec_...",
"secretLast4": "a1b2"
}Store secret in your secret manager; later reads return secretLast4.
Rotate the signing secret
/api/v1/organizations/{organization_id}/webhooks/{webhook_id}/rotate-secretIssue a new signing secret and keep the previous one valid during the grace window.
webhooks.updatePOST /api/v1/organizations/{organization_id}/webhooks/{webhook_id}/rotate-secretFor 24 hours after a rotation, deliveries carry two v1 signatures: one from the new secret and one from the previous secret. Update the verifier, then let the old secret expire.
Subscriptions and filters
An endpoint subscribes to specific event types, or ["*"] for every public event.
| Filter | Narrows delivery to |
|---|---|
filters.eventIds | Events selected here |
filters.passIds | Passes selected here |
Filters reduce noise; they never widen authorization. An endpoint is an Organization resource.
The signed envelope
{
"id": "019ae4c2-...",
"type": "registration.confirmed",
"version": 1,
"api_version": "v1",
"organization_id": "019ae4b0-...",
"occurred_at": "2026-11-04T12:00:00Z",
"subject": { "type": "registration", "id": "019ae4c1-..." },
"data": { "registrationId": "019ae4c1-...", "eventId": "..." }
}| Header | Meaning |
|---|---|
Ulsav-Event-Id | Stable event id; deduplicate on this |
Ulsav-Event-Type | Public event type |
Ulsav-Delivery-Id | Stable delivery id for this endpoint |
Ulsav-Attempt | One-based attempt number |
Ulsav-Signature | t=<unix>,v1=<hex hmac> |
Ulsav-Test | true on a test delivery |
The body is the exact bytes covered by the signature. Verify before parsing; do not re-serialize in between.
Verify the signature
Ulsav-Signature is an HMAC-SHA256 over <timestamp>.<raw body>, keyed by the endpoint secret. Multiple v1 values appear during rotation.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyUlsavWebhook(rawBody, header, secret) {
const fields = Object.fromEntries(
header.split(",").map((part) => {
const [key, ...rest] = part.trim().split("=");
return [key, rest.join("=")];
}),
);
const timestamp = Number(fields.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + ".")
.update(rawBody)
.digest();
return header
.split(",")
.filter((part) => part.startsWith("v1="))
.map((part) => Buffer.from(part.slice(3), "hex"))
.some(
(candidate) =>
candidate.length === expected.length && timingSafeEqual(candidate, expected),
);
}Reject the delivery when:
- the timestamp is more than five minutes off your clock
- no
v1value matches - the body changed after verification
Acknowledge and retry
Diagram source
sequenceDiagram
participant U as Ulsav
participant E as Your endpoint
U->>E: POST signed envelope, 10-second timeout
E->>E: Verify HMAC over timestamp and raw body
alt 2xx after durable accept
E-->>U: Accepted, no retry
else timeout, 408, 425, 429, or 5xx
E-->>U: Retryable failure
U->>E: Retry, exponential from 30s, up to 8 attempts
endReturn any 2xx after durably accepting the event. The delivery request has a 10-second timeout.
| What the receiver returns | What Ulsav does |
|---|---|
A transport error, a timeout, 408, 425, 429, or 5xx | Retries |
Any other 3xx or 4xx | Terminal failure |
- Redirects are not followed.
Retry-Afteris honored, capped at 24 hours.- The default schedule is exponential from 30 seconds with jitter, up to eight attempts.
Deliveries are at least once: an event can arrive more than once and ordering is not guaranteed. Deduplicate on Ulsav-Event-Id or the envelope id, and order by occurred_at plus the time-sortable event id when it matters.
Test and replay
Send a test delivery
/api/v1/organizations/{organization_id}/webhooks/{webhook_id}/testSend a signed sample synchronously.
webhooks.updatePOST /api/v1/organizations/{organization_id}/webhooks/{webhook_id}/test
Content-Type: application/json
{ "eventType": "registration.confirmed" }The body is required; send {} to let the server pick a subscribed event type.
The envelope and signature are the same as any delivery. A test delivery differs in four ways:
| Property | Test delivery |
|---|---|
| Header | Ulsav-Test: true |
| Filters | Bypassed; the event type must still be subscribed |
| Retries | None |
| Delivery log | Recorded |
Replay a delivery
/api/v1/organizations/{organization_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/replayRequeue a terminal delivery with the same ids.
webhooks.replayPOST /api/v1/organizations/{organization_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/replay
Idempotency-Key: 0f2b4c1a-...Replay reuses the same ids.
Key reused for the same delivery
The replay already made comes back, so a network retry is safe.
Key already used for another delivery
The call fails with 409 idempotency_key_conflict.
Catalogue and limits
The public catalogue at GET /api/v1/webhook-event-types returns every type, group, payload version, filters, and a sample envelope. Launch groups:
- Event lifecycle —
event.published,event.updated,event.cancelled,event.postponed,event.archived - Passes —
pass.created,pass.updated,pass.cancelled - Orders and payments —
order.confirmed,payment.succeeded,payment.failed,refund.updated - Registrations —
registration.created,registration.confirmed,registration.cancelled,registration.submitted,registration.transferred,registration.roster_updated,registration.switch_completed,registration_approval.decided - Participants —
participant.created,participant.claimed,participant.removed,participant.transferred - Check-in —
checkin.created - Payouts —
payout.updated
Event types are never renamed once shipped; reserved names that cannot fire are absent.
Endpoints
20 per Organization
Event retention
90 days
Deliveries too
Attempt history
30 days
Test deliveries
5 per endpoint per minute
Operational checklist
- Serve the endpoint over public HTTPS.
- Verify against the raw body before parsing.
- Return
2xxquickly and move slow work to your own queue. - Deduplicate and tolerate out-of-order delivery.
- Monitor terminal failures and replay after fixing the receiver.