# Webhooks

A `background: true` request answers `202` straight away and runs on. When the run ends, Azta sends a webhook to your endpoint, so you do not have to poll. The webhook is signed with the [Standard Webhooks](https://www.standardwebhooks.com) scheme, so any Standard Webhooks library can verify it.

## Configure an endpoint

Add an endpoint in the console at [console.azta.ai/webhooks](https://console.azta.ai/webhooks). Give it an https URL, choose its mode (live or sandbox) and the events it receives. The console shows its signing secret, which looks like `whsec_<base64>`. Keep the secret in an environment variable, never in source code.

## Events

| Event                | Sent when                  |
| -------------------- | -------------------------- |
| `response.completed` | the response is completed  |
| `response.failed`    | the response failed        |
| `response.cancelled` | the response was cancelled |

Events are sent only for `background: true` runs made with an API key. A sandbox key fires webhooks too: its response is completed when it is created, so the event follows at once. A request with `stream: true` sends no event.

## The payload

The event is thin. It names the response and carries no answer:

```json
{
  "object": "event",
  "id": "evt_0123456789abcdef0123456789abcdef",
  "type": "response.completed",
  "created_at": 1793520000,
  "data": { "id": "0b8b2f2c-0000-4000-8000-000000000001" }
}
```

`id` is `evt_` and 32 lowercase hex characters, and is also the `webhook-id` header. `created_at` is Unix seconds. `data.id` is the response id.

Every delivery carries three headers:

| Header              | Value                                                |
| ------------------- | ---------------------------------------------------- |
| `webhook-id`        | the event `id`                                       |
| `webhook-timestamp` | Unix seconds when the attempt was signed             |
| `webhook-signature` | one or more space-separated `v1,<base64>` signatures |

## Verify the signature

Verify every delivery before you act on it. The signature is an HMAC-SHA256, in base64, of `<webhook-id>.<webhook-timestamp>.<raw body>`, keyed with the bytes you get by base64-decoding the secret after `whsec_`. Compare it in constant time against each `v1,` signature, and refuse a timestamp more than 300 seconds from your clock.

Verify the raw request body, exactly as received. A body parsed and serialized again no longer matches.

```ts tab="TypeScript"
import { createHmac, timingSafeEqual } from 'node:crypto';

export class WebhookVerificationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'WebhookVerificationError';
  }
}

export interface AztaWebhookEvent {
  object: 'event';
  id: string;
  type: string;
  created_at: number;
  data: { id: string };
}

interface VerifyOptions {
  toleranceSeconds?: number;
  now?: number;
}

/**
 * Verifies a Standard Webhooks signature and returns the parsed event.
 * Pass the raw request body: the exact string you received, not re-serialized JSON.
 */
export function verifyWebhook(
  payload: string,
  headers: Record<string, string>,
  secret: string,
  opts: VerifyOptions = {},
): AztaWebhookEvent {
  const toleranceSeconds = opts.toleranceSeconds ?? 300;
  const now = opts.now ?? Math.floor(Date.now() / 1000);

  const lower: Record<string, string> = {};
  for (const [name, value] of Object.entries(headers)) {
    lower[name.toLowerCase()] = value;
  }
  const id = lower['webhook-id'];
  const timestamp = lower['webhook-timestamp'];
  const signatures = lower['webhook-signature'];
  if (!id || !timestamp || !signatures) {
    throw new WebhookVerificationError('missing webhook headers');
  }
  if (!secret.startsWith('whsec_')) {
    throw new WebhookVerificationError('secret must start with whsec_');
  }

  const sent = Number(timestamp);
  if (!Number.isInteger(sent) || Math.abs(now - sent) > toleranceSeconds) {
    throw new WebhookVerificationError('timestamp outside the tolerance');
  }

  const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
  if (key.length === 0) {
    throw new WebhookVerificationError('secret is not valid base64');
  }
  const expected = createHmac('sha256', key)
    .update(`${id}.${timestamp}.${payload}`)
    .digest();

  // The header holds one or more space-separated "v1,<base64>" signatures.
  let valid = false;
  for (const entry of signatures.split(' ')) {
    const [version, value] = entry.split(',');
    if (version !== 'v1' || !value) {
      continue;
    }
    const candidate = Buffer.from(value, 'base64');
    if (
      candidate.length === expected.length &&
      timingSafeEqual(candidate, expected)
    ) {
      valid = true;
    }
  }
  if (!valid) {
    throw new WebhookVerificationError('no matching signature');
  }
  return JSON.parse(payload) as AztaWebhookEvent;
}
```

```python tab="Python"
"""Verify a Standard Webhooks signature on an Azta webhook delivery."""

import base64
import hashlib
import hmac
import json
import time
from typing import Mapping


class WebhookVerificationError(Exception):
    pass


def verify_webhook(
    payload: bytes,
    headers: Mapping[str, str],
    secret: str,
    *,
    tolerance_seconds: int = 300,
    now: int | None = None,
) -> dict:
    """Return the parsed event, or raise WebhookVerificationError.

    Pass the raw request body bytes, not re-serialized JSON.
    """
    if now is None:
        now = int(time.time())

    lower = {name.lower(): value for name, value in headers.items()}
    webhook_id = lower.get("webhook-id")
    timestamp = lower.get("webhook-timestamp")
    signatures = lower.get("webhook-signature")
    if not webhook_id or not timestamp or not signatures:
        raise WebhookVerificationError("missing webhook headers")
    if not secret.startswith("whsec_"):
        raise WebhookVerificationError("secret must start with whsec_")

    try:
        sent = int(timestamp)
    except ValueError:
        raise WebhookVerificationError("timestamp outside the tolerance") from None
    if abs(now - sent) > tolerance_seconds:
        raise WebhookVerificationError("timestamp outside the tolerance")

    try:
        key = base64.b64decode(secret[len("whsec_") :], validate=True)
    except ValueError:
        raise WebhookVerificationError("secret is not valid base64") from None
    content = f"{webhook_id}.{timestamp}.".encode() + payload
    expected = hmac.new(key, content, hashlib.sha256).digest()

    # The header holds one or more space-separated "v1,<base64>" signatures.
    valid = False
    for entry in signatures.split(" "):
        version, _, value = entry.partition(",")
        if version != "v1" or not value:
            continue
        try:
            candidate = base64.b64decode(value)
        except ValueError:
            continue
        if hmac.compare_digest(candidate, expected):
            valid = True
    if not valid:
        raise WebhookVerificationError("no matching signature")
    return json.loads(payload)
```

Both are tested in this site's CI against the same vectors. The OpenAI SDK also verifies and parses these webhooks with `client.webhooks.unwrap`; see [Use the OpenAI SDK](/guides/openai-sdk).

## Answer, then fetch the response

Answer with a 2xx status within 15 seconds, and do the work afterwards. Then fetch the answer with [`GET /v1/responses/{id}`](/api-reference/getResponse), using `data.id` from the event.

## Deduplicate on `webhook-id`

A delivery can arrive more than once, so store each `webhook-id` you have handled and skip it when it comes again. The same event keeps the same `webhook-id` on every retry.

## Retries

A 2xx answer is delivered. Any other answer, a timeout after 15 seconds, or a connection error is a failed attempt. A 3xx answer is a failed attempt too: redirects are not followed, so register the final URL.

Each wait starts when the previous attempt fails: the first attempt is immediate, then the waits are 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. That is eight attempts in all, the last about 27.6 hours after the first. After the last one the delivery is not tried again; fetch the response with `GET /v1/responses/{id}` if you missed it.

The console lists every delivery attempt for an endpoint. Events and deliveries are kept for 90 days.
