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

# Webhooks

> Signed HTTP notifications for market, account, archive, export, and billing events. Design a rule over MCP or REST, size it, then verify deliveries.

Webhooks push events to your server instead of making you poll. You register an HTTPS endpoint, subscribe it to event types, and 0xArchive sends a signed POST request to that endpoint each time a matching event occurs.

There are two ways to set one up and both are first class. The usual way is to talk to an agent: connect an MCP client to `https://mcp.0xarchive.io/mcp`, describe the alert you want, and let it read the event catalog, size the rule against real history, create it and confirm the first delivery landed. The dashboard and the [webhook REST routes](#managing-endpoints-and-deliveries) do the same work by hand, authenticated by your session or an API key.

Webhook delivery starts on the Build plan. Free keeps both previews: the [estimate](#estimate) and the [dry-run](#dry-run) answer on every plan, so a rule can be designed and sized before there is anywhere to deliver it. See [Limits](#limits).

## Set up by talking to an agent

Webhooks are built to be configured in conversation. Connect an MCP client to the hosted server, say what you want to be told about, and the agent reads the catalog, sizes the rule against real history, creates it, and checks that a delivery arrived. Nothing it picks is hidden from you: the thresholds, the rate they produce and the stored configuration all come back in the answer.

The server is at `https://mcp.0xarchive.io/mcp` and it authenticates with OAuth. Use the client's built-in OAuth flow. There is no API key to paste and no Authorization header to set.

<CodeGroup>
  ```bash Claude Code theme={"theme":"github-dark"}
  claude mcp add --transport http 0xarchive https://mcp.0xarchive.io/mcp
  ```

  ```text Other MCP-capable clients theme={"theme":"github-dark"}
  Add https://mcp.0xarchive.io/mcp as a remote HTTP MCP server, then start the client's built-in OAuth flow and approve the scopes it asks for.
  ```
</CodeGroup>

Approve the scopes your client shows you at consent time. Reading the catalog, your rules and the delivery log, and running either preview, needs `mcp:webhooks.read`. Creating, editing, resuming or deleting anything needs `mcp:webhooks.write`. Market data tools keep their own `mcp:market.read`. That consent screen is also how you tell whether the webhook tools are live on your connection: if it does not offer the two webhook scopes, they are not enabled on the server you reached, and [Set up by hand](#set-up-by-hand) does the same job in the meantime. See [MCP server](/mcp-server) for connecting a client and for the market-data tools it carries.

### A rule, start to finish

One conversation covers the whole job: pick an event, size it, point it somewhere, confirm it arrived.

**You:** Tell me when a wallet I follow is liquidated on Hyperliquid for more than a quarter of a million dollars.

**1. It reads the catalog.** `list_webhook_event_types` returns one declaration per event type: scope, venues, the filters each accepts, the params that define an occurrence, the metrics a condition can test and the operator vocabulary. Every subscription is checked against that declaration, so this is what the agent works from rather than guessing field names. Here it lands on `account.liquidated`, whose scope is `addresses`.

**2. It checks what your plan allows.** `get_webhook_limits` returns your plan, whether webhook delivery is included, and used against limit for endpoints, subscriptions, watched wallets and deliveries so far today. On a plan without delivery it says so in a sentence you can read, along with the plan that would include it, so the limit is stated up front instead of being inferred from a create that fails.

**3. It registers the wallet.** `add_webhook_watched_address` puts the address on your watched list. `account.*` events are detected only for addresses on that list, so this comes before the rule.

**4. It sizes the rule before you own it.** `estimate_webhook_subscription` replays the configuration over recent history and answers the question a threshold cannot answer on its own: how often would this fire? You get a per-day rate, the typical day and the worst day, and a ladder of thresholds with the rate each one would have produced. If a quarter of a million dollars turns out to fire forty times a day, this is where that shows up, and the ladder says what to move the threshold to. `dry_run_webhook_subscription` shows the individual occurrences behind a number rather than the count alone, on the three types it covers today (`account.fill`, `account.transfer` and `market.liquidation`). `account.liquidated` is not one of them yet, so here the estimate and its ladder are the whole sizing step.

**5. It creates the receiver and the rule.** `create_webhook_endpoint` registers your HTTPS URL and returns the signing secret exactly once, so store it at that moment. `create_webhook_subscription` points the endpoint at `account.liquidated` with your scope, params and conditions, and echoes back the stored configuration with defaults filled in.

**6. It confirms a delivery arrives.** `test_webhook_endpoint` queues a real signed `webhook.test` through the same dispatch path as production traffic, and `list_webhook_deliveries` reads the outcome: state, attempts, the status code your receiver returned and the error if it was not a 2xx. A receiver whose signature check is wrong fails here, before a real alert depends on it. [Verifying signatures](#verifying-signatures) has one you can copy.

### The full tool set

| Job                            | Tools                                                                                                                                                                |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Read the catalog and your plan | `list_webhook_event_types`, `get_webhook_limits`                                                                                                                     |
| Size a rule before creating it | `estimate_webhook_subscription`, `dry_run_webhook_subscription`                                                                                                      |
| Receivers                      | `create_webhook_endpoint`, `list_webhook_endpoints`, `test_webhook_endpoint`, `enable_webhook_endpoint`, `rotate_webhook_endpoint_secret`, `delete_webhook_endpoint` |
| Rules                          | `create_webhook_subscription`, `list_webhook_subscriptions`, `update_webhook_subscription`, `resume_webhook_subscription`, `delete_webhook_subscription`             |
| Watched wallets                | `list_webhook_watched_addresses`, `add_webhook_watched_address`, `delete_webhook_watched_address`                                                                    |
| Deliveries                     | `list_webhook_deliveries`, `redeliver_webhook_delivery`                                                                                                              |

Each tool carries the same rules this page describes, so an agent reading them configures the same thing you would. Rotating a secret and deleting an endpoint are marked destructive: both are irreversible for a live receiver, so confirm before you let an agent run them.

## Set up by hand

The dashboard does all of this with forms. Below are the same steps against the REST API, which is what the dashboard and the MCP tools both call.

**1. Create an endpoint.** The signing secret is returned once, on creation only. Store it immediately.

```bash theme={"theme":"github-dark"}
curl -X POST https://api.0xarchive.io/v1/webhooks/endpoints \
  -H "X-API-Key: $OXARCHIVE_API_KEY" \
  -H "content-type: application/json" \
  -d '{"url": "https://example.com/hooks/0xarchive", "description": "prod receiver"}'
```

```json theme={"theme":"github-dark"}
{
  "success": true,
  "data": {
    "id": "f4797c46-ada6-4c09-a076-5689a974e4be",
    "url": "https://example.com/hooks/0xarchive",
    "status": "active",
    "secret": "whsec_b2e6833ce6500ad3e79e3004..."
  },
  "note": "Store the secret now; it is not shown again."
}
```

**2. Subscribe it to an event type.** `filters` is optional; omit it to receive every occurrence the event's floor allows, with every declared param at its default.

```bash theme={"theme":"github-dark"}
curl -X POST https://api.0xarchive.io/v1/webhooks/subscriptions \
  -H "X-API-Key: $OXARCHIVE_API_KEY" \
  -H "content-type: application/json" \
  -d '{"endpoint_id": "f4797c46-ada6-4c09-a076-5689a974e4be", "event_type": "export.job.completed"}'
```

**3. Send a test event** and confirm your receiver verifies the signature and returns a 2xx. Build the receiver first: [Verifying signatures](#verifying-signatures) has a working one in Python and Node.

```bash theme={"theme":"github-dark"}
curl -X POST https://api.0xarchive.io/v1/webhooks/endpoints/{endpoint_id}/test \
  -H "X-API-Key: $OXARCHIVE_API_KEY"
```

For a market or account subscription, [dry-run](#dry-run) the configuration first to see what it would have delivered over the last few hours.

## Available events

Fetch the live catalog at any time from `GET /v1/webhooks/event-types`, or ask an agent for `list_webhook_event_types`. It is the declaration every subscription is checked against, so read it before you configure anything. Each entry carries:

| Field                         | Meaning                                                                                                                                                                      |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`, `live`, `description` | The event type string, whether it accepts subscriptions today, and what it is.                                                                                               |
| `scope`                       | `public` (market-wide), `addresses` (fires only for your [watched addresses](#watched-addresses)) or `user` (about your own account).                                        |
| `venues`, `filters`           | The venues it covers and which of `venue`, `symbols`, `addresses` it accepts.                                                                                                |
| `params`                      | The numbers that define an occurrence (a window, a threshold, a level), each with a type, unit, default, and either a `min`/`max` range or an `enum` menu of allowed values. |
| `metrics`                     | The fields a condition can test, with their types, units and enum values.                                                                                                    |
| `operators`                   | The operator vocabulary, grouped by metric type.                                                                                                                             |
| `cost_floor`                  | Where a market-wide scan has a floor below which occurrences are never read.                                                                                                 |
| `latency_class`               | `seconds` or `minutes`, see below.                                                                                                                                           |
| `filters_example`             | A configuration that is valid for this event.                                                                                                                                |

28 event types are declared and 24 are live. The four marked coming soon below refuse subscriptions with "not yet available" until the catalog flips them to `live: true`.

### How fast events arrive

`latency_class` says which path produces an event. `seconds`: fills, transfers and liquidations (`account.fill`, `account.transfer`, `market.liquidation`, `account.liquidated`) come from a fast path on our own Hyperliquid node and are delivered under a second after the block in normal conditions; the tail follows Hyperliquid node lag. `chain.upgrade_detected` sits on the same node-side path and is seen within a minute of the binary changing. `minutes`: everything else is detected from the archive and arrives two to three minutes after the fact. `webhook.test` is queued immediately.

Every delivery from either path carries `late_ms` and `late`, so a receiver can tell an event that is arriving late from one that just happened. See [Event envelope](#event-envelope).

### Export lifecycle

| Event type             | What it is                                                                                                                        | Latency | Scope keys | Params (default) |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------- | ---------- | ---------------- |
| `export.job.completed` | A bulk export job finished and its Parquet files are ready. Payload carries `job_id` and an `api_url` pointer for download links. | minutes | None       | None             |
| `export.job.failed`    | A bulk export job failed. Payload carries `job_id` and `error_message`.                                                           | minutes | None       | None             |
| `webhook.test`         | You trigger a test from the dashboard or API to check a receiver.                                                                 | seconds | None       | None             |

### Market

| Event type                 | What it is                                                                                                                                                                                                        | Latency | Scope keys                                                  | Params (default)                                                              |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------- |
| `market.liquidation`       | A liquidation on a covered venue. Fills from one cascade collapse into a single event per account and instant. Every liquidation is an occurrence; condition on `notional_usd` for the sizes you care about.      | seconds | `venue` (hyperliquid, hip3, lighter), `symbols`             | `max_age_s` 60 to 86400 (3600)                                                |
| `market.liquidation_burst` | Liquidation volume in a rolling window crosses your `threshold_usd`. Fires on the rising edge, not repeatedly while elevated. `window_s` and `threshold_usd` are yours to set; the occurrence is defined by them. | minutes | `venue` (hyperliquid, hip3, lighter), `symbols`             | `window_s` 60, 300, 900, 3600 (300); `threshold_usd` 1,000 and up (1,000,000) |
| `market.funding_flip`      | A funding rate changes sign on a subscribed symbol. Every flip is an occurrence; condition on `magnitude` or `open_interest_usd` if a rate hovering near zero would alert too often.                              | minutes | `venue` (hyperliquid, hip3), `symbols`                      | None                                                                          |
| `market.listed`            | A new market is listed on a covered venue. Emitted once per market, the first time data appears for it.                                                                                                           | minutes | `venue` (hyperliquid, hip3, spot, lighter)                  | None                                                                          |
| `market.delisted`          | **Coming soon.** A market flipped from active to inactive. One event per delisting; a relisted market can delist again on a later date.                                                                           | minutes | `venue` (hyperliquid, hip3, lighter, rh-lighter), `symbols` | None                                                                          |
| `market.oi_delta`          | Open interest on a market moved by at least your `threshold_pct` over your `window_s`. Fires once per direction per crossing. Condition on `oi_usd_now` to ignore thin markets.                                   | minutes | `venue` (hyperliquid, hip3), `symbols`                      | `window_s` 300, 900, 3600 (900); `threshold_pct` 0.1 to 1000 (10)             |
| `market.breadth_cross`     | The share of markets trading above session VWAP crossed your `threshold`, with your `hysteresis_pct` band. Venue-wide: symbols do not apply.                                                                      | minutes | `venue` (hyperliquid, hip3)                                 | `threshold` 1 to 99 (30); `hysteresis_pct` 0 to 50 (5)                        |
| `market.pga_payment`       | A priority gas auction payment. Exchange-wide, so `symbols` refers to the payment token, not a market. Condition on `amount` or `notional_usd`.                                                                   | minutes | `venue` (hyperliquid), `symbols`                            | `max_age_s` 60 to 86400 (3600)                                                |
| `hip4.settlement`          | A HIP-4 outcome side settled. One event per outcome and side, with aggregate contracts and value. Symbols use the per-side coin form (`#20481`).                                                                  | minutes | `venue` (hip4), `symbols`                                   | `max_age_s` 60 to 86400 (7200)                                                |

Market-wide scans have floors, declared as `cost_floor` in the catalog, and they are the only numbers the platform decides for you. `market.liquidation` never reads liquidations under 100 USD notional: a subscription with no conditions receives everything from 100 USD up. `market.oi_delta` only considers markets with at least 100,000 USD of open interest. `hip4.settlement` scans at 500 USD and up, and a subscription that sets no `min_notional_usd` receives settlements of 1,000 USD and up; set `min_notional_usd` explicitly to pin your own floor between those. `market.pga_payment` reads payments of 1.5 HYPE and up (a floor that can be lowered as far as 0.5 HYPE on our side, not by a subscription), and stops after 50 payment events in a UTC day so an auction regime shift cannot use up every subscriber's daily deliveries.

### Oracle and chain

| Event type                  | What it is                                                                                                                                                                                                                                                                                                                                                                                                               | Latency | Scope keys                | Params (default)                                                         |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | ------------------------- | ------------------------------------------------------------------------ |
| `oracle.stall`              | **Coming soon.** A HIP-3 dex oracle stopped publishing for longer than your `window_s`. One event type carries both edges: `data.state` is `stalled`, `recovered` or `expired`.                                                                                                                                                                                                                                          | minutes | `venue` (hip3)            | `window_s` 60, 120, 300, 900, 3600 (300)                                 |
| `oracle.jump`               | A HIP-3 oracle price moved by at least your `threshold_pct` between consecutive updates. Only coins that publish a real oracle price are covered. Symbols are dex-namespaced (`xyz:AAPL`); a bare `AAPL` matches nothing.                                                                                                                                                                                                | minutes | `venue` (hip3), `symbols` | `threshold_pct` 0.1 to 50 (2)                                            |
| `chain.block_stall`         | **Coming soon.** Hyperliquid mainnet stopped producing blocks for `threshold_s` or more, confirmed by independent liveness signals so our own node stalling alone never fires it. `data.state` is `stalled` or `recovered`.                                                                                                                                                                                              | minutes | `venue` (hyperliquid)     | `threshold_s` 10, 20, 30, 60, 120, 300, 600 (20)                         |
| `chain.block_time_degraded` | **Coming soon.** Block cadence fell `degraded_pct` or more below its trailing 24 hour baseline for `consecutive_minutes`. Precedes stalls. `data.state` is `degraded` or `recovered`.                                                                                                                                                                                                                                    | minutes | `venue` (hyperliquid)     | `degraded_pct` 10, 20, 30, 50 (20); `consecutive_minutes` 1, 2, 3, 5 (2) |
| `chain.upgrade_detected`    | Hyperliquid shipped a new node build. Two edges share one `incident_id`: `data.state` is `started` when the new binary is swapped onto our node, `completed` when that binary is the running node and reading fresh blocks again, with `duration_s` between them. Identity is the build commit. Builds usually land about weekly, around the weekend UTC. Condition on `state`, `commit`, `weekday_utc` or `duration_s`. | seconds | `venue` (hyperliquid)     | None                                                                     |

### Archive health

| Event type             | What it is                                                                                                                                                                                                          | Latency | Scope keys                                            | Params (default)                                  |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ----------------------------------------------------- | ------------------------------------------------- |
| `archive.gap_detected` | An ingestion stream stopped producing data beyond its expected cadence. Pairs with `archive.gap_resolved`.                                                                                                          | minutes | `venue` (hyperliquid, hip3, spot, lighter)            | None                                              |
| `archive.gap_resolved` | A stream that had stalled started producing data again. The closing half of `archive.gap_detected`.                                                                                                                 | minutes | `venue` (hyperliquid, hip3, spot, lighter)            | None                                              |
| `ingest.stall`         | One market's L2 feed went silent for `threshold_s` while its stream kept flowing. Pairs with `ingest.recovered`. Naming `symbols` opts into per-market events; unfiltered subscriptions get storm-collapsed alerts. | minutes | `venue` (hyperliquid, hip3, spot, lighter), `symbols` | `threshold_s` 60, 120, 300, 600, 1800, 3600 (600) |
| `ingest.recovered`     | A per-market L2 stall cleared. The closing half of `ingest.stall`, sharing its `incident_id`.                                                                                                                       | minutes | `venue` (hyperliquid, hip3, spot, lighter), `symbols` | `threshold_s` 60, 120, 300, 600, 1800, 3600 (600) |

### Account-scoped

Account events fire only for addresses you have registered as [watched addresses](#watched-addresses). Scope keys and conditions narrow venue, symbol and size; the watched list decides which addresses count. None of these events has a floor: every occurrence for a watched address is delivered unless your conditions say otherwise.

| Event type               | What it is                                                                                                                                                                                                                                                                                       | Latency | Scope keys                                                | Params (default)               |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | --------------------------------------------------------- | ------------------------------ |
| `account.liquidated`     | A watched address was liquidated. Fills from one cascade collapse into a single event per instant.                                                                                                                                                                                               | seconds | `venue` (hyperliquid, hip3), `symbols`, `addresses`       | `max_age_s` 60 to 86400 (3600) |
| `account.order_rejected` | The engine involuntarily cancelled a watched address's orders (reduce-only, self-trade, margin, OI cap, sibling filled, liquidated, scheduled cancel), grouped per block. Submission-time rejects never reach the archive and are not covered.                                                   | minutes | `venue` (hyperliquid, hip3, spot), `symbols`, `addresses` | `max_age_s` 60 to 86400 (3600) |
| `account.twap_lifecycle` | A watched address's TWAP changed state: `activated`, `finished`, `terminated`, `error`, `stopped` or `waitingForTrigger`. Condition on `status` or `terminal`.                                                                                                                                   | minutes | `venue` (hyperliquid, hip3, spot), `symbols`, `addresses` | `max_age_s` 60 to 86400 (3600) |
| `account.fill`           | A watched address executed in a market: one event per venue, market, block and account, with the fills collapsed and their notional summed. Every execution is an occurrence; conditions on `notional_usd`, `side`, `taker`, `is_liquidation` and the other declared metrics pick what you want. | seconds | `venue` (hyperliquid, hip3, spot), `symbols`, `addresses` | `max_age_s` 60 to 86400 (3600) |
| `account.transfer`       | A HyperCore spot token movement where a watched address is sender or destination. `symbols` refers to tokens (`USDC`, `HYPE`), not pairs. Condition on `usdc_value`, `kind` or `role`.                                                                                                           | seconds | `venue` (hyperliquid), `symbols`, `addresses`             | `max_age_s` 60 to 86400 (3600) |
| `account.hip4_settled`   | A watched address's HIP-4 position settled, with its settlement value and realized PnL.                                                                                                                                                                                                          | minutes | `venue` (hip4), `symbols`, `addresses`                    | `max_age_s` 60 to 86400 (7200) |

### Billing

| Event type           | What it is                                                                                                                             | Latency | Scope keys | Params (default)                                          |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------- | ---------- | --------------------------------------------------------- |
| `billing.credit_low` | Your own monthly credit pool crossed one of your `levels_pct` (percent remaining). One event per level per month. No scope keys apply. | minutes | None       | `levels_pct` list of values from 0 to 100 (`[25, 10, 0]`) |

## Configuration

Every subscription carries your own configuration. Nothing about size, threshold or window is decided for you: the platform declares what each event can be scoped and conditioned on, and you choose the values. The model is the one you know from spreadsheet conditional formatting: a range, then rules.

```json theme={"theme":"github-dark"}
{
  "endpoint_id": "f4797c46-ada6-4c09-a076-5689a974e4be",
  "event_type": "account.fill",
  "filters": {
    "venue": "hyperliquid",
    "symbols": ["BTC", "ETH"],
    "addresses": ["0x2b1e0bcefada121c5bc484a546e3ca0e2a8bee5b"],
    "params": {"max_age_s": 900},
    "conditions": [
      { "metric": "notional_usd", "op": "greater_than_or_equal", "value": 25000 },
      { "metric": "side", "op": "in", "value": ["buy"] }
    ]
  }
}
```

The response echoes the stored configuration, normalised: symbol operators in their word form, addresses lowercased, every declared param present (defaults filled in), and `min_notional_usd` mirrored from the loosest `notional_usd` lower bound among your conditions.

```json theme={"theme":"github-dark"}
{
  "success": true,
  "data": {
    "id": "3c1f0a52-8d6b-4f0e-9b1a-6f2c4d8e9a10",
    "endpoint_id": "f4797c46-ada6-4c09-a076-5689a974e4be",
    "event_type": "account.fill",
    "filters": {
      "venue": "hyperliquid",
      "symbols": ["BTC", "ETH"],
      "addresses": ["0x2b1e0bcefada121c5bc484a546e3ca0e2a8bee5b"],
      "params": {"max_age_s": 900},
      "conditions": [
        { "metric": "notional_usd", "op": "greater_than_or_equal", "value": 25000 },
        { "metric": "side", "op": "in", "value": ["buy"] }
      ],
      "min_notional_usd": 25000
    },
    "enabled": true,
    "created_at": "2026-09-17T10:12:09Z"
  }
}
```

### Scope

| Key         | Type                | Meaning                                                                                |
| ----------- | ------------------- | -------------------------------------------------------------------------------------- |
| `venue`     | string or string\[] | Restrict to venues the event covers. Absent means every venue it covers.               |
| `symbols`   | string\[]           | Restrict to these markets, in venue-native form. Absent means every market.            |
| `addresses` | string\[]           | `account.*` events only: a subset of your watched addresses. Absent means all of them. |

### Conditions

Conditions compare a metric the event declares against a value. Up to 16 conditions per subscription; all must hold. No conditions means every occurrence: `account.fill` with no conditions is every fill of a watched address, `market.liquidation` with no conditions is every liquidation on the exchange from the 100 USD scan floor up.

| Metric type  | Operators                                                                                                                                  |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| number       | `greater_than`, `greater_than_or_equal`, `less_than`, `less_than_or_equal`, `equal`, `not_equal`, `between`, `not_between`, `in`, `not_in` |
| text or enum | `equal`, `not_equal`, `in`, `not_in`, `contains`, `not_contains`, `starts_with`, `ends_with`                                               |
| boolean      | `equal`, `not_equal`                                                                                                                       |
| timestamp    | `before`, `after`, `equal` (RFC 3339 values)                                                                                               |
| any          | `is_empty`, `is_not_empty`                                                                                                                 |

Symbol spellings (`>=`, `<`, `!=`, ...) are accepted and stored as the word form. `between` and `not_between` take `[low, high]`; `in` and `not_in` take a non-empty list; `is_empty` and `is_not_empty` take no value. `min_notional_usd` at the top level of `filters` is shorthand for a `notional_usd greater_than_or_equal` condition (`usdc_value` on `account.transfer`, the one event whose notional metric has that name). A custom-formula operator (`formula`) is reserved and rejected for now; combine conditions instead.

#### Relative values

Four metrics let a threshold mean the same thing on a large market and a small one. They are computed against a reference we refresh every minute and are conditionable like any other metric.

| Metric                                              | On                                                                                     | Meaning                                                            |
| --------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| `notional_pct_volume_1h`, `notional_pct_volume_24h` | `market.liquidation`, `account.liquidated`, `account.fill`, `market.liquidation_burst` | the event's notional as a percent of that market's trailing volume |
| `notional_pct_oi`                                   | the same events, perps only                                                            | as a percent of that market's open interest                        |
| `notional_pct_wallet_volume_30d`                    | `account.fill`                                                                         | as a percent of that wallet's own 30 day volume                    |

```json theme={"theme":"github-dark"}
{"conditions": [{"metric": "notional_pct_volume_1h", "op": ">=", "value": 2}]}
```

That is "a liquidation worth at least 2 percent of this market's last hour of volume", on every market at once. The value is `null`, so nothing fires, when the denominator is under 10,000 USD, which keeps a thin market from producing a huge percentage. Each payload carries `reference_at`, the time the denominator was measured.

Which metrics an event declares, with their types, units and enum values, is in the catalog: `GET /v1/webhooks/event-types` returns `metrics`, `operators` and `params` per event. A condition on an undeclared metric, an operator that does not fit the metric's type, an out-of-range value or an unknown enum member is rejected at subscribe time with a 400 that names what is declared, so a subscription that could never fire cannot be created.

### Params

Some events are defined by a window or a level, so the value changes what an occurrence is rather than filtering one. Those declare `params` in the catalog, each with a default and either a range or a menu:

| Event                              | Params                                                                                                                                                                             |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `market.liquidation_burst`         | `window_s` one of 60, 300, 900, 3600; `threshold_mode` one of `usd`, `pct_oi`, `pct_volume_1h`; `threshold_usd` from 1,000 (usd mode); `threshold_pct` 0.01 to 100 (percent modes) |
| `market.oi_delta`                  | `window_s` one of 300, 900, 3600; `threshold_pct` 0.1 to 1000                                                                                                                      |
| `market.breadth_cross`             | `threshold` 1 to 99; `hysteresis_pct` 0 to 50                                                                                                                                      |
| `oracle.jump`                      | `threshold_pct` 0.1 to 50                                                                                                                                                          |
| `oracle.stall`                     | `window_s` one of 60, 120, 300, 900, 3600                                                                                                                                          |
| `ingest.stall`, `ingest.recovered` | `threshold_s` one of 60, 120, 300, 600, 1800, 3600                                                                                                                                 |
| `chain.block_stall`                | `threshold_s` one of 10, 20, 30, 60, 120, 300, 600                                                                                                                                 |
| `chain.block_time_degraded`        | `degraded_pct` one of 10, 20, 30, 50; `consecutive_minutes` one of 1, 2, 3, 5                                                                                                      |
| `billing.credit_low`               | `levels_pct` a list of percent-remaining levels, each 0 to 100                                                                                                                     |
| Events with a chain timestamp      | `max_age_s` 60 to 86400, see below                                                                                                                                                 |

`market.liquidation_burst` can be written in relative terms: `{"params": {"window_s": 60, "threshold_mode": "pct_oi", "threshold_pct": 1}}` is "liquidations inside a minute worth one percent of that market's open interest", which is a meaningful threshold on every perp without picking a dollar figure per market. The default mode is `usd`, so a subscription written before modes existed keeps firing exactly as it did.

Omitted params take the declared default. A value outside the range or off the menu is rejected with a 400 naming the allowed values. A declared param may also be written at the top level of `filters` (`"threshold_s": 60`) and is routed into `params`; on `market.liquidation_burst`, a top-level `threshold` means `threshold_usd`. Two subscriptions with different params are two different triggers.

#### max\_age\_s and late deliveries

Events that carry a chain timestamp (`market.liquidation`, `market.pga_payment`, `hip4.settlement`, `account.fill`, `account.transfer`, `account.liquidated`, `account.order_rejected`, `account.twap_lifecycle`, `account.hip4_settled`) declare `max_age_s`: the oldest occurrence you still want delivered, in seconds behind real time. 60 to 86400; default 3600, or 7200 for the two HIP-4 settlement events.

In normal operation it never comes into play. After an outage on our side, the engine catches up as far back as your `max_age_s` and delivers those occurrences with `late: true` and their `late_ms` set, instead of dropping them; anything older is yours to fetch from the REST API or an export. Set it low when a stale alert is worse than no alert (a trading trigger), high when completeness matters more (an audit trail).

### Estimate

`POST /v1/webhooks/subscriptions/estimate` answers the question a threshold cannot answer on its own: how often would this fire? It replays the same detection over the last 1 to 30 days and returns the rate, plus a ladder of thresholds with the rate at each, so a threshold can be picked from the rate you want rather than guessed.

The body is the create body minus `endpoint_id`, plus `lookback_days` (1 to 30, default 7).

```bash theme={"theme":"github-dark"}
curl -X POST https://api.0xarchive.io/v1/webhooks/subscriptions/estimate \
  -H "X-API-Key: $OXARCHIVE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "event_type": "market.liquidation",
    "config": {"conditions": [{"metric": "notional_usd", "op": ">=", "value": 250000}]},
    "lookback_days": 7
  }'
```

```json theme={"theme":"github-dark"}
{
  "success": true,
  "data": {
    "event_type": "market.liquidation",
    "window": { "from": "2026-09-01T14:00:00.000Z", "to": "2026-09-08T14:00:00.000Z" },
    "days": 7,
    "total": 84,
    "per_day": [ { "date": "2026-09-02", "count": 9 }, { "date": "2026-09-03", "count": 31 } ],
    "per_day_p50": 12.0,
    "per_day_max": 31,
    "primary_metric": "notional_usd",
    "ladder": [
      { "value": 50000, "per_day": 41.7 },
      { "value": 250000, "per_day": 12.0 },
      { "value": 1000000, "per_day": 2.1 }
    ],
    "distribution": { "n": 2140, "p50": 4200, "p90": 61000, "p99": 380000, "max": 2100000 },
    "sample": [ { "observed_at_estimate": "2026-09-08T13:41:01.586Z", "data": { "symbol": "BTC" } } ],
    "basis": { "mode": "exact", "note": null }
  }
}
```

`per_day` has one entry per day, oldest first, zero filled, so a quiet day is visible rather than missing. `per_day_p50` is the typical day and `per_day_max` the worst one, which matters more than the average when you are deciding whether to be paged. `ladder` is ten rungs on the config's own threshold metric, each with the daily rate it would have produced, everything else unchanged. `distribution` describes the metric itself over the window, which answers "what counts as large on this market".

`basis.mode` says how the answer was reached:

| Mode       | Meaning                                                                                                    |
| ---------- | ---------------------------------------------------------------------------------------------------------- |
| `exact`    | every occurrence in the window was counted                                                                 |
| `replayed` | the detector's own rules were re-run over history, which is how windowed events such as bursts are counted |
| `sampled`  | a condition could not be expressed as a query, so a recent sample was scaled; `basis.note` says so         |

Estimates are available for `account.fill`, `account.transfer`, `account.liquidated`, `market.liquidation`, `market.pga_payment`, `hip4.settlement`, `market.liquidation_burst`, `market.oi_delta`, `oracle.jump` and `market.funding_flip`. Other types return a 400 naming that list. Estimates never create a subscription or a delivery, and they share one budget of six calls a minute with dry-run.

Percent thresholds are estimated against each market's reference values as they are now, not as they were at the time of each occurrence, which the note says when it applies.

### Dry-run

`POST /v1/webhooks/subscriptions/dry-run` answers "what would this subscription have delivered?" over a recent window, using the same scan and the same matcher that delivery uses, so you can tune a condition against real data before enabling it. It never creates deliveries and never changes anything.

The body is the create body minus `endpoint_id`, plus the window and page size: `event_type`, `config` (also accepted as `filters`), `lookback_s` (60 to 86400, default 3600) and `limit` (1 to 200, default 100). Validation errors are identical to create.

```bash theme={"theme":"github-dark"}
curl -X POST https://api.0xarchive.io/v1/webhooks/subscriptions/dry-run \
  -H "X-API-Key: $OXARCHIVE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "event_type": "market.liquidation",
    "config": {"venue": "hyperliquid", "symbols": ["BTC", "ETH"],
               "conditions": [{"metric": "notional_usd", "op": ">=", "value": 250000}]},
    "lookback_s": 21600,
    "limit": 5
  }'
```

```json theme={"theme":"github-dark"}
{
  "success": true,
  "data": {
    "event_type": "market.liquidation",
    "window": { "from": "2026-09-08T08:00:00.000Z", "to": "2026-09-08T14:00:00.000Z" },
    "matched": 12,
    "truncated": true,
    "occurrences": [
      {
        "observed_at_estimate": "2026-09-08T13:41:01.586Z",
        "data": {
          "venue": "hyperliquid",
          "symbol": "BTC",
          "timestamp": "2026-09-08T13:41:01.586Z",
          "account": "0x4378a374231ad915c6b93349521a1955808c8254",
          "side": "A",
          "direction": "Close Long",
          "notional_usd": 562419.19,
          "size": 7.23477,
          "vwap": 77738.36,
          "mark_price": 77739.0,
          "closed_pnl": -8218.95,
          "fill_count": 66,
          "min_trade_id": 6740275823509,
          "max_trade_id": 1121517228007121,
          "api_url": "/v1/hyperliquid/liquidations/BTC?start=1788874861586&end=1788874861587"
        }
      }
    ]
  }
}
```

`occurrences` are newest first, each with the `data` a delivery would carry and `observed_at_estimate`, the occurrence's own timestamp (a real delivery's `observed_at` is that plus the path's latency). `matched` counts every hit in `window`, before `limit`; `truncated` is true when `occurrences` is shorter than `matched`, or when a scan hit its row cap and `window.from` was moved forward so the window and the list agree. Two deliberate differences from delivery: `max_age_s` is not applied (you pick the window), and for `account.*` events the scan also covers occurrences from before the address was added, so you can see what a threshold would have caught.

Dry-run is available today for `account.fill`, `account.transfer` and `market.liquidation`; other types return a 400 naming that list. It shares its budget of six calls a minute with estimate; use estimate for the rate and dry-run to look at the matches themselves. Dry-running an `account.*` event with no watched addresses returns a 400 asking you to add one first. Going past the shared budget returns a 429. A 503 with `Retry-After` means the engine could not answer; a 504 means the scan ran out of time, so try a shorter `lookback_s`.

### Editing

`PATCH /v1/webhooks/subscriptions/{id}` with `{"config": {...}}` replaces the configuration in place (validated exactly like create; `filters` is accepted as the key too), and `{"enabled": false}` switches delivery off without losing the configuration, so tuning a threshold never means deleting and recreating the subscription. That switch is yours and is separate from the engine's own pause, described under [Limits](#limits); resuming one never flips the other. The response is the updated subscription in the same shape as create.

```bash theme={"theme":"github-dark"}
curl -X PATCH https://api.0xarchive.io/v1/webhooks/subscriptions/3c1f0a52-8d6b-4f0e-9b1a-6f2c4d8e9a10 \
  -H "X-API-Key: $OXARCHIVE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "config": {"venue": "hyperliquid", "symbols": ["BTC", "ETH"],
               "conditions": [{"metric": "notional_usd", "op": ">=", "value": 50000}]}
  }'
```

### Symbol conventions

Symbols are venue-native. A symbol written in the wrong form matches nothing.

| Venue                                    | Form                 | Example                                    |
| ---------------------------------------- | -------------------- | ------------------------------------------ |
| Hyperliquid perps                        | Bare coin            | `BTC`                                      |
| HIP-3                                    | Dex prefix and coin  | `xyz:AAPL` (a bare `AAPL` matches nothing) |
| Hyperliquid Spot pairs                   | Dashed pair          | `HYPE-USDC`                                |
| HIP-4 outcome sides                      | `#` and the asset id | `#20481`                                   |
| `account.transfer`, `market.pga_payment` | Token, not pair      | `USDC`, `HYPE`                             |

## Watched addresses

Account-scoped events (`account.*`) are keyed to addresses you register once. Any address may be watched: your own accounts or counterparties you track. Once an address is on your list, every `account.*` subscription you hold fires for it, subject to that subscription's configuration. Addresses you have not registered never produce account events, whatever the configuration says. How many you can watch at once is set by your plan; see [Limits](#limits).

| Action                   | Route                                |
| ------------------------ | ------------------------------------ |
| List watched addresses   | `GET /v1/webhooks/addresses`         |
| Add a watched address    | `POST /v1/webhooks/addresses`        |
| Remove a watched address | `DELETE /v1/webhooks/addresses/{id}` |

The request body is `{"address": "...", "label": "..."}`. `label` is optional and is truncated to 64 characters. The address must be a 0x-prefixed 40-hex-character EVM address; it is normalised to lowercase before storage, so casing and surrounding whitespace do not matter. Anything else returns a 400.

```bash theme={"theme":"github-dark"}
curl -X POST https://api.0xarchive.io/v1/webhooks/addresses \
  -H "X-API-Key: $OXARCHIVE_API_KEY" \
  -H "content-type: application/json" \
  -d '{"address": "0xAbC0000000000000000000000000000000000001", "label": "desk A"}'
```

```json theme={"theme":"github-dark"}
{
  "success": true,
  "data": {
    "id": "6b1d2c4e-1f0a-4c8e-9a2b-3d4e5f607182",
    "address": "0xabc0000000000000000000000000000000000001",
    "label": "desk A",
    "created_at": "2026-09-09T12:00:00Z"
  },
  "limit": 15
}
```

Re-adding an address you already watch is idempotent: it returns the existing row, updates the label, and does not count against your cap. `limit` in every response is your plan's address cap; a genuinely new address beyond it returns a 400 naming the cap. List and delete return the same row shape.

Hyperliquid's bridge system addresses are refused: `0x2222...2222` and the `0x2000...` plus token index family are the counterparty to every Core-to-EVM move of their token (tens of thousands of transfers a day for USDC), not accounts. The 400 says so; watch the account on your side of the bridge instead.

Subscribe to an `account.*` type the same way as any other event. The subscription's configuration narrows venue, symbols, size and the other declared metrics; the watched list decides which addresses count.

`account.order_rejected` covers the engine's involuntary cancels: `reduceOnlyCanceled`, `selfTradeCanceled`, `siblingFilledCanceled`, `openInterestCapCanceled`, `marginCanceled`, `liquidatedCanceled`, and `scheduledCancel`. Submission-time rejects, where the API refuses an order before it reaches the book, never reach the archive and are not covered.

## Event envelope

Every delivery is a JSON POST with this shape. `id` is the event identifier and is deterministic per real-world occurrence: retries, manual redeliveries, and replays of the same occurrence all carry the same `id`, so it is safe to dedupe on it.

```json theme={"theme":"github-dark"}
{
  "id": "04bade8a-659a-4609-a183-733163bc6a22",
  "type": "account.fill",
  "schema_version": 1,
  "observed_at": "2026-09-08T21:36:41.811Z",
  "late_ms": 479,
  "late": false,
  "data": {
    "venue": "hyperliquid",
    "symbol": "ETH",
    "wire_symbol": "ETH",
    "timestamp": "2026-09-08T21:36:41.332Z",
    "account": "0xbc256baa3480ec7882ac87eadc349e77291b202b",
    "side": "buy",
    "notional_usd": 563400.79,
    "buy_notional_usd": 563400.79,
    "sell_notional_usd": 0.0,
    "size": 227.0836,
    "vwap": 2481.0280914166,
    "taker": true,
    "direction": "Open Long",
    "fee": 167.611724,
    "closed_pnl": -155.12,
    "is_liquidation": false,
    "twap_id": null,
    "fill_count": 23,
    "order_count": 1,
    "order_ids": ["539700803622"],
    "min_trade_id": 17492294096692,
    "max_trade_id": 1121895567956718,
    "api_url": "/v1/hyperliquid/trades/ETH?start=1788903401332&end=1788903401333"
  }
}
```

| Field            | Meaning                                                                                                                                                                             |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`             | Event identifier, deterministic per occurrence. Dedupe on it.                                                                                                                       |
| `type`           | The event type string.                                                                                                                                                              |
| `schema_version` | Payload schema version, `1` today.                                                                                                                                                  |
| `observed_at`    | When the engine produced the delivery.                                                                                                                                              |
| `late_ms`        | Age of the occurrence at that moment: `observed_at` minus `data.timestamp`. `null` when the event has no chain timestamp.                                                           |
| `late`           | `true` when `late_ms` exceeds 10 minutes. Both paths are normally far under that, so `late: true` means the engine or a feed was behind; do not treat the event as "just happened". |
| `data`           | The event payload.                                                                                                                                                                  |

`late_ms` and `late` are present on market, oracle, chain, account, HIP-4 and billing events. Export events, `webhook.test`, and the archive and ingest health events may not carry them; read both as optional fields.

Payloads are pointers, not documents: they identify what happened and where to fetch the full resource. `api_url` is a path on the REST API; call it with your normal authentication for the fills behind a fill or liquidation event, or the job details and download links behind an export event. Download links themselves are never included in webhook payloads. State-carrying events (`oracle.stall`, `chain.block_stall`, `chain.block_time_degraded`) put the edge in `data.state`; paired events (`ingest.stall` and `ingest.recovered`) share a `data.incident_id`.

Request headers on every delivery:

| Header           | Meaning                                                                                                                        |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `0xa-event-id`   | The event identifier, same as `id` in the body                                                                                 |
| `0xa-event-type` | The event type string                                                                                                          |
| `0xa-signature`  | Signature, format `t=<unix seconds>,v1=<hex digest>`. During a secret rotation window a second `,v1=<hex digest>` is appended. |

## Verifying signatures

Every delivery carries an `0xa-signature` header of the form `t=<unix seconds>,v1=<hex digest>`. The digest is HMAC-SHA256 over the string `<t>.<raw body>`, keyed with your endpoint's secret, where the raw body is the exact bytes that arrived. Recompute it, compare in constant time, and reject anything that does not match.

Two rules decide whether a receiver is safe:

* **Sign the bytes you received, not the object you parsed.** Serializing a parsed body back to JSON changes key order, spacing and number formatting, and the digest stops matching. Read the raw body first, verify, then parse. In Express that means `express.raw({ type: "application/json" })` on the webhook route rather than `express.json()`; in Flask, `request.get_data()`.
* **Reject old timestamps.** Compare `t` against your own clock and refuse anything outside a tolerance, 5 minutes being a reasonable default, so a captured delivery cannot be replayed at you later.

The header can carry more than one `v1` value. For the 24 hours after a secret rotation each delivery is signed twice, once with the new secret and once with the previous one, and both digests are sent. Compute your digest with the secret you hold and accept the request when it matches any `v1` value, which is what lets you roll the secret on your receiver without dropping deliveries.

<CodeGroup>
  ```python Python theme={"theme":"github-dark"}
  import hashlib
  import hmac
  import time


  def verify_webhook(secret: str, signature_header: str, raw_body: bytes, tolerance_s: int = 300) -> bool:
      """True when signature_header authenticates raw_body for this endpoint secret."""
      if not signature_header:
          return False

      timestamp, signatures = None, []
      for part in signature_header.split(","):
          key, _, value = part.partition("=")
          key, value = key.strip(), value.strip()
          if key == "t":
              timestamp = value
          elif key == "v1":
              signatures.append(value)
      if not timestamp or not signatures:
          return False

      try:
          age_s = abs(time.time() - int(timestamp))
      except (ValueError, OverflowError):
          return False
      if age_s > tolerance_s:
          return False

      expected = hmac.new(
          secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
      ).hexdigest()
      return any(hmac.compare_digest(expected, sig) for sig in signatures)
  ```

  ```javascript Node.js theme={"theme":"github-dark"}
  const crypto = require("node:crypto");

  /** True when signatureHeader authenticates rawBody (a Buffer) for this endpoint secret. */
  function verifyWebhook(secret, signatureHeader, rawBody, toleranceS = 300) {
    if (typeof signatureHeader !== "string") return false;

    let t = null;
    const signatures = [];
    for (const part of signatureHeader.split(",")) {
      const eq = part.indexOf("=");
      if (eq === -1) continue;
      const key = part.slice(0, eq).trim();
      const value = part.slice(eq + 1).trim();
      if (key === "t") t = value;
      else if (key === "v1") signatures.push(value);
    }
    if (!t || signatures.length === 0) return false;

    const ageS = Math.abs(Date.now() / 1000 - Number(t));
    if (!Number.isFinite(ageS) || ageS > toleranceS) return false;

    const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
    return signatures.some((sig) => {
      const given = Buffer.from(sig, "hex");
      return given.length === expected.length && crypto.timingSafeEqual(expected, given);
    });
  }
  ```
</CodeGroup>

A whole receiver is not much more than that. Respond with any 2xx within 10 seconds, then do the work off the request: delivery is at-least-once, so dedupe on the event `id` and keep the handler itself short.

```javascript Node.js receiver theme={"theme":"github-dark"}
const http = require("node:http");

http
  .createServer((req, res) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => {
      const rawBody = Buffer.concat(chunks);

      if (!verifyWebhook(process.env.OXARCHIVE_WEBHOOK_SECRET, req.headers["0xa-signature"], rawBody)) {
        res.writeHead(401).end();
        return;
      }

      const event = JSON.parse(rawBody.toString("utf8"));
      res.writeHead(204).end();

      if (alreadyHandled(event.id)) return;
      enqueue(event);
    });
  })
  .listen(8080);
```

Send yourself a `webhook.test` once the receiver is up and read the delivery log: a receiver whose signature check is wrong fails there, where you can see the status code it returned, rather than during the incident you built the alert for.

## Retries and failure handling

Delivery is at-least-once. A non-2xx response, a timeout, or a connection error schedules a retry on this ladder: 5 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, then hourly, for up to 24 hours from the first attempt. After 24 hours the delivery is marked exhausted.

An endpoint that keeps failing (10 or more consecutive failures sustained for 6 hours or more) is automatically disabled and stops receiving deliveries. Re-enable it from the dashboard or with `POST /v1/webhooks/endpoints/{id}/enable` once your receiver is healthy. Nothing is buffered while it is off, and the window it missed is recoverable from the REST archive the same way a [paused rule's window](#at-the-daily-cap-a-rule-pauses) is. A disabled endpoint is a receiver problem; a paused subscription is a plan or allowance one.

Because retries and redeliveries reuse the event `id`, an idempotent receiver processes each event exactly once even when it is delivered more than once.

## Managing endpoints and deliveries

| Action                               | Route                                         |
| ------------------------------------ | --------------------------------------------- |
| List endpoints                       | `GET /v1/webhooks/endpoints`                  |
| Create endpoint                      | `POST /v1/webhooks/endpoints`                 |
| Delete endpoint                      | `DELETE /v1/webhooks/endpoints/{id}`          |
| Rotate secret                        | `POST /v1/webhooks/endpoints/{id}/rotate`     |
| Re-enable endpoint                   | `POST /v1/webhooks/endpoints/{id}/enable`     |
| Send test event                      | `POST /v1/webhooks/endpoints/{id}/test`       |
| Delivery log                         | `GET /v1/webhooks/endpoints/{id}/deliveries`  |
| Redeliver an event                   | `POST /v1/webhooks/deliveries/{id}/redeliver` |
| List subscriptions                   | `GET /v1/webhooks/subscriptions`              |
| Subscribe                            | `POST /v1/webhooks/subscriptions`             |
| Estimate a subscription's rate       | `POST /v1/webhooks/subscriptions/estimate`    |
| Dry-run a subscription               | `POST /v1/webhooks/subscriptions/dry-run`     |
| Edit a subscription or switch it off | `PATCH /v1/webhooks/subscriptions/{id}`       |
| Resume a paused subscription         | `POST /v1/webhooks/subscriptions/{id}/resume` |
| Resume every paused subscription     | `POST /v1/webhooks/subscriptions/resume`      |
| Unsubscribe                          | `DELETE /v1/webhooks/subscriptions/{id}`      |
| List watched addresses               | `GET /v1/webhooks/addresses`                  |
| Add a watched address                | `POST /v1/webhooks/addresses`                 |
| Remove a watched address             | `DELETE /v1/webhooks/addresses/{id}`          |
| Event catalog                        | `GET /v1/webhooks/event-types`                |
| Plan allowances and usage            | `GET /v1/webhooks/limits`                     |

The delivery log shows each delivery's state (`pending`, `delivered`, `failed`, `exhausted`), attempt count, last response code, last error, latency, and the full payload, and is retained for 30 days. `?limit=` (default 50) sets how many rows come back. Redelivery clones a past delivery into a fresh attempt with the same event `id`.

### Rotating secrets

`POST /v1/webhooks/endpoints/{id}/rotate` returns a new secret (shown once) and keeps the previous secret valid for 24 hours, so you can roll the secret on your receiver without dropping deliveries. During the window every delivery carries two `v1` digests in `0xa-signature`, one per secret, so a receiver still holding the old secret keeps verifying and a receiver that has rolled verifies with the new one. Accept a request when any `v1` matches the digest you compute.

## Security and destination policy

Webhook destinations must be HTTPS URLs on publicly resolvable hosts. Destinations that resolve to private, loopback, link-local, or cloud metadata addresses are rejected at creation time and re-checked on every delivery attempt. Redirects are not followed.

Endpoint secrets are shown once at creation or rotation and are never returned by list or read routes. Treat a webhook request as authentic only after its signature verifies.

## Limits

| Plan       | Endpoints     | Subscriptions | Watched wallets | Deliveries per day |
| ---------- | ------------- | ------------- | --------------- | ------------------ |
| Free       | Not available | Not available | Not available   | Not available      |
| Build      | 1             | 8             | 2               | 5,000              |
| Pro        | 4             | 40            | 15              | 50,000             |
| Scale      | 12            | 200           | 50              | 500,000            |
| Enterprise | Custom        | Custom        | Custom          | Custom             |

Webhook delivery starts on Build. Free is not webhooks with every number set to zero: on Free there is no endpoint to register, no rule to create and no wallet to watch. The create routes say exactly that, name the plan that would include it, and point at the previews that are open to you now, so you never have to work the wall out from a failure. Enterprise allowances are agreed per account, so talk to us rather than reading a number off a table.

**Free keeps the estimate and the dry-run.** Both answer on every plan, and they are the two surfaces that tell you whether a rule is worth having at all. Choose an event, set a threshold, and the [estimate](#estimate) tells you it would have fired 478 times a day, with a ladder of thresholds and the rate at each so you can move it to the rate you actually want. The [dry-run](#dry-run) then shows you the occurrences behind that number. The whole rule can be built and tuned on Free. The part that needs a paid plan is the destination.

Subscriptions are counted across all of your endpoints, and one endpoint can hold many of them. Creating an endpoint, subscription or watched wallet beyond the allowance returns a 400 that names it. `GET /v1/webhooks/limits` returns the same allowances with what you have already used against each, and whether your plan includes delivery at all, so a dashboard or an agent can show you where you stand instead of inferring it from a refusal.

Deliveries are counted per account across every endpoint, from the moment a delivery is queued, so the retries of one event are never counted twice, and the count starts again each day. The estimate and the dry-run share a budget of 6 calls a minute per account and are not deliveries. Webhook deliveries do not consume API credits.

### At the daily cap, a rule pauses

**Nothing is lost.** When an account goes past its deliveries per day, the subscription that crossed the line is paused, visibly, and reports that it is paused. Events are not dropped where nobody can see them: a receiver cannot tell silently discarded events from a quiet market, and being unable to tell is the worst failure an alerting product has.

A paused rule delivers nothing and buffers nothing. While it is paused it records what it is missing: how many occurrences, and the window they fall in. Your other rules keep delivering until they reach the same account cap, and then they pause the same way.

Nothing is lost because the events were never only in flight. The occurrences a paused rule missed are in the archive, which is the thing 0xArchive is for, and the pause hands you the window to ask for: read the same event's REST route over the paused window, with the same filters, and you have that window back in full. For a paused `account.fill` rule that is `/v1/hyperliquid/trades/{symbol}`; for `market.liquidation` it is `/v1/hyperliquid/liquidations/{symbol}`. Both are per symbol, so a rule that named no `symbols` is one call per symbol it covered rather than one call in total. A delivered payload names its own route in `api_url`, so an earlier delivery from the same rule shows you the shape of the call to make.

Resuming is an explicit call: `POST /v1/webhooks/subscriptions/{id}/resume` for one rule, or `POST /v1/webhooks/subscriptions/resume` for every paused rule at once, which is usually the one you want, because the cap is counted per account and your rules pause against it one after another. Either clears the pause and its counters. A paused rule never restarts on its own, so a receiver you have not fixed or an allowance you have not raised cannot be flooded the moment the day rolls over. It replays nothing, because nothing was buffered, and it leaves your own enabled switch alone, so a rule you had switched off stays off. A rule that keeps pausing is a sizing signal before it is a plan signal: run the [estimate](#estimate) again and move the threshold to a rate that fits the allowance you have.

### When your plan stops including delivery

There is a second reason a rule pauses, and it is not the daily cap. When the plan on the account stops including webhook delivery at all, each rule pauses the next time it matches something, and each one says that it was paused because it had no delivery to make, naming the plan that would include it again. That is deliberately not the daily-cap sentence: "your plan does not include this" and "you have used today's allowance" are different problems with different fixes, so they are never reported as the same thing.

Two ordinary situations land here. A downgrade to Free is one: Free has no endpoint, no rule and no watched wallet, so rules built on a paid plan stop delivering as soon as the account lands on Free. A payment that is still settling is the other: for a short window an account can still read as Free while the plan change catches up, and a rule that matches an occurrence inside that window pauses.

Everything else behaves exactly as it does at the cap. The rule delivers nothing and buffers nothing. It records how many occurrences it missed and the window they fall in, and those occurrences are in the archive, so the same REST read-back described above hands you that window back in full.

Recovery is the one deliberate difference. Once the plan includes webhook delivery again, the engine clears this pause itself the next time the rule matches something, so an upgrade you have just paid for starts delivering without you resuming every rule by hand. A daily-cap pause never lifts itself, because a rule that spends the whole allowance by 09:00 would only do it again the next day. Either way your own on/off switch is untouched, so a rule you had switched off stays off.

### Reading a pause

A paused rule says so on `GET /v1/webhooks/subscriptions`, whichever reason paused it.

| Field                                                                                                                                    | Meaning                                                                                                                                                                                                                                |
| ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`                                                                                                                                 | `active` while the rule is serving, `auto_paused` while the engine has it stopped.                                                                                                                                                     |
| `pause_message`                                                                                                                          | Why this rule is paused and what clears it, in plain words. Present only while it is paused. This is the one to show a person.                                                                                                         |
| `pause_reason`                                                                                                                           | The same cause as a stable machine value, `deliveries_per_day_cap` or `plan_no_webhooks`. Use it for branching, not for display.                                                                                                       |
| `paused_at`                                                                                                                              | When the current gap started.                                                                                                                                                                                                          |
| `suppressed_count`, `suppressed_first_at`, `suppressed_last_at`                                                                          | How many matches were observed and not delivered since the pause began, and the window they fall in. The count is a lower bound rather than a total: occurrences that no active rule asked for are never looked at in the first place. |
| `last_paused_at`, `last_resumed_at`, `last_pause_reason`, `last_suppressed_count`, `last_suppressed_first_at`, `last_suppressed_last_at` | The same record for the pause before this one, kept through a resume so "why did I miss Tuesday" is still answerable a week later.                                                                                                     |

A resume returns the gap it just closed in the same shape, with `replay_window` as the window to re-read for yourself. It is a description of what was missed, not a replay, because nothing was buffered. Full field lists are in the [subscription schema](/schemas/components/webhook-subscription) and the [resume gap schema](/schemas/components/webhook-resume-gap).
