Skip to content

Run callbacks

A service that starts a run can name a URL to be told what happened to it, instead of polling. The platform POSTs a signed JSON body there when the run waits for a human and when it ends. Callbacks go only to receivers a team has registered: a launch names a registered callback endpoint, or a URL under one.

A callback endpoint belongs to a team and says where that team’s runs may report:

Field Meaning
name Unique within the team
origin scheme://host[:port], stored normalised (lowercase host, default port dropped). https only, unless allowPrivateNetwork
pathPrefix Default /. A callback URL’s path must equal it or sit under it on a segment boundary: /hooks admits /hooks and /hooks/run, not /hooksevil
events Any of run.completed, run.waiting_for_human; at least one
allowPrivateNetwork Lets the origin be http and resolve to a private-network address (RFC 1918, CGNAT, ULA). Loopback, link-local and cloud-metadata addresses are refused whatever it says. Only a platform ADMIN may turn it on, and while it is on (or being turned on) only a platform ADMIN may change the endpoint’s origin or pathPrefix, or turn on acceptTemplateWebhookLaunches — the opt-in was granted for one destination and its senders
acceptTemplateWebhookLaunches Default off. Whether a call to a public template webhook may name this endpoint. A webhook caller holds a token, not an identity, so an endpoint accepts such callbacks only when its team says so
signing secret Generated by the platform (whsec_…), stored with the AES-256-GCM envelope every credential uses, and shown once: on create and on rotation

The origin is checked with the SSRF text guard when it is saved; a refused origin is 400 BASE_URL_REFUSED, and a malformed one (a path, query, fragment or credentials in it, or http without the opt-in) is 400 INVALID_ORIGIN. A path prefix with . or .. segments, encoded or not, an encoded /, \, ; or % (%25), an encoded control character, an overlong UTF-8 encoding (%c0, %c1, %e0%80…) or IIS’s %u, a ; (a path parameter some servers strip before routing), or a query is 400 INVALID_PATH_PREFIX.

Endpoints are managed on the team’s page (Run callbacks) or through the API, by the team’s LEADs and ADMINs (a platform ADMIN manages every team’s). Every change is audited without the secret.

Route Effect
GET /api/v1/teams/:teamId/callback-endpoints The team’s endpoints. Never a secret: the last four characters, and when the replaced one stops signing
POST /api/v1/teams/:teamId/callback-endpoints Create one. The response carries secret, once
PATCH /api/v1/teams/:teamId/callback-endpoints/:id Change name, origin, pathPrefix, events, allowPrivateNetwork, acceptTemplateWebhookLaunches; disabled: true disables it, false enables it. Moving a private-network endpoint (origin, pathPrefix) is 403 PRIVATE_NETWORK_ADMIN_ONLY for anyone but a platform ADMIN
POST /api/v1/teams/:teamId/callback-endpoints/:id/rotate-secret A new secret, returned once. The replaced one keeps signing for 24 hours
DELETE /api/v1/teams/:teamId/callback-endpoints/:id Delete it. Runs that named it stop reporting
GET /api/v1/teams/:teamId/callback-endpoints/:id/deliveries Its delivery history (limit, offset)
POST /api/v1/teams/:teamId/callback-endpoints/:id/deliveries/:deliveryId/redeliver Send a dead delivery again. A pending one is still being retried and is refused (409 DELIVERY_PENDING)

A disabled endpoint cannot be chosen by a launch, and a delivery due to it goes dead with ENDPOINT_DISABLED; enable it and redeliver to send it.

Three launch paths accept an optional callback object:

Path Where it goes
POST /api/v1/agent-runs (agent-runs.md) A body field
POST /api/v1/workflow-templates/:id/runs A body field, beside payload and label
POST /api/v1/webhooks/:token (automations.md) A top-level callback object in the body; it is taken out before the payload is validated and stored
{
"callback": {
"url": "https://hooks.example.com/hooks/auto-swe?token=receiver-token",
"context": { "jobId": 4211 }
}
}
Field Meaning
endpointId A registered endpoint; the callback posts to its origin and path prefix
url A full URL under a registered endpoint. Its query is kept (it may carry the receiver’s own token)
context Optional, any JSON up to 1 KB serialised. Echoed in every payload, untouched

Exactly one of endpointId and url. A url must have the exact origin of an enabled endpoint and a path under its prefix; it may not carry credentials (user:pass@) or a fragment, and its path may not contain . or .. segments, encoded or not, an encoded separator, ;, % or control character, an overlong UTF-8 or %u encoding, or a ; — /hooks/../admin, /hooks/%2e%2e/admin, /hooks/%252e%252e/admin, /hooks/.%00./x, /hooks/%c0%ae%c0%ae/admin, /hooks/%u002e%u002e/admin and /hooks/..;/admin are refused rather than resolved. The rules apply at save, at launch and again before every send. When several endpoints match, the longest prefix wins; two with the same prefix are 400 CALLBACK_URL_AMBIGUOUS (name one with endpointId).

