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

# Webhooks

> Skip polling — receive a signed POST when a run completes or fails.

Two delivery patterns are supported:

* **Per-request** — include `webhook_url` on a single generation call.
  One-shot; fires once for that task.
* **Subscriptions** — call `POST /webhooks` once per org; fires for every
  matching event thereafter.

Subscriptions are the right default for production. Per-request is handy
for ad-hoc scripts or testing.

## Events

Currently we fire two events:

* `run.completed` — a task transitioned to `status=completed`
* `run.failed` — a task transitioned to `status=failed`

Subscriptions are opt-in per event via the `events` field when subscribing
(see below). Subscribe only to the events you actually handle.

## Subscribing

```bash theme={null}
POST /api/v1/webhooks
X-API-Key: $OVERTEN_API_KEY
Content-Type: application/json

{
  "url": "https://myapp.example.com/webhooks/overten",
  "events": ["run.completed", "run.failed"],
  "description": "Production production pipeline"
}
```

Response:

```json theme={null}
{
  "webhook_id": "whk_...",
  "url": "https://myapp.example.com/webhooks/overten",
  "events": ["run.completed", "run.failed"],
  "description": "Production pipeline",
  "active": true,
  "secret": "whsec_...",
  "created_at": "..."
}
```

**The `secret` is shown only once.** Save it — you need it to verify
signatures. Losing it means deleting the subscription and creating a new
one.

## Per-request webhooks

If you just want a single callback for one task:

```json theme={null}
POST /excel/generate
{
  "prompt": "...",
  "async": true,
  "webhook_url": "https://myapp.example.com/webhooks/overten"
}
```

This fires against your **org's primary webhook secret** (from
`GET /verify`), not a per-subscription one. Same signature scheme.

## Payload shape

```json theme={null}
POST https://myapp.example.com/webhooks/overten
Content-Type: application/json
X-Overten-Signature: sha256=<hex_hmac>
X-Overten-Timestamp: 1713312345
X-Overten-Event: run.completed
User-Agent: Overten-Webhook/1.0

{
  "event": "run.completed",
  "run_id": "run_01HXYZ",
  "task_id": "task_01HXYZ",
  "agent_name": "excel_agent_api",
  "format": "excel",
  "status": "completed",
  "download_url": "https://...",
  "edit_url": "https://...",
  "summary": "Created a 12-row sales report...",
  "occurred_at_ms": 1713312345678
}
```

On `run.failed` the shape is:

```json theme={null}
{
  "event": "run.failed",
  "run_id": "run_01HXYZ",
  "task_id": "task_01HXYZ",
  "agent_name": "excel_agent_api",
  "format": "excel",
  "error": {
    "code": "internal_error",
    "message": "..."
  },
  "occurred_at_ms": 1713312345678
}
```

## Verifying signatures

The signature is computed as:

```
signed_payload = f"{timestamp}.{raw_body}"
signature      = hmac.new(secret, signed_payload, sha256).hexdigest()
```

Where `timestamp` is the `X-Overten-Timestamp` header value and
`raw_body` is the exact bytes of the POST body (not re-serialized JSON).

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib, time
  from fastapi import Request, HTTPException

  SECRET = "whsec_..."

  async def verify(request: Request):
      body = await request.body()
      sig_header = request.headers.get("X-Overten-Signature", "")
      ts = request.headers.get("X-Overten-Timestamp", "")
      if not sig_header.startswith("sha256="):
          raise HTTPException(400, "missing signature")
      expected_sig = sig_header.removeprefix("sha256=")

      # Replay protection: reject > 5 min old
      if abs(time.time() - int(ts)) > 300:
          raise HTTPException(400, "timestamp too old")

      signed_payload = f"{ts}.{body.decode()}".encode()
      computed = hmac.new(SECRET.encode(), signed_payload, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(computed, expected_sig):
          raise HTTPException(400, "bad signature")
  ```

  ```javascript Node theme={null}
  import crypto from "node:crypto";

  const SECRET = process.env.OVERTEN_WEBHOOK_SECRET;

  export function verify(req) {
    const sigHeader = req.headers["x-overten-signature"] || "";
    const ts = req.headers["x-overten-timestamp"] || "";

    if (!sigHeader.startsWith("sha256=")) throw new Error("missing signature");
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("stale");

    const body = req.rawBody;  // original bytes, not JSON.stringify(req.body)
    const expected = crypto
      .createHmac("sha256", SECRET)
      .update(`${ts}.${body}`)
      .digest("hex");

    const given = sigHeader.replace(/^sha256=/, "");
    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(given))) {
      throw new Error("bad signature");
    }
  }
  ```

  ```go Go theme={null}
  package webhook

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "net/http"
      "strconv"
      "strings"
      "time"
  )

  const secret = "whsec_..."

  func Verify(r *http.Request, body []byte) error {
      sig := strings.TrimPrefix(r.Header.Get("X-Overten-Signature"), "sha256=")
      ts := r.Header.Get("X-Overten-Timestamp")

      n, _ := strconv.ParseInt(ts, 10, 64)
      if abs64(time.Now().Unix()-n) > 300 {
          return fmt.Errorf("stale timestamp")
      }

      signedPayload := ts + "." + string(body)
      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write([]byte(signedPayload))
      expected := hex.EncodeToString(mac.Sum(nil))

      if !hmac.Equal([]byte(sig), []byte(expected)) {
          return fmt.Errorf("bad signature")
      }
      return nil
  }
  ```
</CodeGroup>

## Retry policy

Non-2xx responses trigger retries with exponential backoff:

```
1s → 4s → 16s → 64s → 256s → 1024s → 4096s
```

Seven attempts total, \~1.5h total window. After the last attempt we give
up; the task itself is still completed (or failed) on our side — only the
notification was lost. Your application should be idempotent and not
depend on the webhook firing exactly once.

4xx responses are **not retried** — if your endpoint returns 400/401/404,
we assume your service is saying "don't try again."

## Best practices

<AccordionGroup>
  <Accordion title="Respond fast, process async" icon="bolt">
    Return `200` immediately and enqueue the work internally. We time out
    at 15 seconds per attempt; if your handler runs synchronously longer
    than that we'll treat it as a failure.
  </Accordion>

  <Accordion title="Be idempotent on run_id + event" icon="repeat">
    Retries and edge cases can cause duplicate deliveries. Key your
    processing on `(run_id, event)` and skip if already handled.
  </Accordion>

  <Accordion title="Verify signatures" icon="shield">
    Webhook URLs are attacker-reachable. Signatures prove it came from us.
    Never trust the payload without verifying.
  </Accordion>

  <Accordion title="Store the secret like a database password" icon="key">
    If it leaks, rotate by deleting the subscription and recreating it —
    the new subscription will issue a fresh secret. Old signatures will
    stop validating immediately.
  </Accordion>
</AccordionGroup>

## Debugging

The dashboard's **Webhooks → Delivery log** shows every attempt for the
last 30 days with:

* HTTP status received
* Response body (truncated to 1KB)
* Latency per attempt
* Retry schedule remaining

If deliveries are failing, check there first before reaching out to
support.
