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

# Webhooks

> Receive signed HTTPS notifications when trades, feed items, or positioning views change.

Webhooks push changes to your server, so you do not have to poll the feed, events, or Position Intelligence. Each change arrives as one signed JSON `POST` to an HTTPS endpoint you register.

| Family | Event types | Mirrors | One event per |
| - | - | - | - |
| Trade events | `trade_event.created`, `trade_event.deleted` | [`GET /v2/events`](/guides/rest/data-reads#events) | trade event |
| Feed items | `feed_item.created`, `feed_item.updated`, `feed_item.deleted` | [`GET /v2/feed`](/guides/rest/feed) | source message |
| Positioning | `positioning.<view>.updated` for the six views | [Position Intelligence](/guides/rest/signal-intelligence.md) | view |

Payloads use the same IDs and shapes as the REST reads, so you can mix webhooks and REST calls. [Webhook payloads](/guides/rest/webhook-events.md) documents every event type with an example.

Trade and feed events cover the traders in the partner feed: both their signals and their messages must be publicly visible, and traders excluded from the feed for editorial reasons produce no events. Some traders returned by `/events` therefore never produce webhook events.

## Register an endpoint

You need an active partner account. You can register up to five endpoints, either in the Centaur app under **Settings > Webhooks**, or over REST with an API key that has the [`webhooks.manage` scope](/guides/overview/authentication#scopes):

```bash theme={null}
curl --fail-with-body -X POST 'https://api.centaur.io/v2/webhooks' \
  -H "x-api-key: $CENTAUR_API_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "url": "https://example.com/centaur-webhooks",
    "description": "Production trade alerts",
    "eventTypes": ["feed_item.created", "feed_item.updated", "feed_item.deleted"],
    "filters": { "assetClasses": ["crypto"] }
  }'
```

The response contains the endpoint and its signing secret: `whsec_` followed by 43 characters. Centaur shows the secret only once, so store it where your receiver can read it. The same routes also answer under `/v1/webhooks`.

The URL must use HTTPS, must not contain a username or password, and its host must already resolve to a public address. Centaur rejects hosts that are or resolve to private, loopback, link-local, or cloud metadata addresses, at registration and before every delivery.

### Choose events and filters

`eventTypes` lists the types the endpoint receives. Choose at least one. `filters` narrows trade and feed events. Positioning events ignore filters and always carry the whole view.

| Filter | Matches events |
| - | - |
| `traderIds` | of these traders |
| `sourceIdentityIds` | from these sources, the `source.identity.sourceIdentityId` values |
| `assetIds` | on these assets |
| `assetClasses` | on assets of these classes: `crypto`, `stocks`, `indices`, `commodities`, `fx` |

Each filter takes up to 100 values. An omitted filter matches everything. Values within a filter match any. Different filters must all match, and `assetIds` and `assetClasses` must match the same asset. A feed item matches when one of its current or removed events matches. Filter changes apply only to events detected afterward.

`maxInFlight` caps how many deliveries Centaur sends to the endpoint at once, from 1 to 50. The default is 5.

## Receive deliveries

Every delivery is one JSON object, never an array, with an envelope around the payload. [Webhook payloads](/guides/rest/webhook-events.md#envelope) describes the envelope and headers. Follow four rules:

1. **Deduplicate on the event `id`.** Delivery is at least once. A retry, a replay, or a recovered delivery repeats the same `id`.
2. **Do not assume order.** Deliveries run in parallel and failed ones retry later, so they can arrive out of `sequence` order. For feed items, `current` is the message's state after the change.
3. **Return a 2xx fast.** Store the event, respond, then process it. An attempt fails if your status does not arrive within 10 seconds, including DNS lookup and connection, or if it is outside 2xx. Redirects are not followed, so a 3xx is a failure.
4. **Verify the signature on the raw body**, before you parse the JSON.

Ignore fields you do not recognize. Payloads can gain fields without a `schemaVersion` change.

### Verify signatures

Deliveries do not come from fixed IP addresses, so you cannot allowlist Centaur by IP. The signature proves a request came from Centaur. Verify it on every request.

The `Centaur-Signature` header holds a timestamp `t` and one or more `v1` signatures. Each `v1` is the lowercase hex HMAC-SHA256 of the string `<t>.<raw body>`, keyed by the whole secret string, including the `whsec_` prefix. Do not base64-decode the secret.

1. Read `t` and every `v1` from the header.
2. Reject the delivery when `t` is more than 5 minutes from your clock. This stops an old request from being replayed.
3. Compute the HMAC with each secret you hold. Accept the delivery when any `v1` matches, using a constant-time comparison.

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

const TOLERANCE_SECONDS = 300

export function verifyCentaurSignature(header, rawBody, secrets, nowSeconds = Math.floor(Date.now() / 1000)) {
  let timestamp = null
  const signatures = []
  for (const part of header.split(',')) {
    const [key, value] = part.trim().split('=')
    if (key === 't') timestamp = Number(value)
    if (key === 'v1' && value) signatures.push(Buffer.from(value, 'hex'))
  }
  if (!Number.isInteger(timestamp) || Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false
  for (const secret of secrets) {
    const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest()
    for (const signature of signatures) {
      if (signature.length === expected.length && timingSafeEqual(signature, expected)) return true
    }
  }
  return false
}
```

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

TOLERANCE_SECONDS = 300


def verify_centaur_signature(header, raw_body, secrets, now_seconds=None):
    now_seconds = int(time.time()) if now_seconds is None else now_seconds
    raw_body = raw_body.encode() if isinstance(raw_body, str) else raw_body
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1":
            signatures.append(value.encode())
    if timestamp is None or abs(now_seconds - timestamp) > TOLERANCE_SECONDS:
        return False
    for secret in secrets:
        expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest().encode()
        if any(hmac.compare_digest(expected, signature) for signature in signatures):
            return True
    return False
```

Pass the raw body as bytes or as the exact string received. In Express, for example, mount the route with `express.raw({ type: 'application/json' })` and parse `req.body` only after verifying it.

After you rotate the secret, every delivery carries one `v1` for the new secret and one for the previous secret for 24 hours. Deploy the new secret within that window. Accepting both secrets in the meantime means no delivery fails verification. Rotating again within the 24 hours retires the oldest secret at once, so only the two newest secrets ever sign.

### Test vectors

Check your code against these fixed values. They use `t = 1759658400`, a time in the past, so pass that time as the current time when you test.

| Input | Value |
| - | - |
| Previous secret | `whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8` |
| New secret | `whsec_ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8` |
| Raw body | `{"id":"01a0e77a-0a10-79f4-94ae-460be7c2ff7f","type":"trade_event.created"}` |
| Signature with the previous secret | `9ddbc989941745c3b41bf8003a840bed37c2a755e9a9af58f14eef7b6140e11e` |
| Signature with the new secret | `9d900da8b33831c814b94d20ac9baa0f1b532b0dbf7ebfb33b72b250a203c95a` |

During the rotation from the previous secret to the new one, the header is:

```text theme={null}
t=1759658400,v1=9d900da8b33831c814b94d20ac9baa0f1b532b0dbf7ebfb33b72b250a203c95a,v1=9ddbc989941745c3b41bf8003a840bed37c2a755e9a9af58f14eef7b6140e11e
```

`verifyCentaurSignature(header, body, [newSecret], 1759658400)` and `verify_centaur_signature(header, body, [previous_secret], now_seconds=1759658400)` both return true. Changing one byte of the body makes them return false. You can reproduce a signature without any code:

```bash theme={null}
printf '%s' '1759658400.{"id":"01a0e77a-0a10-79f4-94ae-460be7c2ff7f","type":"trade_event.created"}' \
  | openssl dgst -sha256 -hmac 'whsec_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8'
```

### Send a test delivery

`POST /v2/webhooks/{id}/test` sends the `trade_event.created` example from [Webhook payloads](/guides/rest/webhook-events.md) to that endpoint only, whatever its event types and filters. It has a new event `id`, the current time as `occurredAt`, and a top-level `test: true` marker inside the signed body. Real events never carry `test`, including real events on staging. A test is signed and retried like any delivery and keeps its marker on retries and replays, including tests created before the marker existed. The response gives its `eventId`, `deliveryId`, and `status`: `PENDING`, or `HELD` when the endpoint is paused or disabled.

## Manage endpoints

These routes take the endpoint `id`. The [API reference](/api-reference/overview.md) has every request and response.

| Route | Effect |
| - | - |
| `GET /v2/webhooks`, `GET /v2/webhooks/{id}` | List your endpoints or read one. Secrets are never returned. |
| `PATCH /v2/webhooks/{id}` | Change the URL, description, event types, filters, or `maxInFlight`. A new URL is checked again. `filters` replaces the stored filters, and `{}` removes them. |
| `DELETE /v2/webhooks/{id}` | Delete the endpoint. It receives nothing further. |
| `POST /v2/webhooks/{id}/pause` | Hold new deliveries until you resume. |
| `POST /v2/webhooks/{id}/resume` | Send everything held while paused, then continue. |
| `POST /v2/webhooks/{id}/disable` | Turn the endpoint off, with an optional `reason`. New deliveries are held. |
| `POST /v2/webhooks/{id}/enable` | Turn a disabled endpoint back on and check its URL again. Held deliveries stay held until you replay them. |
| `POST /v2/webhooks/{id}/rotate-secret` | Issue a new signing secret, shown once. |
| `POST /v2/webhooks/{id}/test` | Send one [test delivery](#send-a-test-delivery). |
| `POST /v2/webhooks/{id}/replay` | Send past deliveries again. See [Replay](#replay). |
| `GET /v2/webhooks/{id}/deliveries` | List deliveries, newest first, with status, attempt count, and your latest response status. Filter with `status`. Deliveries are kept for seven days. |
| `GET /v2/webhooks/{id}/deliveries/{deliveryId}` | Read one delivery with its payload and, when your latest response was not a 2xx, the first 512 bytes of your response body. |

Pause and resume work on active and paused endpoints. Enable works on disabled endpoints. Pausing or resuming a disabled endpoint, or enabling a paused one, returns `409`.

A delivery's `status` is one of:

* `PENDING`: waiting for its first or next attempt.
* `IN_FLIGHT`: being sent.
* `SUCCEEDED`
* `HELD`: kept unsent while the endpoint is paused or disabled.
* `DEAD`: every attempt failed. See [Retries](#retries-and-dead-letters).

## Retries and dead letters

A delivery succeeds when your endpoint answers with a 2xx within 10 seconds. Otherwise Centaur retries it with growing, randomized waits: six attempts over about two hours. A retry keeps the event `id`, `sequence`, and payload, with a fresh signature timestamp.

When the sixth attempt fails, the delivery becomes a dead letter with status `DEAD`. A `410 Gone`, or a host that resolves to an address Centaur refuses, makes it a dead letter at once and disables the endpoint. Dead letters stay in the endpoint's deliveries, and you can [replay](#replay) them.

## Disabled endpoints

Centaur disables an endpoint, and sets `disabledReason`, when:

* it answers `410 Gone` (`410_gone`). Use this status to tell Centaur to stop.
* at least 50 attempts in a row have failed and it has had no success for about two hours (`sustained_failure`). The two-hour clock restarts when you enable or resume the endpoint.
* its host starts resolving to an address Centaur refuses (`unsafe_url`).

When you disable an endpoint yourself, `disabledReason` is the reason you gave, or `partner_request`. Centaur may also disable an endpoint with a note.

While an endpoint is paused or disabled, Centaur keeps creating its deliveries as `HELD` for the seven-day retention window and sends none of them. Deliveries already waiting for an attempt are held too. Resuming a paused endpoint sends its held deliveries. Enabling a disabled endpoint does not: replay the range you need.

## Replay

`POST /v2/webhooks/{id}/replay` sends past deliveries again. Give the start of the range as a time or as a sequence number:

```bash theme={null}
curl --fail-with-body -X POST 'https://api.centaur.io/v2/webhooks/<endpoint-id>/replay' \
  -H "x-api-key: $CENTAUR_API_KEY" \
  -H 'content-type: application/json' \
  -d '{ "from": "2026-10-05T08:00:00.000Z" }'
```

* `from` selects deliveries created at or after that time, at most seven days ago. Older events are deleted, so reconcile beyond that with REST reads.
* `fromSequence` reaches only deliveries that were already attempted. Held deliveries that were never attempted have no sequence yet, so release them with `from`.
* Held deliveries in the range are released. Succeeded and dead deliveries are sent again as new deliveries with the same event `id` and a new `sequence`, so deduplicate on `id`. Deliveries already waiting or in flight are left alone.
* The endpoint must be active. Resume or enable it first.
* A replay that resends deliveries can run once a minute per endpoint. Otherwise the API returns `429` with `Retry-After`.

The response reports `released`, the held deliveries released, and `reenqueued`, the deliveries sent again.

## Limitations

* **Detection is periodic.** Centaur checks for trade and feed changes every 30 seconds and recalculates each positioning view at most every 1, 5, 10, or 15 minutes, depending on the view. Events arrive some time after the change.
* **Only changes after registration.** An endpoint receives changes detected after it was created. It can receive a `trade_event.deleted`, `feed_item.updated`, or `feed_item.deleted` for an item it never saw created.
* **Corrections can reissue unchanged events under new IDs.** When a trader's history is corrected, Centaur deletes that trader's later events and creates them again with new IDs, often with the same content. You receive `trade_event.deleted` and `trade_event.created` pairs with `origin: replacement`, and one `feed_item.updated` per affected message. Position IDs can change too, even on unchanged trades from later messages. `origin: replacement` is a same-message, same-pass hint, not a guaranteed correction label or an old-to-new ID map, so do not use it to track position identity. A pair with the same content is an ID change, not a new signal. One correction can send a burst as large as the trader's history after the corrected time, and `maxInFlight` caps how many of those requests reach you at once. A very large correction can span several detection passes, so some re-created events carry `origin: new` and a message can get more than one `feed_item.updated`. To receive fewer events, subscribe to feed items only, where `current` shows that nothing changed.
* **`removed` is not a reversal.** An event in `removed`, or a `trade_event.deleted`, means stop treating that event as true. It does not mean the trader reversed the trade.
* **Short-duration positions are delivered.** `/events`, `/feed`, and `/positions` hide positions that lasted 60 seconds or less. Webhooks still deliver their events.
* **No events for source edits or deletions.** Source text is the version Centaur extracted the trade from: the original message, or its earliest edit when the original is unavailable. Later edits and deletions produce no event and do not remove extracted trades on their own.
* **No events for visibility changes.** When a trader becomes visible or hidden, or joins or leaves the feed's editorial exclusions, existing items produce no events. Use a fresh REST read to reconcile.
* **No history window for feed items.** A late event on an older message produces a feed item event even when the message is outside the feed's seven-day window.
* **Prices can be missing.** Trade prices may be null. A later price update does not send a follow-up trade webhook.
* **Positioning depends on prices.** Early Opportunities leaves out candidates without the prices it needs, so partial price failures can change membership and send alerts. Delivery does not hold back old rows or suppress those changes. Check the payload's `coverage` and `missingPriceGroups`.


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