Which endpoints a launch may use:

  • A person (agent runs, template runs): an endpoint of any team they belong to — any member, whatever their role. A platform ADMIN may use any team’s. The run’s own team does not matter: a person in teams A and B can make a run of team B report to team A’s endpoint, and team A’s endpoint managers then see that delivery (the run’s id and status, and what the payload carries) in their endpoint’s history.
  • A service principal (service-principals.md): its own team’s endpoints only.
  • A template webhook: the template’s own team’s endpoints that set acceptTemplateWebhookLaunches. A global template has no team, so its webhook refuses a callback with 400 CALLBACKS_UNAVAILABLE.

The refusals are 400: INVALID_CALLBACK_URL, CALLBACK_URL_NOT_REGISTERED, CALLBACK_ENDPOINT_NOT_FOUND, CALLBACK_URL_AMBIGUOUS, CALLBACKS_UNAVAILABLE, and on the webhook INVALID_CALLBACK for a malformed object. Nothing is written or started when one is refused. A launch that asked for a callback answers with callback.target — the origin and path, never the query.

The callback is stored beside the request (RunCallback), with the full URL sealed in the AES-GCM envelope, because its query may be a credential. It is never written to the request’s stored raw payload, the Temporal workflow input, a log line or the delivery history: those show the origin and path only.

A re-run does not inherit the callback. The callback reports the execution that asked for it. A re-run reuses the request but is a new decision by whoever re-runs it, so it reports nowhere, and the re-run endpoint takes no callback.

Event When
run.waiting_for_human A human step (approval, decision, input, review) is created. Once per step
run.completed The run ends, whatever the outcome: SUCCESS, FAILED, TIMED_OUT, SKIPPED or CANCELLED

An endpoint receives only the events it subscribes to. Every request is a POST with Content-Type: application/json and these headers:

Header Value
X-AutoSwe-Signature t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")> — the scheme automations.md describes. During the 24 hours after a rotation it carries two v1= entries, one per secret
X-AutoSwe-Event The event type
X-AutoSwe-Delivery The delivery id; also the body’s id. The same on every retry and redelivery
{
"id": "6f1c…",
"type": "run.completed",
"createdAt": "2026-10-14T12:00:00.000Z",
"run": {
"workRequestId": "…",
"workflowRunId": "…",
"temporalWorkflowId": "agent-1a2b3c4d-…",
"status": "SUCCESS",
"startedAt": "2026-10-14T11:40:00.000Z",
"endedAt": "2026-10-14T12:00:00.000Z",
"costUsd": 0.4213,
"pullRequestUrl": "https://github.com/acme/api/pull/42",
"dashboardUrl": "https://app.example.com/runs/…"
},
"launchedBy": { "kind": "user", "id": "<user id>", "templateId": "<template id>" },
"context": { "jobId": 4211 }
}

launchedBy says who asked for the run: kind is user (the person’s id), service_principal (the principal’s id) or template_webhook (the template’s id, for a call to its public webhook); templateId names the template launched when there is one (not for an agent run). automation is reserved for launches by an automation, none of which takes a callback yet.

pullRequestUrl is present when the run opened one. dashboardUrl is built from the worker’s WEB_URL. A run.waiting_for_human payload also carries humanStep: { id, kind, dashboardUrl } (the Approvals page, /approvals), its endedAt is null and its costUsd is the spend so far. A payload never carries a transcript, a trace, the run’s context snapshot or the request’s text: a receiver that needs more fetches it with the API, as itself.

The body is fixed when the event happens; a retry or a redelivery sends the same bytes with a fresh signature timestamp.

Verify the signature over the raw body before parsing it, then dedupe on the delivery id:

import { verifyAutoSweSignature } from '@auto-swe/sdk';
app.post('/hooks/auto-swe', express.raw({ type: 'application/json' }), (req, res) => {
const result = verifyAutoSweSignature({
header: req.get('X-AutoSwe-Signature'),
nowSec: Math.floor(Date.now() / 1000),
rawBody: req.body, // the bytes as received
secrets: [process.env.AUTO_SWE_CALLBACK_SECRET],
});
if (!result.ok) {
return res.status(401).send(result.reason);
}
const delivery = JSON.parse(req.body.toString('utf8'));
if (alreadyProcessed(delivery.id)) {
return res.sendStatus(200); // a retry of something already handled
}
enqueue(delivery);
res.sendStatus(204);
});

During your own rotation, pass both secrets in secrets. Answer quickly (within 10 seconds) and do the work afterwards.

The signature proves the platform sent it, not who launched the run. Anyone allowed to name your endpoint — every member of its team, the team’s service principals and, if you opted in, anyone holding a template’s webhook URL — chooses the path, the query and context of what you receive, and the platform signs it. So:

  • check launchedBy against who you expect to start runs that report to you;
  • make context something you issued and can verify (a nonce or an HMAC over your own job id), not a bare identifier you act on;
  • register the narrowest prefix the receiver needs, one endpoint per receiver, and leave acceptTemplateWebhookLaunches off unless the receiver checks launchedBy.

