# Webhooks

Instead of polling, register an endpoint and receive a signed `POST` each time
an asynchronous analysis completes.

## Register an endpoint

cURL:

```bash
curl -X POST "$API/v1/webhooks?api_key=$ANALYZE_API_KEY" \
  -H "Authorization: Bearer $(sign)" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/hooks/analyze","signal_keys":["is_scammer"]}'
```

Node.js:

```ts
const { endpoint, secret } = await sigwise.webhooks.create({
  url: "https://example.com/hooks/analyze",
  signal_keys: ["is_scammer"],
});
```

Python:

```python
created = analyze.webhooks.create(url="https://example.com/hooks/analyze", signal_keys=["is_scammer"])
secret = created["secret"]
```

The response includes the endpoint's signing **secret, shown only once**. Store
it with your application's secrets.

`signal_keys` is optional: with it, the endpoint only receives analyses that
answered one of those signals. Update an endpoint with
`PATCH /v1/webhooks/{endpoint_id}` (for example `{"enabled": false}` to pause
it) and remove it with `DELETE`. Deliveries already queued for a paused
endpoint wait and resume when you enable it again.

## Choosing events

`events` picks which event types an endpoint receives. Omit it to receive all
of them.

| Event | Sent when |
|-------|-----------|
| `analysis.completed` | Any analysis of an object finishes. |
| `rule.triggered` | A [rule](https://sigwise.ai/docs/guides/rules.md) whose webhook action points at this endpoint fires. |

To be notified only when a rule matches, for example `is_scammer > 50`,
register an endpoint that receives rule firings and nothing else, then point
the rule's webhook action at it:

```bash
curl -X POST "$API/v1/webhooks?api_key=$ANALYZE_API_KEY" \
  -H "Authorization: Bearer $(sign)" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com/hooks/scammers","events":["rule.triggered"]}'
```

## Payload

```http
POST /hooks/analyze HTTP/1.1
Content-Type: application/json
User-Agent: analyzeapi-webhooks/1
X-Webhook-Event: analysis.completed
X-Webhook-Delivery: 3f0c1c8e-7a5e-4e0b-9f5d-0a4d7f6c2b11
X-Webhook-Timestamp: 1790000000
X-Webhook-Signature: sha256=5d41402abc4b2a76b9719d911017c592…
```

```json
{
  "event": "analysis.completed",
  "object_id": "user-42",
  "tenant_id": "0c6f…",
  "model": "model-1",
  "occurred_at": "2026-09-27T10:00:05Z",
  "answers": [
    { "signal": "is_scammer", "type": "noul", "noul": 0.91 }
  ]
}
```

`answers` has the same shape as a [direct moderation](https://sigwise.ai/docs/guides/moderation.md)
verdict. [Rules](https://sigwise.ai/docs/guides/rules.md) with a webhook action send `rule.triggered`
events through the same machinery; both payloads are in the
[API reference](https://sigwise.ai/docs/reference.md).

## Verify every delivery

`X-Webhook-Signature` is `sha256=` followed by the hex HMAC-SHA256 of
`"."`, keyed with the endpoint secret. Verify it
against the **raw** body (before any JSON parsing), compare in constant time,
and reject timestamps more than a few minutes old to stop replays.

Node.js:

```ts
import { constructWebhookEvent } from "@sigwise/sdk";

app.post("/hooks/analyze", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = constructWebhookEvent(
      req.body, // Buffer
      req.header("x-webhook-signature"),
      req.header("x-webhook-timestamp"),
      process.env.ANALYZE_WEBHOOK_SECRET!,
    );
    handle(event);
    res.sendStatus(204);
  } catch {
    res.sendStatus(400);
  }
});
```

Python:

```python
from sigwise import WebhookVerificationError, construct_webhook_event

@app.post("/hooks/analyze")
def hook():
    try:
        event = construct_webhook_event(
            request.get_data(),
            request.headers.get("X-Webhook-Signature"),
            request.headers.get("X-Webhook-Timestamp"),
            os.environ["ANALYZE_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError:
        return "", 400
    handle(event)
    return "", 204
```

Go:

```go
func hook(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	ev, err := sigwise.ParseWebhookEvent(body,
		r.Header.Get("X-Webhook-Signature"), r.Header.Get("X-Webhook-Timestamp"),
		os.Getenv("ANALYZE_WEBHOOK_SECRET"), 0)
	if err != nil {
		http.Error(w, "invalid signature", http.StatusBadRequest)
		return
	}
	handle(ev)
	w.WriteHeader(http.StatusNoContent)
}
```

PHP:

```php
use SigWise\Webhook;
use SigWise\WebhookVerificationException;

try {
    $event = Webhook::constructEvent(
        file_get_contents('php://input'),
        $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null,
        $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? null,
        getenv('ANALYZE_WEBHOOK_SECRET'),
    );
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    exit;
}
```

Go (no SDK):

```go
func verify(secret, timestamp, signature string, body []byte) bool {
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(body)
	want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(want), []byte(signature))
}
```

## Retries and the dead-letter queue

Respond with any `2xx` quickly (within 10 seconds) and do slow work
afterwards.

- Network errors, timeouts, `429` and `5xx` responses are **retried** with
  exponential backoff and jitter (starting at about 30 seconds and doubling),
  up to 6 attempts.
- Other `4xx` responses are **permanent** and not retried.
- A delivery that fails permanently or runs out of attempts moves to the
  **dead-letter queue** (`status: dead`), keeping its payload and last response.

Deliveries can arrive more than once and out of order. Deduplicate on
`X-Webhook-Delivery` (retries of one delivery share it) and use `occurred_at`
to ignore stale updates.

Inspect deliveries with `GET /v1/webhook_deliveries` (`?status=dead`,
`?endpoint_id=`) and re-send one with
`POST /v1/webhook_deliveries/{delivery_id}/replay` once your endpoint is fixed.
