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.
1. Callback endpoints
Section titled “1. Callback endpoints”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.
2. Asking for a callback at launch
Section titled “2. Asking for a callback at launch”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 with400 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.
3. Events and payload
Section titled “3. Events and payload”| 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.
4. Verifying a delivery
Section titled “4. Verifying a delivery”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
launchedByagainst who you expect to start runs that report to you; - make
contextsomething 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
acceptTemplateWebhookLaunchesoff unless the receiver checkslaunchedBy.
5. Delivery
Section titled “5. Delivery”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
2xxanswer 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 withRETRIES_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.
Limitations
Section titled “Limitations”- At least once, in no particular order. A receiver that answers after the 10-second timeout, or
whose
2xxis lost, gets the delivery again; dedupe onX-AutoSwe-Delivery. Arun.completedcan arrive before arun.waiting_for_humanthat 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.startedevent. The launch’s201response 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 andcontextare 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