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

# Webhooks

> Receive event notifications on your server, verify their signatures, and recover failed deliveries.

A webhook sends event notifications to a public HTTPS URL you control. Use it when your application should react to new trades, grade changes, or other supported events without polling. Each request is signed so your server can check that it came from 0xinsider.

## Before you start

You need a public HTTPS endpoint on port 443 and a Pro or Max API key or appropriately scoped OAuth token. Keep the signing secret on your server. If you cannot host a public endpoint, use the [event stream](/api-reference/endpoint/get-stream).

Set `OXINSIDER_API_KEY` for the examples below. Webhook management requires Pro or Max access; an inactive subscription returns `402` with `error.reason: "subscription_inactive"`.

## Choose event types

The access column describes the event's data. Webhook management itself still requires Pro or Max access, including for events marked `No`.

Choose the events your application needs, then put their names in `event_types` when you create the webhook. [List webhook events](/api-reference/endpoint/list-webhook-events) returns the supported catalog and current status.

| Event type | Paid event | Status | What `data` contains |
| - | - | - | - |
| `large_trades_inserted` | Yes | `active` | `count` |
| `whale_trades_inserted` | Yes | `active` | An alias of `large_trades_inserted`, with the same fields. |
| `large_trade_inserted_v2` | Yes | `active` | One trade: market, wallet, grade snapshot, size, shares, price, side, outcome, and trade time |
| `wallet_grade_changed` | Yes | `active` | `wallet`, `trader_id`, `old_grade`, `new_grade`, `direction`, `skill_index`, `final_score`, `date` |
| `suspicious_trade_flagged` | Yes | `active` | `trade_id`, `wallet`, `trader_id`, `condition_id`, `suspicion_score`, `track`, `side`, `size`, `price` |
| `insider_radar_flag_raised` | Yes | `active` | An alias of `suspicious_trade_flagged`, with the same fields. |
| `sharp_money_flow_detected` | Yes | `active` | `condition_id`, `net_flow_usd`, `abs_net_flow_usd`, `dominant_side`, `grade_floor`, `whale_trade_count`, `window` |
| `smart_money_flow_detected` | Yes | `active` | An alias of `sharp_money_flow_detected`, with the same fields. |
| `trader_synced` | No | `active` | `trader_id`, `wallet` |
| `whale_trader_synced` | No | `active` | An alias of `trader_synced`, with the same fields. |
| `large_positions_updated` | No | `active` | `count` |
| `live_sports_updated` | No | `active` | `event_slug`, `game_id`, `league`, `version`, `changed`, `observed_at`, `published_at`, `status`, `period`, `clock`, `live`, `ended`, `scores`, `series_format`, `snapshot_url` |
| `export_job_ready` | Owner only | `active` | `job_id`, `status` (`ready`), `format`, `next_action` (`download`), `total_trades`, `processed_trades`, `file_size`, `data_as_of` |
| `export_job_failed` | Owner only | `active` | `job_id`, `status` (`failed`), `format`, `next_action` (`resubmit`), `failure_reason` |
| `export_job_expired` | Owner only | `active` | `job_id`, `status` (`expired`), `format`, `next_action` (`resubmit`) |
| `export_job_cancelled` | Owner only | `active` | `job_id`, `status` (`cancelled`), `format`, `next_action` (`resubmit`) |

