Webhooks

Be told when a run finishes, instead of waiting for it.

A webhook is an HTTPS request Amy makes to your server when something happens in your organisation — a run completes, fails, or pauses for an approval. Use them for work that finishes later: runs you submit and poll, runs that wait for a person's approval, or anything you would otherwise keep a connection open for.

Events are published for the runs of your whole organisation, including runs started from the Console, not only runs your API key started.

Register an endpoint

An organisation Admin adds endpoints in the Console: Webhooks → Add endpoint. Endpoints are managed there rather than over the /v1 API, so an API key cannot create or change one.

  • URL: https, up to 500 characters, with no credentials embedded in it. Amy refuses a URL that resolves to a private or internal address, both when you save it and again at every delivery.
  • Events: choose the event types you want, up to 50. Leave the list empty to receive every event. Names are matched exactly; there are no wildcards.
  • Secret: Amy generates a signing secret that starts whsec_ and shows it once, when you create the endpoint (and again if you rotate it). Store it in your secret manager like an API key.
  • An endpoint can be disabled, re-enabled and deleted; deleting it also deletes its delivery log. The Console's Delivery log shows each delivery's status, attempts and last response.

What Amy sends

Each delivery is a POST with a JSON body and three headers of its own:

HeaderValue
X-Webhook-IdThe id of this delivery to this endpoint. It is the same on every retry and on a redelivery from the Console, and differs between endpoints that receive the same event.
X-Webhook-EventThe event type, for example amy.run.completed.
X-Webhook-Signaturet=<unix seconds>,v1=<hex> — see Verify the signature. The timestamp is inside this header; there is no separate timestamp header.
a delivery
POST /amy/webhooks HTTP/1.1
Content-Type: application/json; charset=utf-8
X-Webhook-Id: 0197a000-0000-7000-8000-0000000000d1
X-Webhook-Event: amy.run.completed
X-Webhook-Signature: t=1792053000,v1=e0976326f068c8c0d37675f6facad8b043ac30df7d96a14ce16c42de332d09f0

{"eventId":"0197a000-0000-7000-8000-000000000000","type":"amy.run.completed", …}

The body is always an envelope of four fields, with camelCase names throughout:

application/json
{
  "eventId": "0197a000-0000-7000-8000-000000000000",
  "type": "amy.run.completed",
  "occurredAt": "2026-10-15T08:30:00+00:00",
  "data": {
    "runId": "0197a000-0000-7000-8000-000000000001",
    "agentSlug": "amy",
    "status": "completed",
    "endUserRef": "user_8841",
    "conversationId": null,
    "completedAt": "2026-10-15T08:30:00+00:00",
    "usage": { "inputTokens": 184, "outputTokens": 61, "totalTokens": 245 },
    "traceId": "0af7651916cd43dd8448eb211c80319c"
  }
}
  • eventId — one id per event. It is identical for every endpoint that receives the event and for every retry.
  • type — the same string as X-Webhook-Event.
  • occurredAt — when the event was published (ISO 8601, UTC).
  • data — the event's payload. Your code should ignore fields it does not know: new fields may be added (see Versioning).

Payloads are deliberately small: ids and facts, never the answer or an error message. To read the result, fetch the run. A run that your API key started can be read back with GET /v1/runs/{runId}; one started from the Console cannot (it is a 404 for your key).

shell
curl https://api.askamy.co.za/v1/runs/0197a000-0000-7000-8000-000000000001 \
  -H "X-Api-Key: $AMY_API_KEY"

Events

Every run event carries these fields in data:

runId, agentSlug, status, endUserRef, conversationId and traceId — plus what the table adds.

EventFires whenAdds to data
amy.run.completedA run finished successfully.completedAt, usage
amy.run.truncatedThe answer was cut off at the per-call output limit.completedAt, usage (status: "truncated")
amy.run.budget_exceededA run stopped because it used its budget.completedAt, usage
amy.run.failedA run failed. No error text is sent; read the run.completedAt
amy.run.cancelledA run was cancelled.completedAt
amy.run.approval_requestedA run paused for a decision; resume it with Approvals.approvalId, toolName
amy.run.approval_expiredAn approval expired before anyone decided.approvalId, toolName (status: "expired")
amy.run.retriedA failed run was retried and is running again. Not a final state.retriedCode, attempt

usage is { inputTokens, outputTokens, totalTokens }. A run can emit amy.run.failed, then amy.run.retried, then amy.run.completed: treat only the latest as the run's state.

Team runs, a Console feature, publish amy.team_run.started, .completed, .failed, .awaiting_human, .resumed and .cancelled. If you do not use team runs you will not see them; leave them out of your event list.

Verify the signature

