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

# Webhooks

> Receive signed HTTP callbacks when a run starts, completes, or fails.

Webhooks let your app react to [run](/api-reference/concepts/canvases-and-runs) lifecycle events without polling. When an event fires, Spine POSTs a signed JSON envelope to a URL you configured, and you verify it with a shared secret.

Pair webhooks with [polling](/api-reference/concepts/polling-and-status) if you want both — the delivery tells you when a run is ready, and `GET /v1/run/{run_id}` gives you the full shape on demand.

<Note>
  Webhook endpoints are configured per workspace in the Spine developer portal. A single endpoint can subscribe to one or more event types, or to `*` for everything.
</Note>

## Event types

| Event           | Fires when                                                                               |
| --------------- | ---------------------------------------------------------------------------------------- |
| `run.started`   | Immediately after [`POST /v1/run`](/api-reference/endpoints/create-run) creates the run. |
| `run.completed` | The run reaches terminal status `completed`. Includes `final_output` and artifacts.      |
| `run.failed`    | The run reaches terminal status `failed`. Includes the error message.                    |
| `webhook.ping`  | Fired by **Send test event** in the portal — use it to exercise your verifier.           |

## Event envelope

Every delivery has the same top-level shape.

```json theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
{
  "id": "evt_78ffe7c625d94d2192b2f5a8d63933a6",
  "type": "run.completed",
  "created": 1745251200,
  "livemode": true,
  "api_version": "2026-04-01",
  "data": {
    "object": {
      "run_id": "06bb6cee-7982-48af-acd4-05d21ce39ed5",
      "canvas_id": "3c86545a-2e06-4c29-aac5-557f897c8ada",
      "status": "completed",
      "duration_ms": 10736,
      "artifacts": [],
      "final_output": "2 + 2 equals 4."
    }
  }
}
```

| Field         | Type   | Notes                                                                                                |
| ------------- | ------ | ---------------------------------------------------------------------------------------------------- |
| `id`          | string | `evt_<uuid>`. Unique per delivery — use for idempotency.                                             |
| `type`        | string | One of the event types above.                                                                        |
| `created`     | int    | Unix seconds when the event was generated.                                                           |
| `livemode`    | bool   | `true` in production, `false` in development / staging.                                              |
| `api_version` | string | Envelope schema version. Bumped on breaking changes.                                                 |
| `data.object` | object | The payload. For `run.*` events, mirrors [`GET /v1/run/{run_id}`](/api-reference/endpoints/get-run). |

## Request headers

```
Content-Type: application/json
User-Agent: Spine-Webhooks/1.0
Spine-Signature: t=1745251200,v1=5fe3f78dbf86c53084d1bd1222005d3c064567937e5ceafb904e30551fdb89ad
Spine-Event-Id: 37070895-029a-4802-a5da-4052e7964cc3
Spine-Event-Type: run.completed
```

`Spine-Signature` is `t={unix_ts},v1={hex}` — timestamp and HMAC-SHA256 over `{t}.{raw_body}`. `Spine-Event-Id` equals `event.id` in the body; persist it to dedupe. `Spine-Event-Type` is redundant with `event.type` but handy for routing before JSON parse.

## Verify the signature

<Warning>
  Always verify the signature before processing a delivery. Anyone who learns your webhook URL can send unsigned POSTs; the signature proves the body came from Spine.
</Warning>

Given the `whsec_...` secret you stored when you created the endpoint, the raw request body, and the `Spine-Signature` header, verification is five steps:

1. **Parse the header.** Split on `,` to extract `t` (unix seconds) and every `v1=` value.
2. **Check freshness.** Reject if `abs(now - t) > 300` — blocks replayed deliveries.
3. **Rebuild the signed payload.** Literally `{t}.{raw_body}`: the header's `t` value, a single `.`, then the **raw request body bytes**. Not the parsed JSON — frameworks may reorder keys and invalidate the signature.
4. **Recompute the HMAC.** `HMAC-SHA256(secret, signed_payload)` as a lowercase hex digest.
5. **Constant-time compare** against any `v1` value from the header. Use `hmac.compare_digest` (Python) or `crypto.timingSafeEqual` (Node) — never `==`.

