Skip to main content
Before you start: the OMS API is in early access. Every endpoint, including the ones in this guide, requires an early-access API key. Request access before you begin.
OMS delivers state changes to an HTTPS endpoint you own. Delivery is at-least-once and unordered, so a correct handler does four things: verify the signature, respond quickly, skip events it has already applied, and refuse to apply state older than what it already holds. This guide covers the handler itself. For the events you can subscribe to, see the webhook events catalog.

Register your endpoint

Create the subscription with POST /webhooks. url must be a public HTTPS endpoint. subscriptions takes event type values from the catalog or "*" for all of them. The response returns a signingKey exactly once.
Store signingKey the moment you receive it. It is never returned again. If you lose it, call POST /webhooks/{webhookId}/rotate-key for a new one. The old key stops working immediately, with no overlap, so deploy the new key to your verifier at the same time.

Verify the signature

Every delivery carries a Webhook-Signature header:
v1 is a hex HMAC-SHA256 over the string <t>.<raw request body>, keyed with your signing key. Two details matter:
  • Hash the raw body, before any JSON parsing. Re-serializing the parsed object changes the bytes and the signature will not match.
  • Compare in constant time, and reject deliveries whose t differs from your current time by more than 5 minutes in either direction (|now - t| > 300 seconds). A future-dated t is rejected the same way as a stale one, so clock skew on your side cannot admit a replay. OMS regenerates t and the signature on every attempt, so a legitimate retry always carries a fresh timestamp.

Respond before you process

Return a 2xx as soon as you have verified and persisted the delivery, then do the real work asynchronously. Each attempt is bounded by the webhook’s timeoutMs (default 5000, hard cap 10000), measured from connect through response. Anything slower is recorded as a failed attempt and retried, even if your handler eventually succeeded, which turns slow processing into duplicate work. Any non-2xx response, timeout, or network error is retried. Status codes are not special-cased: returning 410 Gone does not stop delivery.

Read the envelope

Every delivery body has the same six fields:
Branch on type, then read the resource from data.resource. It has the same shape GET returns for that resource, minus empty lists. A *.statusChanged event adds data.previousStatus, and a *.updated event adds data.changedFields. See the webhook events catalog for every type. Write the handler to tolerate values it does not know:
  • Unknown type: acknowledge with 2xx and ignore it. New types can appear without a version bump.
  • Unknown subStatus: fall back to the part before the dot (processing.payoutHeld falls back to processing).
  • schema is not "v1": the event predates the current format. Ignore it or handle it separately.

Deduplicate on id and idempotencyKey

Delivery is at-least-once: OMS may deliver successfully, fail to record it, and send again. id (evt_) is the same on every retry of an event, so store it with a unique constraint and drop a delivery whose id you have already applied. Occasionally the same change is emitted twice with two different id values. Both carry the same idempotencyKey, so skip a delivery whose idempotencyKey you have already applied too.

Order by createdAt

OMS delivers in parallel, so events arrive in any order, even for the same resource. There is no sequence number. createdAt is strictly increasing per resource, so apply an event only when its createdAt is newer than the last one you applied for that resource:
createdAt is UTC with exactly three fractional digits, so the ISO strings compare correctly as text. Events about related resources can also arrive out of order: a transfer.created can land before the transaction.created it belongs to. If you receive a transfer for a transaction you have not seen, hold it until the transaction arrives, or fetch it with GET /transactions/{transactionId}. When a gap matters, read the resource from the API instead of inferring the missing step.

Test your endpoint

POST /webhooks/{webhookId}/test sends a signed test delivery to your endpoint, regardless of your subscriptions, so you can check signing and reachability before any real event fires.
Inspect what OMS sent with GET /webhooks/{webhookId}/deliveries. Filter by status, test, a createdAfter/createdBefore window, or eventId (the envelope id). Each delivery record has its own whd_ ID and nests the exact body OMS sent under event: its id, idempotencyKey, type, createdAt, schema, and data. GET /webhooks/{webhookId}/deliveries/{deliveryId} adds the HTTP attempts, with the response status, duration, and request body of each, which tells you whether a failure was your endpoint or the network.

When deliveries fail

A failed delivery is retried on a fixed schedule: That is 10 attempts across roughly 3 days, after which the delivery is marked failed. Requeue failed deliveries yourself with POST /webhooks/{webhookId}/deliveries/retry, passing explicit deliveryIds, or a status plus a createdAfter/createdBefore range. A manual retry resets the attempt count. If your endpoint stays down, two things happen on separate clocks:
  • Notification after the endpoint has been failing longer than notifyThresholdSecs (10 minutes to 1 day, default 1 hour). Sent once per down episode, not per failed attempt.
  • Auto-suspension after roughly 3 days of continuous failure.
A suspended webhook keeps creating delivery rows and records them as skipped rather than queued, so nothing is silently dropped while you fix the endpoint. Only OMS sets suspended, and it never clears it on its own: call POST /webhooks/{webhookId}/enable to return the webhook to enabled and reset the down clock, then requeue the skipped deliveries with POST /webhooks/{webhookId}/deliveries/retry.
A disabled webhook behaves differently: it creates no delivery rows at all, so events that fire while it is disabled cannot be recovered. Prefer fixing a suspended endpoint over disabling it.
Delivery history is retained for 30 days, and it is the only way to recover missed events: there is no GET /events. For anything older, read the resource’s current state from the API. DELETE /webhooks/{webhookId} is a soft delete: the webhook disappears from list and get, but its history is kept for the retention window.