Anyone can POST to your URL, so check every delivery before you trust it. The scheme is a timestamp plus an HMAC:

  1. Read the raw request body exactly as received, before any JSON parsing. Parsing and re-serialising changes the bytes.
  2. From X-Webhook-Signature take t (Unix seconds) and v1 (lowercase hex). Ignore any other keys.
  3. Compute HMAC-SHA256 of the string <t>.<raw body> (the timestamp, a dot, the body), keyed with the UTF-8 bytes of the whole whsec_… string — including the whsec_ prefix, not base64-decoded. Write it as lowercase hex.
  4. Compare with v1 in constant time.
  5. Reject the delivery if t is more than five minutes from your clock, in either direction. This stops a captured delivery being replayed later.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * secret          the whsec_… string shown when you created the endpoint, as is
 * signatureHeader the X-Webhook-Signature header value
 * rawBody         the request body EXACTLY as received (string or Buffer), before any JSON parsing
 */
export function verifyAmyWebhook(
	secret,
	signatureHeader,
	rawBody,
	toleranceSeconds = 300,
	nowSeconds = Date.now() / 1000
) {
	const parts = {};
	for (const entry of signatureHeader.split(',')) {
		const at = entry.indexOf('=');
		if (at > 0) parts[entry.slice(0, at)] = entry.slice(at + 1);
	}
	const timestamp = Number(parts.t);
	const presented = parts.v1;
	if (!Number.isInteger(timestamp) || !presented) return false;

	// Reject old (replayed) and far-future timestamps.
	if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return false;

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

	const a = Buffer.from(expected);
	const b = Buffer.from(presented);
	return a.length === b.length && timingSafeEqual(a, b);
}
Python
import hashlib
import hmac
import time


def verify_amy_webhook(secret, signature_header, raw_body, tolerance_seconds=300, now=None):
    """secret: the whsec_... string as shown. raw_body: the request body as bytes,
    exactly as received, before any JSON parsing."""
    parts = dict(item.split("=", 1) for item in signature_header.split(",") if "=" in item)
    try:
        timestamp = int(parts["t"])
        presented = parts["v1"]
    except (KeyError, ValueError):
        return False

    # Reject old (replayed) and far-future timestamps.
    now = time.time() if now is None else now
    if abs(now - timestamp) > tolerance_seconds:
        return False

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

These two samples are run against a fixed secret, body and timestamp in the test suite, and Amy's own signer is asserted against the same signature, so the recipe above is exactly what is sent.

Node.js (Express)
import express from 'express';
import { verifyAmyWebhook } from './verify-amy-webhook.js';

const app = express();

// Keep the body as bytes: the signature covers them exactly, so parsing first would break it.
app.post('/amy/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
	const ok = verifyAmyWebhook(
		process.env.AMY_WEBHOOK_SECRET,
		req.get('X-Webhook-Signature') ?? '',
		req.body // a Buffer
	);
	if (!ok) return res.sendStatus(400);

	const event = JSON.parse(req.body.toString('utf8'));
	// Record event.eventId first; if you have seen it, answer 200 and stop.
	// Then hand the work to a queue and answer 2xx within 10 seconds.
	res.sendStatus(200);
});

Respond fast, then work

  • Answer with any 2xx within 10 seconds. Do the real work afterwards, from a queue.
  • Anything else is a failed attempt — a 4xx, a 5xx, a timeout, or a redirect (Amy never follows redirects). So a signature you reject is retried too.

Retries

Amy makes up to 5 attempts per delivery. After a failed attempt it waits about 1 minute, then 2, 4 and 8 minutes (plus a little scheduling delay), with no jitter. Each attempt is signed afresh with a new t, over the same body. After the fifth failure the delivery is marked dead and is not retried again.

A delivery that is no longer pending can be sent again from the Console's Delivery log (Redeliver), which gives it a fresh set of five attempts.

Failing endpoints are switched off. After 20 deliveries in a row go dead, Amy disables the endpoint and stops sending to it; any success resets the count. Re-enable it in the Console once your receiver is fixed. While an endpoint is disabled Amy creates no deliveries for it, so events published in that time are not recorded and cannot be redelivered. Deliveries already waiting when it was switched off are recorded as dead; redeliver them once it is enabled again.

Duplicates and ordering

Delivery is at least once: a retry can arrive after your server did process the first attempt, and events can arrive out of order. So:

  • Record each eventId you have handled and skip repeats. It is the same across retries, redeliveries and endpoints. X-Webhook-Id identifies one delivery to one endpoint — useful in your logs and when you talk to us.
  • Do not assume order. Use occurredAt or, better, fetch the run and act on its current status.

Rotate the secret

Webhooks → Rotate secret issues a new secret and invalidates the old one immediately; there is no overlap period. Deliveries still waiting to retry are signed with the new secret too. So rotate, then update your receiver straight away: deliveries that fail verification in between are retried (see above) and succeed once your receiver has the new secret, as long as you are quick.

What Amy does not do

  • It does not guarantee ordering, and it does not send a test event.
  • It does not follow redirects, or deliver to private or internal addresses.
  • It does not put model output, tool results or error text in a webhook.