* `live_sports_updated` sends one pulse per game, and at most one every 20 seconds per game. A game going live or final is sent straight away. The game clock moving on its own never sends one. See [Live game pulses](#live-game-pulses).
* `suspicious_trade_flagged` and `insider_radar_flag_raised` are one event under two spellings. Subscribe with either name; listing both on one endpoint stores one. Your endpoint receives every delivery under the spelling it registered, in the `x-0xinsider-event-type` header and in the payload's `type`, so an endpoint created before September 23, 2026 keeps receiving `insider_radar_flag_raised`.
* `large_trades_inserted` (and its older spelling `whale_trades_inserted`) carries a count of new large trades, not the trades themselves. Read the trades from [Large trades](/api-reference/endpoint/get-large-trades).
* `large_trade_inserted_v2` carries one trade per delivery. Set `trade_filters` when you create or update the endpoint to require a `condition_id`, `wallet`, minimum `min_grade`, or minimum `min_size_usd`; all supplied filters must match. `grade` is captured at publication and may be `null`, which never passes a minimum-grade filter. The delivery id stays stable across retries.
* `large_trades_inserted` and `whale_trades_inserted`, and `trader_synced` and `whale_trader_synced`, are each one event under two spellings, like the suspicious-trade pair below: an endpoint receives the spelling it registered.
* `sharp_money_flow_detected` and `smart_money_flow_detected` are also aliases. An endpoint receives the name it registered.
* The 4 `export_job_*` events go only to endpoints owned by the account that submitted the export.
* When paid access lapses, every queued delivery of a paid type moves to `dead_letter` with `last_error` set to `delivery owner is no longer authorized for this event`.

<span id="create-and-verify-the-endpoint" />

## Create and verify your webhook

<Steps>
  <Step title="Create the webhook">
    ```bash theme={null}
    curl -X POST \
      -H "Authorization: Bearer $OXINSIDER_API_KEY" \
      -H "Idempotency-Key: webhook-create-2026-09-22" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "Production webhook",
        "url": "https://api.yourapp.com/webhooks/0xinsider",
        "event_types": ["large_trades_inserted"]
      }' \
      "https://api.0xinsider.com/api/v1/webhooks"
    ```

    Save the returned `signing_secret` and `verification.token` immediately; later reads do not return them. Configure your receiver with the signing secret before the next step. The webhook receives events only after verification.
  </Step>

  <Step title="Verify it within 24 hours">
    ```bash theme={null}
    curl -X POST \
      -H "Authorization: Bearer $OXINSIDER_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"verification_token": "whv_3c2e...redacted"}' \
      "https://api.0xinsider.com/api/v1/webhooks/42/verify"
    ```

    Replace `42` with the returned webhook ID and the token placeholder with `verification.token`. This call sends a signed `webhook.verification` challenge to your URL. Your server must verify it and return `2xx` within 10 seconds for the webhook to become `active`.
  </Step>
</Steps>

The challenge body is `{"type":"webhook.verification","token":"<verification_token>","webhook_id":<id>}`. It is signed the same way a delivery is, and it carries `x-0xinsider-timestamp`, `x-0xinsider-signature`, and `x-0xinsider-event-type` `webhook.verification`. It carries none of the event, delivery, or idempotency headers listed below.

If verification fails, use the response to choose the next action:

* `422` with `error.param` `url`: your server answered outside `2xx`, took longer than 10 seconds, or is not reachable from the public internet.
* `400` with `error.param` `verification_token`: the token is wrong or has expired. Delete the endpoint and create it again.

<span id="what-a-delivery-carries" />

## Receive a delivery

Each delivery is a `POST` with a JSON body and `User-Agent: 0xinsider-webhooks/1.0`. Verify the signature, save the event durably, and return `2xx` within 15 seconds. Process the saved event afterward, so a crash after acknowledgment does not lose it.

| Header | Value |
| - | - |
| `x-0xinsider-signature` | The HMAC-SHA256 signature, as `v1=<hex>`. During a staged secret rotation it holds two comma-separated candidates. |
| `x-0xinsider-timestamp` | The Unix time in seconds at which 0xinsider signed the body. |
| `x-0xinsider-event-id` | The event's id. It is the same on every retry of that event. |
| `x-0xinsider-event-type` | The event type, one of the types in the catalog above. |
| `x-0xinsider-delivery-id` | The `id` of this delivery's row in the delivery log. |
| `x-0xinsider-delivery-attempt` | Which attempt this is, counting from 1. |
| `idempotency-key` | A stable key for this endpoint and event pair. It does not change between retries. |

```json theme={null}
{
  "id": "evt_large_trades_inserted_rtds_9f2c...redacted",
  "type": "large_trades_inserted",
  "created_at": "2026-06-17T14:28:20Z",
  "data": { "count": 7 }
}
```

## Live game pulses

`live_sports_updated` reports changes to a game's `scores`, `status`, `period`, `live`, or `ended` fields. A clock change alone does not send an event, but each event includes the latest clock.

Ordinary updates are limited to one event per game every 20 seconds. The next provider update after that interval triggers the event. A game's first update, start, and final state can be sent immediately.

Events depend on provider updates. If the provider stops sending updates, the webhook also stops receiving them.

Changes during the waiting interval appear in the next event's `changed` array. The body contains the current values, rather than every intermediate value.

| Field | What it is |
| - | - |
| `event_slug` | The Polymarket event slug the game trades under. |
| `game_id` | The provider's game id, as a string. `null` when the frame carried none. |
| `league` | The league abbreviation, such as `NBA`. `null` when the frame carried none. |
| `version` | A counter for this game that goes up by one per pulse. Deliveries are not ordered, so drop a pulse whose `version` you already have. |
| `changed` | The material fields that moved since this game's previous pulse. |
| `observed_at` | When the provider stamped the frame this pulse reports. `null` when the frame carried no time. |
| `published_at` | When 0xinsider sent the pulse. |
| `status`, `period`, `clock` | The provider's status text, period text, and game clock. Each is `null` when the frame carried none. |
| `live`, `ended` | Whether the game is in play and whether the provider has called it final. |
| `scores` | One entry per side: `team`, `score`, and `map_score` on esports series. |
| `series_format` | The `BoN` token on an esports series. `null` on every other sport. |
| `snapshot_url` | The game's page on 0xinsider. Read it after a gap instead of replaying pulses. |

```json theme={null}
{
  "id": "evt_live_sports_updated_nba-lal-bos-2026-09-22_pulse_14",
  "type": "live_sports_updated",
  "created_at": "2026-09-22T19:41:08Z",
  "data": {
    "event_slug": "nba-lal-bos-2026-09-22",
    "game_id": "20263",
    "league": "NBA",
    "version": 14,
    "changed": ["scores", "period"],
    "observed_at": "2026-09-22T19:41:07Z",
    "published_at": "2026-09-22T19:41:08Z",
    "status": "In Progress",
    "period": "Q3",
    "clock": "07:21",
    "live": true,
    "ended": false,
    "scores": [
      { "team": "Los Angeles Lakers", "score": "78" },
      { "team": "Boston Celtics", "score": "81" }
    ],
    "series_format": null,
    "snapshot_url": "https://0xinsider.com/event/nba-lal-bos-2026-09-22"
  }
}
```

## Check the signature

Verify the signature before parsing JSON. It covers the timestamp, a dot, and the exact request body bytes.

```
signature = "v1=" + hex( HMAC_SHA256( signing_secret, timestamp + "." + raw_body ) )
```

* Calculate the HMAC from the raw request bytes. Parsing and re-serializing JSON can change those bytes.
* Use the whole `whsec_...` string as the key, prefix included.
* Reject a request whose `x-0xinsider-timestamp` is more than 300 seconds from your own clock.
* Split `x-0xinsider-signature` on commas and accept the request when any candidate matches. A staged rotation sends two candidates for an hour.
* Compare each candidate in constant time.
* Deduplicate ordinary events on `x-0xinsider-event-id` before acting. The verification challenge has no event ID; handle it separately.

Use these verifier functions in your request handler. Pass the raw body and the two signature headers to `verify` before parsing JSON. For an ordinary event, save and deduplicate it before acknowledging; for a verification challenge, return a timely `2xx` after the signature passes.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  const SECRET = process.env.OXINSIDER_WEBHOOK_SECRET;
  if (!SECRET) throw new Error("Set OXINSIDER_WEBHOOK_SECRET before starting the receiver");
  const TOLERANCE_SECONDS = 300;

  function verify(rawBody, signatureHeader, timestampHeader) {
    const ts = Number(timestampHeader);
    if (!Number.isFinite(ts)) return false;
    if (Math.abs(Date.now() / 1000 - ts) > TOLERANCE_SECONDS) return false;

    const expected =
      "v1=" +
      crypto
        .createHmac("sha256", SECRET)
        .update(`${timestampHeader}.${rawBody}`)
        .digest("hex");

    // A staged rotation sends two comma-separated candidates. Accept either.
    return signatureHeader
      .split(",")
      .map((s) => s.trim())
      .some((candidate) => {
        const a = Buffer.from(candidate);
        const b = Buffer.from(expected);
        return a.length === b.length && crypto.timingSafeEqual(a, b);
      });
  }

  ```

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

  SECRET = os.environ["OXINSIDER_WEBHOOK_SECRET"].encode()
  TOLERANCE_SECONDS = 300

  def verify(raw_body: bytes, signature_header: str, timestamp_header: str) -> bool:
      try:
          ts = int(timestamp_header)
      except (TypeError, ValueError):
          return False
      if abs(time.time() - ts) > TOLERANCE_SECONDS:
          return False

      signed = f"{timestamp_header}.".encode() + raw_body
      expected = "v1=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

      # A staged rotation sends two comma-separated candidates. Accept either.
      return any(
          hmac.compare_digest(part.strip(), expected)
          for part in signature_header.split(",")
      )

  ```
</CodeGroup>

<span id="retries" />

## Handle retries and disabled webhooks

0xinsider retries a delivery when your server returns a non-`2xx` status, the connection fails, or the 15-second deadline expires. Each delivery has at most 8 attempts. After the eighth failure, its status becomes `dead_letter`.

The default retry delay grows after each failure: 60, 120, 240, 480, 960, 1,920, then 3,600 seconds. Some failures use a shorter random delay or the receiver's `Retry-After`, as shown below. Delivery scheduling can add up to 10 seconds.

The [delivery log](/api-reference/endpoint/list-webhook-deliveries) reports the next attempt time and `retry_schedule_reason`:

| How the attempt failed | The wait | `retry_schedule_reason` |
| - | - | - |
| Your server answered `408`, `429`, or `5xx` and sent a usable `Retry-After`. | The header's value, clamped to between 60 and 3,600 seconds. | `receiver_retry_after` |
| Your server answered `408`, `429`, or `5xx` without a usable `Retry-After`, or the connection failed or timed out. | A random value between half the ceiling and the ceiling. Two endpoints that fail at the same moment do not retry at the same moment. | `transient_failure` |
| Your server answered with any other status outside `2xx`. | The full ceiling. | `permanent_or_auth_failure` |

`Retry-After` is read as a number of seconds or as an HTTP-date. A missing, malformed, or already-past value is ignored, and the delivery falls back to its ordinary wait.

[Webhook deliveries](/api-reference/endpoint/list-webhook-deliveries) reports `status`, `last_error`, `next_attempt_at`, and `retry_schedule_reason` on each row, and keeps rows for 7 days. Two more reasons appear there: `manual_redelivery` after [Redeliver a webhook delivery](/api-reference/endpoint/redeliver-webhook-delivery), and `configuration_changed` when an edit to the endpoint released an attempt that had not been sent. Both `next_attempt_at` and `retry_schedule_reason` are `null` while an attempt is in flight and once the delivery is `delivered` or `dead_letter`.

The webhook also counts consecutive failures across all its deliveries:

* `failure_count` on the endpoint counts consecutive failed attempts across all of its deliveries. Any success resets it to 0.
* At 8 consecutive failures the endpoint's `status` becomes `disabled` and 0xinsider emails the account owner.
* Several failing deliveries can reach this limit before any one delivery uses all 8 attempts.

<Warning>
  Disabling an endpoint moves every queued delivery to `dead_letter`. Re-enabling it with `{"enabled": true}` on [Update a webhook](/api-reference/endpoint/update-webhook) does not resend them. Requeue each one with [Redeliver a webhook delivery](/api-reference/endpoint/redeliver-webhook-delivery), or catch up with the replay below.
</Warning>

## Replay after downtime

[Event replay](/api-reference/endpoint/get-event-replay-since) lets you catch up on `large_trades_inserted` and its alias `whale_trades_inserted`. It does not replay other event types. For those, inspect delivery logs or read the current API state after an outage.

1. Store `next_cursor` from every replay response.
2. After the outage, call `/api/v1/events/feed/since?cursor=<the cursor you stored>`.
3. Process each event in `data`, then store the response's new `next_cursor`.
4. Repeat while `has_more` is `true`.

## Rotate the signing secret

Use staged rotation for routine secret changes. Use immediate rotation if the secret is exposed in source control, logs, screenshots, or elsewhere.

### Staged rotation, with a 1-hour overlap

Staged rotation lets you deploy a new secret while both signatures remain valid for 1 hour. This avoids rejecting deliveries during the change.

<Steps>
  <Step title="Prepare the new secret">
    ```bash theme={null}
    curl -X POST \
      -H "Authorization: Bearer $OXINSIDER_API_KEY" \
      -H "Idempotency-Key: webhook-prepare-2026-09-22" \
      "https://api.0xinsider.com/api/v1/webhooks/42/rotate-secret/prepare"
    ```

    [Prepare a staged webhook secret](/api-reference/endpoint/prepare-webhook-secret) returns the new `signing_secret` and sets `secret_rotation.status` to `pending`. Deliveries keep using the current secret. Deploy the new one to every receiver before you move on.
  </Step>

  <Step title="Activate it">
    ```bash theme={null}
    curl -X POST \
      -H "Authorization: Bearer $OXINSIDER_API_KEY" \
      -H "Idempotency-Key: webhook-activate-2026-09-22" \
      "https://api.0xinsider.com/api/v1/webhooks/42/rotate-secret/activate"
    ```

    [Activate a staged webhook secret](/api-reference/endpoint/activate-webhook-secret) promotes the prepared secret and returns it once more. `secret_rotation.status` becomes `overlap`, and `secret_rotation.overlap_expires_at` is the instant the overlap ends, 1 hour later. Until then `x-0xinsider-signature` carries two comma-separated `v1=<hex>` candidates: the new secret's signature first, the old secret's second.
  </Step>

  <Step title="Retire the old secret">
    ```bash theme={null}
    curl -X POST \
      -H "Authorization: Bearer $OXINSIDER_API_KEY" \
      -H "Idempotency-Key: webhook-retire-2026-09-22" \
      "https://api.0xinsider.com/api/v1/webhooks/42/rotate-secret/retire"
    ```

    [Retire a staged webhook secret](/api-reference/endpoint/retire-webhook-secret) ends the overlap as soon as every receiver accepts the new secret. `secret_rotation.status` goes back to `idle` and deliveries carry one signature again. If you never call it, the overlap ends on its own at `overlap_expires_at`.
  </Step>
</Steps>

Use an `Idempotency-Key` for each operation and reuse that key if its response is lost. Repeating `prepare` while the same secret is `pending` returns that secret again. Changing the webhook's `url` discards any prepared or overlapping secret.

### Immediate rotation, after a leak

[Rotate a webhook secret](/api-reference/endpoint/rotate-webhook-secret) replaces the secret in a single call.

```bash theme={null}
curl -X POST \
  -H "Authorization: Bearer $OXINSIDER_API_KEY" \
  -H "Idempotency-Key: webhook-rotate-2026-09-22" \
  "https://api.0xinsider.com/api/v1/webhooks/42/rotate-secret"
```

The old secret stops working when the call returns, and there is no overlap. Deploy the new `signing_secret` to every receiver immediately: a delivery your server rejects is retried about a minute later and counts toward the 8 consecutive failures that disable the endpoint. A `409` with `error.reason` `webhook_delivery_in_progress` means a delivery is in flight, so send the request again once it finishes.

<span id="what-it-does-not-do" />

## Limits

* Deliver to a laptop or a private network. The URL has to be public HTTPS on port 443. To read events from a machine with no public address, open the [Stream](/api-reference/endpoint/get-stream) instead.
* The count event `large_trades_inserted` does not contain individual trades. Choose `large_trade_inserted_v2` for one trade per delivery.
* Replay any event type other than `large_trades_inserted` (`whale_trades_inserted`).
* Count events cannot filter individual trades. For `large_trade_inserted_v2`, set `trade_filters` to filter by market, wallet, minimum grade, or minimum size.
* Return `signing_secret` a second time. [Webhook](/api-reference/endpoint/get-webhook) and [Webhooks](/api-reference/endpoint/list-webhooks) never carry it, so rotate the secret if you lose it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.