<CodeGroup>
  ```python Python theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
  import hmac, hashlib, time

  TOLERANCE_SECONDS = 300

  def verify_spine_webhook(secret: str, raw_body: bytes, header: str) -> bool:
      pairs = [kv.split("=", 1) for kv in header.split(",") if "=" in kv]
      t = next((v for k, v in pairs if k == "t"), None)
      v1s = [v for k, v in pairs if k == "v1"]
      if not t or not v1s:
          return False
      if abs(int(time.time()) - int(t)) > TOLERANCE_SECONDS:
          return False
      expected = hmac.new(
          secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
      ).hexdigest()
      return any(hmac.compare_digest(expected, v) for v in v1s)
  ```

  ```typescript TypeScript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
  import crypto from "node:crypto";

  const TOLERANCE_SECONDS = 300;

  export function verifySpineWebhook(
    secret: string,
    rawBody: Buffer | string,
    header: string,
  ): boolean {
    const pairs = header.split(",").map((s) => s.trim().split("="));
    const t = pairs.find(([k]) => k === "t")?.[1];
    const v1s = pairs.filter(([k]) => k === "v1").map(([, v]) => v);
    if (!t || v1s.length === 0) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - parseInt(t, 10)) > TOLERANCE_SECONDS) {
      return false;
    }
    const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : rawBody;
    const signed = Buffer.concat([Buffer.from(`${t}.`), body]);
    const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
    const e = Buffer.from(expected);
    return v1s.some((s) => {
      const b = Buffer.from(s);
      return b.length === e.length && crypto.timingSafeEqual(e, b);
    });
  }
  ```
</CodeGroup>

Express parses JSON by default and discards the raw bytes needed for verification — capture them first.

```typescript theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
app.post(
  "/webhooks/spine",
  express.raw({ type: "application/json" }),
  (req, res) => {
    if (!verifySpineWebhook(SECRET, req.body, req.header("Spine-Signature") ?? "")) {
      return res.status(400).json({ error: "invalid signature" });
    }
    const event = JSON.parse(req.body.toString("utf8"));
    // dedupe on event.id, then handle event.type
    res.status(200).json({ ok: true });
  },
);
```

## Test your setup

Before wiring real events to your verifier, send a ping and confirm it parses cleanly.

1. In the Spine developer portal, open your webhook endpoint and click **Send test event**. This fires a `webhook.ping` delivery through the real pipeline — signed, headered, same shape as production events.
2. Confirm your endpoint returned `2xx` in the portal's delivery log. A non-2xx means your verifier rejected a legitimate Spine signature; inspect the delivery's response body for your error message.
3. Click **Resend** on the delivery to re-fire it as you iterate.

The ping envelope is intentionally minimal so you can assert on it:

```json theme={"theme":{"light":"github-light","dark":"tokyo-night"}}
{
  "id": "evt_...",
  "type": "webhook.ping",
  "created": 1745251200,
  "livemode": false,
  "api_version": "2026-04-01",
  "data": { "object": { "ok": true } }
}
```

Common causes of a failed verifier:

* **Body was re-parsed before hashing.** Hash the bytes you received, not the re-serialized JSON. Express needs `express.raw()`; FastAPI needs `await request.body()` on the `Request` object.
* **Wrong secret.** After rotation, the previous secret stops signing immediately — update the value in your app before the next delivery.
* **Clock skew.** If your server drifts more than 5 minutes from Spine's clock, every delivery will fail the freshness check. Run NTP.
* **Non-constant-time compare.** Fine functionally, but leaks timing information to attackers. Use the platform primitive.

## Responding

Return any `2xx` status within **10 seconds** to mark the delivery as succeeded. Anything else — non-2xx, timeout, network error — is recorded as `failed` in the delivery log. Do heavy work asynchronously: acknowledge fast, process later.

## Idempotency

Two situations can look like duplicates:

* **Resend.** Clicking **Resend** in the portal clones a past delivery and fires a fresh one with a **new** `Spine-Event-Id`.
* **Repeat trigger.** If a run transitions through a terminal state more than once (rare), each transition issues a new delivery with a new `Spine-Event-Id`.

Either way: persist the `Spine-Event-Id` alongside the downstream effect, and skip if you've seen it.

## Secret management

The full `whsec_...` secret is shown exactly once when you create or rotate an endpoint. Store it in a secret manager or your `.env` — after the reveal, the portal shows only a masked form like `whsec_••••abcd`.

Rotating invalidates the old secret immediately. If multiple consumers share a secret, update your verifiers before rotating.

## Notes

* Deliveries are single-attempt in v1. Transient failures are recorded and can be re-fired with **Resend** in the portal. Automatic retries with exponential backoff are planned.
* Use `*` in the events list to subscribe an endpoint to every event type, including ones added later.
* HTTPS is enforced in production.
