Skip to main content
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 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 and the dry-run answer on every plan, so a rule can be designed and sized before there is anywhere to deliver it. See 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.
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 does the same job in the meantime. See 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 has one you can copy.

The full tool set

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.
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.
3. Send a test event and confirm your receiver verifies the signature and returns a 2xx. Build the receiver first: Verifying signatures has a working one in Python and Node.
For a market or account subscription, 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: 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.

Export lifecycle

Market

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

Archive health

Account-scoped

Account events fire only for addresses you have registered as 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.

Billing

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

Scope

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. 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.
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: 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).
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: 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.
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; resuming one never flips the other. The response is the updated subscription in the same shape as create.

Symbol conventions

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

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

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.
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.
Node.js receiver
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 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

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

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 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 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 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. 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 and the resume gap schema.
Last modified on September 20, 2026