# Webhooks

Instead of polling, subscribe to signed outbound webhooks for job completion.

## Endpoints & events

```http
POST /api/v1/webhook_endpoints
Authorization: Bearer norano_sk_live_…
{ "url": "https://your-app.example/hooks/norano",
  "enabled_events": ["job.succeeded", "job.failed"] }
```

The response includes a signing secret `whsec_…`, shown **once**. Events are
job-centric: `job.queued`, `job.running`, `job.succeeded`, `job.failed`,
`job.canceled`. Most agents subscribe only to the two terminal events. Each event
has a stable `id`, `type`, `created`, and a `data.object` that is the full Job
(see /docs/api/jobs).

## Signature scheme

Each delivery carries:

```http
Norano-Signature: t=<unix>,v1=<hex HMAC-SHA256>
Norano-Webhook-Id: <event id>
```

The signed payload is the exact string `"{t}.{raw_request_body}"`, keyed by your
endpoint secret. **Verify against the raw body** (before any JSON parse), with a
**constant-time** compare. Reject if `now - t > 300s` (5-minute tolerance) and
dedupe on the event id to defeat replays.

## Verify — Node

```js
import crypto from 'node:crypto';

// rawBody: the exact bytes of the request body (Buffer or string)
// header: the value of the "Norano-Signature" header
export function verifyNoranoWebhook(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
  const t = Number(parts.t);
  const sig = parts.v1;
  if (!t || !sig) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(sig);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

## Verify — Python

```python
import hashlib
import hmac
import time

def verify_norano_webhook(raw_body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
    # Parse defensively: a malformed header (a part without "=") must REJECT, not raise.
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
        sig = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance_sec:
        return False

    signed = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
```

## Delivery & replay

Deliveries retry with exponential backoff (~6 attempts over ~24h); a 2xx marks the
event delivered. Each attempt is recorded in a delivery log; you can replay a
delivery with `POST /api/v1/webhook_deliveries/{id}/retry`.

## SSRF note (callback URLs are agent-supplied)

Because an agent supplies the callback URL, Norano accepts `https://` only and
rejects loopback, private, and link-local targets (including the cloud metadata IP),
re-resolves the host at delivery time, and does not follow redirects to disallowed
targets.
