# Webhooks

Coinset webhooks let you receive Coinset events at your own HTTPS endpoint.

## Quick start

1. Create a webhook in the Developer UI (or via the API).
2. Implement the **verification handshake** (Coinset tests your endpoint before it starts sending events).
3. Verify **signatures** on every delivery using the per-webhook secret.
4. Treat deliveries as **at-least-once** and deduplicate using `X-Coinset-Event-Id`.
   * Coinset may observe similar events from multiple regions; we do best-effort cross-region deduping, but you should still enforce idempotency on your side.

If you want a working local receiver you can copy/paste, see **[Webhook receiver (Node.js)](/docs/webhooks/receiver)**.

## Creating & managing webhooks

Webhook management is served under `\`/hooks\`\` and requires authentication:

* **Browser session** (passkey login), or
* **API key** (`Authorization: Bearer <token>`)

Endpoints:

* `GET /hooks` — list your webhooks
* `POST /hooks` — create (returns the secret **once**)
* `GET /hooks/:id` — get details
* `PATCH /hooks/:id` — update URL and/or filter (URL changes re-verify)
* `DELETE /hooks/:id` — delete
* `POST /hooks/:id/test` — enqueue a test delivery
* `POST /hooks/:id/rotate_secret` — rotate the secret

**API reference:** For exact request/response schemas, examples, and Try‑It, see the Coinset API docs:

* **[Coinset API (webhooks)](/docs/api?api=coinset)** (selects the Coinset API spec)

## Endpoint URL rules

Coinset validates webhook URLs to reduce SSRF risk:

* **HTTPS required**
  * `http://` is only allowed for local development on `localhost` / `127.0.0.1`
* **No userinfo** (`https://user:pass@host` is rejected)
* **No private IP literals** (e.g. `127.0.0.1`, `10.x`, `192.168.x`, `172.16–31.x`, `::1`, link-local)
* **No `.local` domains**

## Verification handshake (required)

New webhooks start as `pending_verification`. Coinset verifies the endpoint by sending a POST request:

* **Headers**
  * `X-Coinset-Webhook-Id: <hook_id>`
  * `X-Coinset-Webhook-Challenge: <uuid>`
* **Body**

```json
{
  "type": "coinset.webhook.verify",
  "hook_id": "…",
  "challenge": "…"
}
```

To pass verification:

* Return any **2xx** response.
* Optionally return JSON with a `challenge` field equal to the incoming challenge (Coinset checks it if present).

## Deliveries

All webhook deliveries are sent as `POST` with `content-type: application/json`.

### Delivery headers

* `X-Coinset-Webhook-Id`: webhook id
* `X-Coinset-Delivery-Id`: delivery attempt group id
* `X-Coinset-Event-Id`: SHA-256 hex of the raw JSON body (use for idempotency)
* `X-Coinset-Timestamp-Ms`: unix milliseconds timestamp used for signing
* `X-Coinset-Signature`: HMAC-SHA256 signature (hex)
* `X-Coinset-Region`: emitting region (optional; if present, matches the `region` field in the body)

### Signature verification

Coinset signs the exact bytes of:

* `"<timestamp_ms>."` (ASCII) **concatenated with**
* the **raw request body bytes**

Compare the result (hex) to `X-Coinset-Signature` using a constant-time compare.

Example (Node.js):

```js
import crypto from "crypto";

function verifyCoinsetSignature({ secret, timestampMs, rawBody, signatureHex }) {
  const mac = crypto.createHmac("sha256", secret);
  mac.update(`${timestampMs}.`);
  mac.update(rawBody); // Buffer
  const expected = mac.digest("hex");

  // constant-time compare
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signatureHex, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Event types

Coinset can deliver:

* **Peak events** (`type: "peak"`)
* **Reorg events** (`type: "reorg"`)
* **Transaction events** (`type: "transaction"`)
* **Offer lifecycle events** (`type: "offer"`)
* **Test deliveries** (`type: "coinset.webhook.test"`)
* **Verification** (`type: "coinset.webhook.verify"`)

Webhook deliveries use an envelope. The delivered JSON body looks like:

```json
{
  "region": "fmt | msp | ldn | sin",
  "instance_id": "<server-instance-id>",
  "seq": 123,
  "message": {
    "type": "transaction",
    "data": {
      "status": "pending | confirmed | removed",
      "ids": ["<txid>", "<txid>", "..."],
      "height": 123,
      "header_hash": "...",
      "p2s": ["<p2>", "..."],
      "incoming_p2s": ["<p2>", "..."],
      "outgoing_p2s": ["<p2>", "..."]
    }
  }
}
```

The delivered JSON body is the event payload (and is what gets hashed for `X-Coinset-Event-Id`).

Notes:

* `region` can be used as a **read-consistency hint** if you need to immediately fetch related data after receiving a delivery (e.g. call the matching region origin).
* For transaction events, Coinset may **shrink `ids`** to only those relevant to your filter and/or to suppress near-duplicate ids seen recently.
* Offer events include `offer_id`, `status`, and optional `tx_id`/`p2s` when available.
* Reorg events include `old_peak_height/hash`, `new_peak_height/hash`, and `reorg_depth`.

## Filters

Webhooks support a JSON filter object:

* `events`: list of event types (lowercased). If omitted, Coinset defaults effectively to `["peak"]`.
* `tx_id`: list of transaction ids (lowercased). Hard cap: **50**.
* `tx_status`: list of `pending | confirmed | removed` (only for transaction events).
* `tx_direction`: list of `incoming | outgoing` (only for transaction events).
* `offer_id`: list of offer ids (lowercased).
* `offer_status`: list of offer lifecycle statuses (`open | pending | confirmed | cancel_pending | cancelled | expired | parse_failed`).
* `p2`: list of puzzle hashes used to match transaction/offer participants.

Example: only confirmed transaction events for a set of tx ids:

```json
{
  "events": ["transaction"],
  "tx_id": ["<txid1>", "<txid2>"],
  "tx_status": ["confirmed"],
  "tx_direction": ["incoming"],
  "p2": ["<p2>"]
}
```

## Reliability & retries

Deliveries are **at-least-once**:

* Coinset retries failed deliveries with exponential backoff (up to **10 attempts**).
* Each attempt has a timeout (currently **5 seconds**).
* After **10 consecutive failures**, the webhook is automatically set to `disabled`.

Best practices:

* Return **2xx quickly**, queue work internally.
* Use `X-Coinset-Event-Id` to dedupe.
* Log `X-Coinset-Delivery-Id` and `X-Coinset-Webhook-Id` for debugging.
