/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: truegenerates 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 inalreadyInwith itsstatus, so adding is safe to retry. A trigger you removed stays removed: sending it again comes back inalreadyInwithstatus: "removed"and is not armed; arm it again under a new id.name: optional, 1 to 128 characters, sent back in the message.chainandaddress: the token (solanaorevm:<id>; EVM addresses are lowercased, Solana mints kept as they are).priceUsdorpriceNative, 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’sunit(usdornative).
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
eqtakes its side when you create the trigger: if the token’s price in the level’s unit (thepriceUsdorpriceNativeof/v1/token/priceat 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 thedirection. A token with no price yet in that unit cannot takeeq(rejected,details.reason: "noPrice"): usegteorlte.- 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
eqis refused (noPrice). While/v1/token/pricedoes not return the token, a new trigger on it comes backNOT_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
priceUsdjudges the token’s USD price: its native price times the native coin’s USD price, the samepriceUsdas/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.priceNativejudges 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 inrejected 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, wePOST this JSON to your URL:
conditionis the operator and level you set (eqkept),unitits unit (usdornative),directionthe side it is watched from.priceUsdandpriceNativeare the token’s price when it met the condition, in USD and in the native coin; the one of the trigger’sunitis the price that met it.priceUsdis always set on a USD level; on a native level it isnullif the native coin’s USD price was not available at that moment.poolis the market the price was read on,atwhen it was observed (unix ms).- Headers:
Content-Type: application/json,User-Agent: Serialized-Webhooks/1,X-Serialized-Event: <eventId>andX-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
tchanges. 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
eventIdonce. - 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 same402 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.