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

# Price Webhooks

> A signed HTTP call to your server, once, when a token's price crosses your level: take-profit, stop-loss and limit orders without polling. 1 credit per trigger sent to your URL, managing them is free.

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](https://docs.serialized.xyz/conventions.md#the-main-market), as of its last trade there**: the same price as [`/v1/token/price`](https://docs.serialized.xyz/endpoints/token-price-lean) and the [`token-updates`](https://docs.serialized.xyz/streams/token-updates-stream) 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](#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](#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`](https://docs.serialized.xyz/endpoints/price-webhook-endpoint) (`rotate: true` generates a new one).
* [Create](https://docs.serialized.xyz/endpoints/price-webhook-create.md) 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`](https://docs.serialized.xyz/endpoints/price-webhook-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](https://docs.serialized.xyz/endpoints/price-webhook-add.md) 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](https://docs.serialized.xyz/endpoints/price-webhook-remove.md) triggers by id, [clear](https://docs.serialized.xyz/endpoints/price-webhook-clear.md) a webhook or [delete](https://docs.serialized.xyz/endpoints/price-webhook-delete.md) it. Adding and removing triggers never return a price: the price a trigger fired at comes in its message and in [list triggers](https://docs.serialized.xyz/endpoints/price-webhook-triggers.md) (`firedPriceUsd`, `firedPriceNative`, `pool`).

## Conditions

| Operator | Fires when the price is |
| - | - |
| `gt` | above the level |
| `gte` | at or above the level |
| `lt` | below the level |
| `lte` | at or below the level |
| `eq` | at the level, reached from either side |

* **`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`](https://docs.serialized.xyz/endpoints/token-price-lean) 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`](https://docs.serialized.xyz/endpoints/token-price-lean) 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`](https://docs.serialized.xyz/endpoints/token-price-lean). 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:

```json theme={null}
{
  "type": "price.reached",
  "eventId": "evt_7c1e9a2b0d3f4e5a8b6c9d0e1f2a3b4c",
  "webhookId": "tpsl",
  "triggerId": "order-8812",
  "name": "TP x2",
  "data": {
    "chain": "solana",
    "address": "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263",
    "condition": { "gte": "0.000023" },
    "unit": "usd",
    "direction": "gte",
    "priceNative": "0.000000121",
    "priceUsd": "0.0000230142",
    "pool": "Hs97TCZeuYiJxooo3U73qEHXg3dKpRL4uYKYRryEK9CF",
    "at": 1792541400000
  }
}
```

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

```js Node.js theme={null}
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(secret, rawBody, header, toleranceSec = 300) {
  if (typeof header !== 'string') return false;
  const parts = Object.fromEntries(header.split(',').map((kv) => [kv.slice(0, kv.indexOf('=')), kv.slice(kv.indexOf('=') + 1)]));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = Buffer.from(createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
  const given = Buffer.from(parts.v1 ?? '');
  return expected.length === given.length && timingSafeEqual(expected, given);
}
```

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

def verify(secret: str, raw_body: bytes, header: str, tolerance_sec: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance_sec:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

## 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](#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](https://docs.serialized.xyz/endpoints/price-webhook-triggers.md) (`delivery.attempts`, `lastStatus`, `lastError`).
* Change the URL or the secret with [`/endpoint`](https://docs.serialized.xyz/endpoints/price-webhook-endpoint): the next attempts use the new ones.

## Statuses

| `status` | The trigger |
| - | - |
| `active` | is armed and watching its token |
| `firing` | fired, its delivery is in progress |
| `delivered` | fired and your endpoint answered a 2xx |
| `failed` | ended without a delivery; `failReason` says why |
| `removed` | was removed (remove, clear or delete) |

| `failReason` | What happened | Billed |
| - | - | - |
| `delivery_failed` | no attempt got a 2xx | 1 credit if its first attempt went out to your URL, nothing otherwise |
| `expired` | the delivery could not complete within 5 minutes after the trigger is taken for delivery | 1 credit if its first attempt went out to your URL, nothing otherwise |
| `url_refused` | the webhook's URL, or the address its host resolves to, is refused by our checks when we send (a private address) | 1 credit if its first attempt went out to your URL, nothing otherwise |
| `webhook_deleted` | the webhook was deleted before the delivery succeeded | 1 credit if its first attempt went out to your URL, nothing otherwise |
| `payment_required`, `quota_exceeded`, `account_suspended` | your account could not pay when the trigger fired: nothing was sent, and the trigger keeps no `firedPriceUsd`, `firedPriceNative` or `pool` | nothing |
| `account_unknown` | the account that owns the trigger could not be found when it fired (a closed account): nothing was sent | nothing |

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

| | Per account |
| - | - |
| Active triggers, across all your price webhooks | 1,000 (contact us for more) |
| Price webhooks | 100 |
| Triggers per add call | 1,000, on up to 100 distinct tokens |
| Ids per remove call | 10,000 |

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](#statuses)). Creating, reading, changing, clearing and deleting webhooks and triggers costs 0 credits, and so do [test pings](https://docs.serialized.xyz/endpoints/price-webhook-test.md). 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](https://docs.serialized.xyz/authentication.md#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](https://docs.serialized.xyz/endpoints/usage-breakdown.md) 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](https://docs.serialized.xyz/authentication.md#rate-limits)).

## Test your endpoint

[`POST /v1/webhook/price/{id}/test`](https://docs.serialized.xyz/endpoints/price-webhook-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](#verify-the-signature) instead). One ping per webhook every 5 seconds. Free.


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