Events are written to an outbox (OutboundDelivery) in the same database transaction as the change they report: the run’s terminal write, which decides exactly once whether the run ended (the workflow’s own finalisation and the run reaper share it, so a reaped run reports too), or the human step’s creation. Workflow code never writes one.

A sweep sends them: ScheduledCallbackDeliveryWorkflow, on the auto-swe-callback-delivery Temporal Schedule (CALLBACK_DELIVERY_ENABLED, default true; CALLBACK_DELIVERY_CRON, default every minute). It claims up to 100 due rows at a time with FOR UPDATE SKIP LOCKED, holding each for five minutes while it sends, eight at a time.

  • The request goes through the guarded fetch: the host is resolved and every address checked (private addresses only with the endpoint’s opt-in), and the connection is pinned to a checked address. A redirect is refused, never followed. Each attempt has a 10-second timeout.
  • Before every send the sweep checks the stored URL is still under the endpoint’s current origin and prefix, and the origin still passes the text guard.
  • A 2xx answer delivers it. Anything else is retried after 1 minute, doubling to at most 6 hours, for 24 hours from the first attempt (or the last redelivery); then it is dead with RETRIES_EXHAUSTED.
  • Only the status code and a fixed error class are kept: TIMEOUT, NETWORK, ADDRESS_REFUSED, REDIRECT, HTTP_STATUS. A response body is never read, and a transport error’s text is never stored or logged.
  • A delivery goes dead at once, with a reason, when its endpoint was deleted (ENDPOINT_DELETED) or disabled (ENDPOINT_DISABLED), the URL is no longer under the endpoint (ENDPOINT_CHANGED), or its sealed URL or the endpoint’s secret cannot be decrypted (URL_UNREADABLE, SECRET_UNREADABLE).

Redelivery puts a dead delivery back to pending, due now, with a fresh 24-hour window and the same id and body. A delivered one is not resent, and a pending one is refused: it is still being retried, and may be mid-send. Each claim of a row by the sweep sets a fresh claim token and a result is written only under the token it was sent with, so a late result never overwrites a later attempt’s. It is open to the endpoint’s managers, from the endpoint’s delivery history or the run page, and audited.

History. The run page shows the run’s callback target and its deliveries to whoever can see the run (GET /api/v1/workflow-runs/:id/callback-deliveries). A member of the endpoint’s team (or a platform ADMIN) sees each delivery in full — the endpoint, the target’s path, attempts, status codes, error classes — and the endpoint’s managers get the redeliver button; anyone else sees the origin each delivery went to and how it ended. The worker’s logs name the origin only. A daily sweep (ScheduledCallbackDeliveryPruneWorkflow, CALLBACK_DELIVERY_PRUNE_ENABLED, default true; CALLBACK_DELIVERY_PRUNE_CRON, default 47 4 * * *) deletes delivered and dead rows older than workflow.callbackDeliveryRetentionDays (ADMIN, GLOBAL only, default 30). Pending rows are never pruned.

  • At least once, in no particular order. A receiver that answers after the 10-second timeout, or whose 2xx is lost, gets the delivery again; dedupe on X-AutoSwe-Delivery. A run.completed can arrive before a run.waiting_for_human that was retried.
  • Up to a minute late. Deliveries wait for the next sweep, so the default cadence adds up to a minute; a stopped worker or a disabled schedule holds them until it runs again. A delivery claimed by a sweep that dies is retried when its five-minute hold lapses.
  • Three launch paths. Work requests (POST /work-requests), epics, schedules, event automations, Slack and the channel assistant take no callback, and a re-run reports nowhere.
  • No run.started event. The launch’s 201 response is the start signal.
  • Global template webhooks cannot report. A callback needs a team’s registered endpoint, and a global template has no team.
  • The signature does not identify the launcher. It proves the platform sent the delivery. Who asked for it is in launchedBy, and the path, query and context are the launcher’s choice (section 4).
  • Any member can aim at any of the team’s endpoints. Endpoints are team-wide: there is no per-endpoint list of who may use it beyond the team, its principals and the webhook opt-in. A person in several teams can make a run of one team report to another team’s endpoint, which then shows that run’s id and status in its delivery history.
  • A callback’s path and query are treated as secrets. Some receivers put a credential in the path (a Slack incoming-webhook URL does). The full URL is sealed; the origin and path are shown only to the endpoint’s team and platform ADMINs, and everyone else — run viewers, worker logs — sees the origin. The launch response still echoes the full target to its caller, who supplied it.
  • Retry policy is fixed. The backoff, the 24-hour window, the timeout and the batch size are constants, not settings.
  • A redirect is a failure. A receiver that moved must be re-registered at its new origin.
  • An edited endpoint strands old URLs. Narrowing an endpoint’s prefix or changing its origin makes pending deliveries under the old one go dead (ENDPOINT_CHANGED).

© 2026 Jorge Barnaby

Source-available under FSL-1.1-MIT

Source on GitHub

Built with Astro and Starlight