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:
| Header | Value |
|---|---|
X-Webhook-Id | The 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-Event | The event type, for example amy.run.completed. |
X-Webhook-Signature | t=<unix seconds>,v1=<hex> — see Verify the signature. The timestamp is inside this header; there is
no separate timestamp header. |
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:
{
"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 asX-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).
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.
| Event | Fires when | Adds to data |
|---|---|---|
amy.run.completed | A run finished successfully. | completedAt, usage |
amy.run.truncated | The answer was cut off at the per-call output limit. | completedAt, usage (status: "truncated") |
amy.run.budget_exceeded | A run stopped because it used its budget. | completedAt, usage |
amy.run.failed | A run failed. No error text is sent; read the run. | completedAt |
amy.run.cancelled | A run was cancelled. | completedAt |
amy.run.approval_requested | A run paused for a decision; resume it with Approvals. | approvalId, toolName |
amy.run.approval_expired | An approval expired before anyone decided. | approvalId, toolName (status: "expired") |
amy.run.retried | A 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:
- Read the raw request body exactly as received, before any JSON parsing. Parsing and re-serialising changes the bytes.
- From
X-Webhook-Signaturetaket(Unix seconds) andv1(lowercase hex). Ignore any other keys. - Compute
HMAC-SHA256of the string<t>.<raw body>(the timestamp, a dot, the body), keyed with the UTF-8 bytes of the wholewhsec_…string — including thewhsec_prefix, not base64-decoded. Write it as lowercase hex. - Compare with
v1in constant time. - Reject the delivery if
tis more than five minutes from your clock, in either direction. This stops a captured delivery being replayed later.
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);
}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.
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
2xxwithin 10 seconds. Do the real work afterwards, from a queue. - Anything else is a failed attempt — a
4xx, a5xx, 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.
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
eventIdyou have handled and skip repeats. It is the same across retries, redeliveries and endpoints.X-Webhook-Ididentifies one delivery to one endpoint — useful in your logs and when you talk to us. - Do not assume order. Use
occurredAtor, better, fetch the run and act on its currentstatus.
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.