Skip to main content
A price webhook calls your server when a token’s price crosses a level you set. Register an HTTPS endpoint once, then arm triggers on it: each trigger watches one token for one condition, fires once when the condition is met, and ends. Built for the take-profit, stop-loss and limit orders of a trading app: no polling, thousands of orders per account. The price is the token’s price read on its main market, as of its last trade there: the same price as /v1/token/price and the token-updates stream, never a frozen one (see below). Every message names that market (pool). Set each level in USD (priceUsd) or in the native coin of the chain (priceNative: ETH on Base, BNB on BNB Smart Chain, SOL on Solana), whichever your app counts in (USD or native).

A webhook

  • Its id: yours (1 to 64 characters among A-Z a-z 0-9 _ . : -, not starting with a dot), or one we generate when you create it without one (wh_ + 16 hex characters). Ids are private to your account.
  • Its URL (callbackUrl): https:// on the default port (443), a public host, no credentials, up to 2,048 characters. We check it when you create the webhook and when you change it. Redirects are never followed: register the final URL.
  • Its secret: every message is signed with it (verify the signature). Send your own (16 to 256 printable ASCII characters, no spaces) or let us generate one (whsec_…): a generated secret is returned once, in the response that created it, and never shown again. Replace it at any time with /endpoint (rotate: true generates a new one).
  • Create it, optionally with its first triggers. Creating an id you already have adds the triggers to it, as long as the URL (and the secret and name, when you send them) are the same; a different one answers 400. If you lost the response of a creation that generated your secret, rotate it with /endpoint.

Triggers

A trigger is { "id"?, "name"?, "chain", "address", "priceUsd": { "<operator>": "<level>" } }, or the same with priceNative:
  • id: yours (same rule as a webhook id), or generated (tr_ + 16 hex characters). Unique within its webhook: an id stays taken for 30 days after its trigger ends. Re-sending the same trigger (same id, token and condition) changes nothing and comes back in alreadyIn with its status, so adding is safe to retry. A trigger you removed stays removed: sending it again comes back in alreadyIn with status: "removed" and is not armed; arm it again under a new id.
  • name: optional, 1 to 128 characters, sent back in the message.
  • chain and address: the token (solana or evm:<id>; EVM addresses are lowercased, Solana mints kept as they are).
  • priceUsd or priceNative, one of the two: one operator and its level, in USD or in the chain’s native coin, as a decimal string ("0.0024", at most 32 characters) or a JSON number. Every response and message gives the trigger’s unit (usd or native).
Add up to 1,000 triggers per call, on up to 100 distinct tokens. An invalid trigger does not fail the call: it comes back in rejected with its index and the error, and the others are armed. Remove triggers by id, clear a webhook or delete it. Adding and removing triggers never return a price: the price a trigger fired at comes in its message and in list triggers (firedPriceUsd, firedPriceNative, pool).

Conditions

  • eq takes its side when you create the trigger: if the token’s price in the level’s unit (the priceUsd or priceNative of /v1/token/price at that moment) is at or below the level, the trigger fires when the price reaches the level (direction: "gte"); if it is above, when it comes down to it (direction: "lte"). Every response and message gives the direction. A token with no price yet in that unit cannot take eq (rejected, details.reason: "noPrice"): use gte or lte.
  • Only a live price counts. A trigger is never judged on a bonding curve’s last price once the token has moved to a new market. Until the token’s new market has a price, armed triggers wait and a new eq is refused (noPrice). While /v1/token/price does not return the token, a new trigger on it comes back NOT_FOUND, whatever its condition.
  • A condition already true fires at once. A take-profit set below the current price fires within seconds.

USD or native

  • priceUsd judges the token’s USD price: its native price times the native coin’s USD price, the same priceUsd as /v1/token/price. It moves when the token trades and when the native coin moves: if SOL drops 10%, a token priced in SOL is worth 10% fewer dollars, and a USD stop-loss on it fires even if the token did not trade. Use it when your app and your users count in dollars.
  • priceNative judges the token against its chain’s native coin only: a move of the native coin never fires it. Use it to follow a token’s own performance.
  • A USD level is judged on a current native coin price only: without one, USD triggers wait, they never fire on an old price. A sudden move of more than 5% in the native coin’s USD price is taken once the next reading confirms it (about 10 seconds on Solana, about a minute on EVM chains), so a single bad print never fires USD levels.

Which tokens

