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

# Webhooks

> Get a run's result pushed to your server, signed.

Add `webhook.url` (a public `https` URL) to `POST /v1/runs`. When the run ends, Spiral posts one event with the full
run object.

## Events

| Event | When |
| - | - |
| `run.completed` | The run finished; `output` holds the results. |
| `run.failed` | The run ended with an `error`. |
| `run.canceled` | The run was stopped before it ran. |

New event types are added over time: ignore the ones you do not handle.

## Headers

Deliveries follow [Standard Webhooks](https://www.standardwebhooks.com):

| Header | Meaning |
| - | - |
| `webhook-id` | `<run id>:<event type>`, the same on every attempt: dedupe on it for at least 7 days. |
| `webhook-timestamp` | When this attempt was sent. |
| `webhook-signature` | `v1,<base64>`: HMAC-SHA256 over `id.timestamp.body` with your signing secret. During a secret rotation it carries two signatures; either one verifies. |

## Verify the signature

Find your organization's signing secret in the console under **API keys**, and verify every delivery over the raw
body:

```ts Node.js theme={null}
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.SPIRAL_WEBHOOK_SECRET);

export async function POST(req: Request) {
  const body = await req.text();
  const event = wh.verify(body, Object.fromEntries(req.headers)); // throws when the signature is wrong
  // queue the work, then answer fast
  return new Response(null, { status: 204 });
}
```

```python Python theme={null}
from standardwebhooks import Webhook

wh = Webhook(os.environ["SPIRAL_WEBHOOK_SECRET"])

def handle(request):
    event = wh.verify(request.body, request.headers)  # raises when the signature is wrong
    # queue the work, then answer fast
    return 204
```

## Delivery

* Answer `2xx` within 10 seconds and do the work afterwards. A delivery is re-sent with the same `webhook-id` until a
  `2xx`, for up to 24 hours.
* A body over 2.5 MiB (or one your server answers `413`) is sent thin: `truncated: true` and no `output`. Read the run
  with `GET /v1/runs/{id}`.
* The webhook is sent right after the run is charged, so it usually carries the final charge (`sequence` 4). When the
  provider's bill comes later it carries `cost.state: held` (`sequence` 3), and the final charge is on
  `GET /v1/runs/{id}`.
* Ignore a delivery whose `sequence` is lower than one you already applied.


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