Webhooks
Get a signed webhook when a background response ends, verify it with Standard Webhooks, and fetch the answer.
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 scheme, so any Standard Webhooks library can verify it.
Configure an endpoint
Add an endpoint in the console at 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:
{
"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.
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;
}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.
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}, 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.