Ulsav Docs

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

POST/api/v1/organizations/{organization_id}/webhooks

Register an endpoint in this Organization. The signing secret comes back once, in this response.

Auth API keyCapability webhooks.create
curl -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

POST/api/v1/organizations/{organization_id}/webhooks/{webhook_id}/rotate-secret

Issue a new signing secret and keep the previous one valid during the grace window.

Auth API keyCapability webhooks.update
POST /api/v1/organizations/{organization_id}/webhooks/{webhook_id}/rotate-secret

For 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.

FilterNarrows delivery to
filters.eventIdsEvents selected here
filters.passIdsPasses 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": "..." }
}
HeaderMeaning
Ulsav-Event-IdStable event id; deduplicate on this
Ulsav-Event-TypePublic event type
Ulsav-Delivery-IdStable delivery id for this endpoint
Ulsav-AttemptOne-based attempt number
Ulsav-Signaturet=<unix>,v1=<hex hmac>
Ulsav-Testtrue 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 v1 value 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
  end

Return any 2xx after durably accepting the event. The delivery request has a 10-second timeout.

What the receiver returnsWhat Ulsav does
A transport error, a timeout, 408, 425, 429, or 5xxRetries
Any other 3xx or 4xxTerminal failure
  • Redirects are not followed.
  • Retry-After is 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

POST/api/v1/organizations/{organization_id}/webhooks/{webhook_id}/test

Send a signed sample synchronously.

Auth API keyCapability webhooks.update
POST /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:

PropertyTest delivery
HeaderUlsav-Test: true
FiltersBypassed; the event type must still be subscribed
RetriesNone
Delivery logRecorded

Replay a delivery

POST/api/v1/organizations/{organization_id}/webhooks/{webhook_id}/deliveries/{delivery_id}/replay

Requeue a terminal delivery with the same ids.

Auth API keyCapability webhooks.replay
POST /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 2xx quickly and move slow work to your own queue.
  • Deduplicate and tolerate out-of-order delivery.
  • Monitor terminal failures and replay after fixing the receiver.

On this page