> ## Documentation Index
> Fetch the complete documentation index at: https://docs.polygon.technology/llms.txt
> Use this file to discover all available pages before exploring further.

# Receiving webhooks

> Build an endpoint that verifies OMS webhook deliveries and applies them safely, without double-processing or applying stale state.

<Note>
  **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](https://info.polygon.technology/get-early-access?utm_source=docs\&utm_medium=card\&utm_campaign=oms_access) before you begin.
</Note>

OMS delivers state changes to an HTTPS endpoint you own. Delivery is at-least-once and ordering is best effort, so a correct handler does four things: verify the signature, respond quickly, ignore deliveries it has already applied, and refuse to apply state older than what it already has.

This guide covers the handler itself. For the events you can subscribe to and the envelope they arrive in, see the [webhook events catalog](/api-reference/webhook-events).

## Register your endpoint

Create the subscription with `POST /webhooks`. The response returns a `signingKey` exactly once.

```bash theme={null}
curl -X POST https://sandbox-api.polygon.technology/v0.11/webhooks \
  -H "Authorization: Bearer {accessToken}" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: whk-primary-001" \
  -d '{
    "url": "https://api.yourapp.com/webhooks/oms",
    "subscriptions": ["*"]
  }'
```

<Warning>
  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, which invalidates the old key.
</Warning>

## Verify the signature

Every delivery carries a `Webhook-Signature` header:

```text theme={null}
Webhook-Signature: t=1767225600,v1=5d41402abc4b2a76b9719d911017c592...
```

`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` is too old to limit replay. OMS regenerates `t` and the signature on every attempt, so a legitimate retry always carries a fresh timestamp.

<CodeGroup>
  ```ts Node theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const SIGNING_KEY = process.env.OMS_WEBHOOK_SIGNING_KEY!;
  const TOLERANCE_SECONDS = 300;

  function verify(rawBody: Buffer, header: string | undefined): boolean {
    if (!header) return false;

    const parts = new Map(
      header.split(",").map((kv) => {
        const [k, v] = kv.split("=");
        return [k.trim(), v?.trim() ?? ""];
      }),
    );
    const timestamp = parts.get("t");
    const signature = parts.get("v1");
    if (!timestamp || !signature) return false;

    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
    if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

    const expected = crypto
      .createHmac("sha256", SIGNING_KEY)
      .update(`${timestamp}.`)
      .update(rawBody)
      .digest();
    const received = Buffer.from(signature, "hex");

    return (
      received.length === expected.length &&
      crypto.timingSafeEqual(received, expected)
    );
  }

  // express.raw keeps the exact bytes OMS signed.
  app.post(
    "/webhooks/oms",
    express.raw({ type: "application/json" }),
    (req, res) => {
      if (!verify(req.body, req.header("Webhook-Signature"))) {
        return res.sendStatus(401);
      }

      const delivery = JSON.parse(req.body.toString("utf8"));

      // Acknowledge first, process outside the request.
      res.sendStatus(200);
      void enqueue(delivery);
    },
  );
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import json
  import os
  import time

  from flask import Flask, request

  app = Flask(__name__)
  SIGNING_KEY = os.environ["OMS_WEBHOOK_SIGNING_KEY"].encode()
  TOLERANCE_SECONDS = 300


  def verify(raw_body: bytes, header: str | None) -> bool:
      if not header:
          return False

      parts = dict(
          part.split("=", 1) for part in header.split(",") if "=" in part
      )
      timestamp, signature = parts.get("t"), parts.get("v1")
      if not timestamp or not signature:
          return False

      try:
          age = abs(int(time.time()) - int(timestamp))
      except ValueError:
          return False
      if age > TOLERANCE_SECONDS:
          return False

      expected = hmac.new(
          SIGNING_KEY, f"{timestamp}.".encode() + raw_body, hashlib.sha256
      ).hexdigest()

      return hmac.compare_digest(signature, expected)


  @app.post("/webhooks/oms")
  def receive():
      # request.get_data() keeps the exact bytes OMS signed.
      raw_body = request.get_data()
      if not verify(raw_body, request.headers.get("Webhook-Signature")):
          return "", 401

      delivery = json.loads(raw_body)

      # Acknowledge first, process outside the request.
      enqueue(delivery)
      return "", 200
  ```
</CodeGroup>

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

## Deduplicate on `id`

The envelope's `id` is the delivery ID (`whd_`). It is one per event and endpoint, and it stays the same across that delivery's retries, which makes it your deduplication key.

Because delivery is at-least-once, the same `id` can arrive more than once: OMS may succeed in sending and fail to record it, then send again. Persist `id` with a unique constraint and drop a delivery you have already applied.

<Note>
  The five `*.statusChanged` events also carry `eventId`, the producer event ID (`evt_`). That ID is shared across the fan-out to every endpoint subscribed to the same event, so it is not a per-endpoint dedup key. Deduplicate on `id`.
</Note>

## Order by `sequence`

Ordering is best effort, not a guarantee. Retries and concurrency mean an older event can arrive after a newer one for the same resource.

`sequence` is a per-resource monotonic counter. Apply a delivery only when its `sequence` is newer than the last one you applied for that resource, so a late arrival cannot overwrite newer state. Where a gap matters, refetch the resource from the API instead of inferring the missing step.

```ts theme={null}
const last = await lastAppliedSequence(resourceId);
if (delivery.sequence !== undefined && last !== undefined && delivery.sequence <= last) {
  return; // stale delivery, already superseded
}
```

If you need strict in-order delivery, webhooks are the wrong transport. Reconcile against the API instead.

## Read the event type

Most events name the type in `event`. The five `*.statusChanged` events use `eventType`. Read whichever is present:

```ts theme={null}
const eventType = delivery.event ?? delivery.eventType;
```

The changed resource is under `data` in both shapes, so branch on `data.status` rather than parsing the event name for a status suffix.

## Test your endpoint

`POST /webhooks/{webhookId}/test` sends a `webhook.test` event. It is delivered regardless of your subscriptions, so it exercises signing and reachability before any real event fires.

```bash theme={null}
curl -X POST https://sandbox-api.polygon.technology/v0.11/webhooks/whk_01H9Xa.../test \
  -H "Authorization: Bearer {accessToken}" \
  -H "Idempotency-Key: whk-test-001"
```

Inspect what OMS actually sent with `GET /webhooks/{webhookId}/deliveries`, filtering by `status`, `eventId`, `test`, or a `createdAfter`/`createdBefore` window. `GET /webhooks/{webhookId}/deliveries/{deliveryId}` expands a single delivery with its HTTP attempts, including the response status and duration OMS recorded for each one, which is the fastest way to confirm whether a failure was your endpoint or the network.

## When deliveries fail

A failed delivery is retried on a fixed schedule:

| Attempts so far       | 0  | 1  | 2   | 3   | 4  | 5   | 6  | 7  | 8  | 9  |
| --------------------- | -- | -- | --- | --- | -- | --- | -- | -- | -- | -- |
| Delay before the next | 0s | 5s | 10s | 30s | 1m | 10m | 1h | 6h | 1d | 2d |

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

<Tip>
  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.
</Tip>

Delivery history is retained for 30 days by default. `DELETE /webhooks/{webhookId}` is a soft delete: the webhook disappears from list and get, but its history is kept for the retention window.
