> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.respona.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.respona.com/_mcp/server.

# Webhooks

Webhooks let Respona **push** order and placement status changes to an HTTPS endpoint you host, so you don't have to
[poll](/guides/core-concepts/orders-placements). They complement polling - the webhook tells you *something changed*; you then
read authoritative state (and live pricing) back from the API.

Webhooks are configured per workspace from **Settings → Webhooks** (the same place you manage API keys). Webhook
delivery is server-to-server; the endpoint you register must be a public HTTPS URL.

## Events

| Event                      | Fires when                                                                                              |
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
| `order.status_changed`     | A client order changes status (e.g. `DRAFT` → `LAUNCHED` → `IN_PROGRESS` → `COMPLETED`).                |
| `placement.status_changed` | A placement changes status (e.g. `ORDERED` → `IN_PROGRESS` → `PENDING_APPROVAL` → `APPROVED` → `LIVE`). |

The `status` / `previous_status` strings match exactly what `GET /rest/api/v1/orders/{order_id}` returns. `DRAFT`
transitions are **not** delivered (you created the draft yourself), and transitions that don't change the public status
are suppressed.

## Payload

`Content-Type: application/json`, snake\_case, and **no tenant identifiers**. `order_id` / `placement_id` are the same
identifiers the REST API uses, so you can read current state straight back.

```json
{
  "event": "placement.status_changed",
  "delivery_id": "b3f1c2a4-...",
  "webhook_id": "9d8c7b6a-...",
  "occurred_at": "2026-07-27T10:15:30Z",
  "api_version": "v1",
  "data": {
    "order_id": "48213",
    "placement_id": "99120",
    "number": "PL-1042",
    "status": "LIVE",
    "previous_status": "IN_PROGRESS"
  }
}
```

Headers on every delivery:

| Header                  | Meaning                                                                               |
| ----------------------- | ------------------------------------------------------------------------------------- |
| `X-Respona-Signature`   | `t=<unix_seconds>,v1=<hex-hmac>` - see [Verifying signatures](#verifying-signatures). |
| `X-Respona-Event`       | The event type, e.g. `placement.status_changed`.                                      |
| `X-Respona-Delivery-Id` | Unique per delivery. Use it to dedupe.                                                |
| `X-Respona-Webhook-Id`  | The endpoint that received this event.                                                |
| `X-Respona-Api-Version` | Payload version (`v1`).                                                               |

## Verifying signatures

Each delivery is signed with the endpoint's signing secret (shown **once** when you create or rotate the endpoint -
store it somewhere safe). Verify before trusting a payload:

1. Read the `X-Respona-Signature` header - it looks like `t=1769509200,v1=5257a869...`.
2. Compute `HMAC-SHA256(secret, "{t}.{raw_request_body}")` as lowercase hex.
3. Compare it (constant-time) against the `v1` value. Reject if it doesn't match.
4. Reject deliveries whose `t` is too old (e.g. more than 5 minutes) to blunt replay.

```python
import hashlib, hmac, time

def verify(secret: str, signature_header: str, raw_body: bytes, tolerance_seconds: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = int(parts["t"])
    if abs(time.time() - timestamp) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    # The header may carry more than one v1= value during a secret rotation; accept if any matches.
    provided = [v for k, v in (p.split("=", 1) for p in signature_header.split(",")) if k == "v1"]
    return any(hmac.compare_digest(expected, p) for p in provided)
```

During a **secret rotation** the signature header carries two `v1=` values (old and new) for the grace window, so a
consumer mid-rollout verifies against either. Once you've moved to the new secret, the old one stops signing when the
window closes.

## Delivery, retries, and ordering

* **At-least-once.** A delivery may arrive more than once. Make your handler idempotent by deduping on
  `X-Respona-Delivery-Id`.
* **Respond fast with a 2xx.** Any `2xx` within the timeout is success. Do slow work asynchronously - acknowledge
  first, process after.
* **Retries with backoff.** A non-2xx response or a timeout is retried on an increasing schedule (roughly 1m, 5m, 30m,
  2h, 6h) before the delivery is marked exhausted.
* **Order is best-effort, not guaranteed.** Two rapid transitions can arrive out of order. Every payload carries
  `occurred_at` and `previous_status` so you can order events and detect gaps; treat the REST API as the source of
  truth.
* **Auto-pause.** An endpoint that fails many deliveries in a row is automatically paused; resume it from Settings once
  you've fixed it.

## Managing endpoints

From **Settings → Webhooks** you can create, edit, pause, and delete endpoints, rotate the signing secret (with an
overlap window so you never miss a beat), pick which events an endpoint receives, and review a **delivery history**
with per-attempt response codes plus a **Redeliver** button to replay a past delivery.

## Security notes

* Endpoints must be **HTTPS** and resolve to a **public** address - URLs pointing at private, loopback, or link-local
  ranges are rejected at registration and re-checked at delivery time.
* The signing secret is shown once and never again. If you lose it, rotate to get a new one.
* Treat the secret like a password: never log it, never expose it client-side.