# Webhooks

Start a run with a `webhook` and Jinn posts its result there once: `run.succeeded` or `run.failed`. The body is the run, as `GET /v1/runs/{id}` returns it.

```json
{
  "id": "evt_7c41e0a9d2b35f8e61c09a4d",
  "type": "run.succeeded",
  "created": "2026-10-06T09:14:03Z",
  "data": { "id": "run_7c41e0a9d2b35f8e61c09a4d", "state": "succeeded", "output": { "url": "https://…", "files": [ … ] }, … }
}
```

Jinn tries three times: at once, after a minute and after ten minutes. Answer with a `2xx` status within 10 seconds. If every try fails, read the run instead.

## Check the signature

Webhooks follow [Standard Webhooks](https://www.standardwebhooks.com/), signed with Ed25519 (`v1a`). Each account has its own key. Find its public key (`whpk_…`) in the console under **Account**, or at `GET /v1/webhooks/public-key`.

Each request has three headers:

| Header | What it is |
|---|---|
| `webhook-id` | The event's id. |
| `webhook-timestamp` | When it was signed, in Unix seconds. |
| `webhook-signature` | `v1a,` and the base64 Ed25519 signature of `{webhook-id}.{webhook-timestamp}.{body}`. |

Refuse a request whose signature does not verify, or whose timestamp is more than five minutes from now.

Go:

```go
event, err := jinn.VerifyWebhook(publicKey, r.Header, body, time.Now())
```

TypeScript:

```ts
const event = await verifyWebhook(publicKey, request.headers, await request.text());
```

Python, with `cryptography`:

```python
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
import base64

key = Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key.removeprefix("whpk_")))
signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.{body}".encode()
signature = base64.b64decode(headers["webhook-signature"].split(",", 1)[1])
key.verify(signature, signed)  # raises if it does not match
```