Every token we index on every supported chain, except the chain’s native coin, its wrapped form (WETH, WBNB, wrapped SOL…) and USD stablecoins: a trigger on one of them comes back in rejected with details.reason nativeCoin, wrappedNative or stablecoin. Liquid staking tokens and non-USD stablecoins are served. A token we do not know comes back in rejected with NOT_FOUND.

The message

When a trigger fires, we POST this JSON to your URL:
  • condition is the operator and level you set (eq kept), unit its unit (usd or native), direction the side it is watched from.
  • priceUsd and priceNative are the token’s price when it met the condition, in USD and in the native coin; the one of the trigger’s unit is the price that met it. priceUsd is always set on a USD level; on a native level it is null if the native coin’s USD price was not available at that moment. pool is the market the price was read on, at when it was observed (unix ms).
  • Headers: Content-Type: application/json, User-Agent: Serialized-Webhooks/1, X-Serialized-Event: <eventId> and X-Serialized-Signature.

Verify the signature

X-Serialized-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of "<t>.<raw body>" keyed with your secret, and t the time of this attempt. Verify it on the raw body, before parsing the JSON, compare in constant time, and refuse a t more than 5 minutes away from your clock. The signature is how you know a message is ours. Messages and test pings can come from different addresses, which can change: do not allowlist by IP address, verify the signature.

Delivery and retries

  • Answer with a 2xx within 5 seconds. Do the work after answering: a slower answer counts as a failed attempt.
  • Retries: an attempt that does not get a 2xx is retried 5 seconds, 30 seconds and 2 minutes after the first one (4 attempts in all). The body is the same at every attempt; only the signature’s t changes. A redirect counts as a failure.
  • A host that does not resolve is retried the same way. Nothing goes out to your URL, so nothing is billed for it (credits).
  • A URL refused by our checks when we send (the URL, or the address its host resolves to, is a private address) is not retried: the trigger ends as failed (url_refused).
  • At least once: the same message can arrive twice. Process each eventId once.
  • Within 5 minutes: a delivery that has not got its 2xx 5 minutes after the trigger is taken for delivery stops, and the trigger ends as failed (expired).
  • A trigger whose delivery does not succeed ends as failed. Read what happened with list triggers (delivery.attempts, lastStatus, lastError).
  • Change the URL or the secret with /endpoint: the next attempts use the new ones.

Statuses

A delivered trigger is billed the same way: 1 credit if its first attempt went out to your URL. A trigger belongs to your account: revoking the API key that created it does not stop it. Remove it, or delete its webhook, to stop it.

Limits

A call that would go past an account limit is refused whole, nothing is written: 429 RATE_LIMITED with details.limit (priceTriggers or priceWebhooks), details.max and details.current. A 503 OVERLOADED on creating or adding means we cannot take new triggers for a moment: nothing was written, retry after Retry-After. A webhook that does not exist, or was deleted, answers 404 with details.reason: "unknown".

Credits

1 credit per trigger sent: a fired trigger is billed once, when its first delivery attempt goes out to your URL, whatever your endpoint answers; retries are included. If that first attempt does not go out (the trigger ended before it, its host does not resolve, or our checks refuse its URL), the trigger is not billed, even if a later attempt is delivered (fail reasons). Creating, reading, changing, clearing and deleting webhooks and triggers costs 0 credits, and so do test pings. Removing, clearing, deleting and reading stay open when your credits run out; creating a webhook and adding triggers need an account that can pay, and answer the same 402 or 403 as a billable call otherwise (payment and quota responses). Once your account can no longer pay, new triggers end as failed and nothing is sent; triggers already on their way when the limit is reached are still delivered and billed. Billed triggers show in usage by endpoint as WEBHOOK price. Management calls have their own rate group, Webhook: 600 units per minute per key by default, raised on request. Adding triggers counts one unit per distinct token of the call, or one per 10 triggers when that is more (a full call of 1,000 triggers counts 100), removing one per 100 ids, a page of triggers one per 100 rows asked, any other call one (rate limits).

Test your endpoint

POST /v1/webhook/price/{id}/test sends a signed ping to the webhook’s URL and tells you what your endpoint answered: {"type": "ping", "eventId": "ping_…", "webhookId": "…", "at": <ms>}, with the same headers and signature as a real message. A ping can come from a different address than your messages: a ping that passes does not mean an IP allowlist would let messages through (verify the signature instead). One ping per webhook every 5 seconds. Free.