Signed agent completion callbacks¶
Register a callback when dispatching an agent task. Astrolift persists the final event and delivers it from its production network. A receiver outage does not require your service to keep polling every task.
This contract applies to runAstroliftAgent tasks. It is separate from the
pod-to-platform AGENT_CALLBACK_URL and generic application webhook envelopes.
It does not send progress or workflow-level events.
Configure the organization¶
Use a CLI release that includes astro agent callbacks and a server whose
schema includes configureAgentTaskCallbacks. The server advertises
agents.completion_callbacks and agents.completion_callback_redelivery. Inspect astro version, astro
status --json, and astro agent callbacks --help before automating the setup.
astro agent callbacks configure --allow-host hooks.internal.example.org
astro agent callbacks show --json
astro agent callbacks secret-set completion-key --file /secure/path/completion-key
configure replaces the allow-list. Repeat --allow-host for additional hosts.
Exact hosts and *.internal.example.org are supported; the wildcard matches
subdomains, not the bare parent domain. --clear disables all allowed hosts.
The destination must be HTTPS. URL credentials and fragments are rejected;
redirects cannot bypass the allow-list. Internal destinations must resolve and
be reachable from the production network, with a valid TLS certificate.
Configuring hosts requires organization update permission. Writing the signing
key requires organization secret-write permission and the corresponding token
scope. Dispatching with a secret reference requires the normal agent dispatch
grant plus organization secret-read permission and token scope secret:read.
Team-limited tokens cannot configure organization callbacks or dispatch with
an organization signing secret. Secret names start with an ASCII letter or digit,
use only ASCII letters, digits, _, ., and -, and are at most
128 characters. Keys contain 32 to 4096 UTF-8 bytes; use a cryptographically
random key. FULL dispatch also requires app-read authorization for the target
app; NOTIFY does not add that result-read requirement. The signing key itself is not part
of dispatch input or its response.
Use --stdin instead of --file when a secret manager supplies the value. The
CLI offers no literal signing-key argument and never echoes its value. It strips
trailing CR/LF from text input; configure the receiver with the same key bytes.
Register at dispatch¶
astro agent dispatch report --env-spec report-prod --input @input.json \
--callback-url https://hooks.internal.example.org/tasks \
--callback-secret-ref completion-key \
--correlation-id request-42 --callback-mode FULL --json
| Input field | Meaning |
|---|---|
callbackUrl |
Allowed HTTPS receiver URL |
callbackSecretRef |
Organization secret name storing the HMAC key |
correlationId |
Opaque caller ID, at most 128 characters; returned unchanged |
callbackMode |
FULL includes result JSON; NOTIFY omits it; default FULL |
Callback options are optional. A dispatch without them keeps its existing
behavior. The server rejects disallowed destinations before launching the task.
--wait waits for task execution; callback delivery has its own state and can
remain pending while the receiver is unavailable.
The equivalent GraphQL operation is:
mutation Dispatch($input: RunAstroliftAgentInput!) {
runAstroliftAgent(input: $input) {
ok
errors { code message field }
data { id status callbackStatus callbackAttempts callbackLastError }
}
}
{
"input": {
"agentSlug": "report",
"callbackUrl": "https://hooks.internal.example.org/tasks",
"callbackSecretRef": "completion-key",
"correlationId": "request-42",
"callbackMode": "FULL",
"triggerPayload": {"request_id": "request-42"}
}
}
Check both GraphQL errors and mutation ok. For authentication, tenancy, and
HTTP transport see the API reference.
Final event and delivery attempts¶
Astrolift creates one logical agent_task.finished event when a task reaches
completed, failed, cancelled, or timed_out, including platform-enforced
timeouts. Intermediate states and internal execution retries do not notify the
caller. Only the final outcome generates an event.
Delivery is at least once. A lost success response can cause another POST.
Atomically deduplicate by task_id before changing downstream state. A fresh
X-Astrolift-Delivery UUID identifies every HTTP attempt, including replay.
Do not deduplicate only by that attempt UUID.
Content-Type: application/json
X-Astrolift-Event: agent_task.finished
X-Astrolift-Delivery: <fresh-uuid-per-attempt>
X-Astrolift-Timestamp: <unix-seconds>
X-Astrolift-Signature: v1=<lowercase-hex-hmac-sha256>
{
"event": "agent_task.finished",
"task_id": "00000000-0000-4000-8000-000000000042",
"correlation_id": "request-42",
"agent_slug": "report",
"status": "completed",
"exit_code": 0,
"failure_message": null,
"attempt": 1,
"started_at": "2026-10-01T19:02:11Z",
"finished_at": "2026-10-01T19:04:37Z",
"usage": {
"duration_ms": 146000,
"input_tokens": null,
"output_tokens": null,
"model": null
},
"result": {"output": {"summary": "Processing completed"}}
}
FULL uses the stored task result wrapper; agent output commonly lives under
result.output. A failed task without a normal result can return its recorded
failure output in result.
attempt is the final agent execution attempt, separate from HTTP delivery
attempts. Usage values that were not observed are null; they are not reported
as zero. Duration uses the actual task timing. A task that failed before
starting can have a null started_at and duration. In NOTIFY, result is absent
and free-text failure content is not sent; fetch the result through
agentTask(id) using an authorized API client.
The merged retirement source uses the reduced envelope for retries and manual replay too. It omits the retired envelope field in memory before signing outgoing bytes; historical ciphertext and arbitrary user result JSON remain unchanged. Always verify the exact bytes received. Adoption requires the paired API/client release and old worker drain described in those operator notes.
Receiver outage and backoff¶
Each POST has a 10-second timeout. The event survives worker restarts and a receiver outage. Automatic retries span at least 24 elapsed hours, using exponential backoff and jitter: nominally 30 seconds, 2 minutes, 10 minutes, 30 minutes, then approximately hourly. Exact timing varies with jitter and worker availability. A new timestamp, signature, and delivery UUID are created for every actual attempt. The retry window begins when the final event is recorded, rather than when the agent was dispatched.
| Outcome | Delivery action |
|---|---|
Any 2xx |
Mark delivered; stop retries |
4xx except 408 and 429 |
Mark failed; do not retry automatically |
408, 429, 5xx, transport failure, or timeout |
Retry with backoff |
| Retry window exhausted | Mark failed; allow authorized manual replay |
Return a 2xx after durably accepting the event, and process it asynchronously
if the downstream work might take longer than the timeout. Return 503 or
429 for temporary unavailability; 400 declares a permanent problem.
agentTask exposes callbackStatus (pending, delivered, or failed),
callbackAttempts, and a sanitized callbackLastError. No-callback tasks have
null callback state. Pending can include a registered task that has not yet
finished. The replay mutation is redeliverAgentTaskCallback(taskId: GUID!).
It resends the final event without rerunning the agent and requires task-scoped
agent-dispatch and app-read authorization, plus their token scope ceilings.
Replay is subject to task-result access and retention; it does not resurrect a
deleted or expired result. It starts a new retry window and resets the attempt
count for that delivery cycle. An already pending delivery continues its
automatic retry cycle; replay is available after delivery settles.
Verify the signature before parsing¶
The signed input is ASCII(timestamp) + b"." + raw_body. Use the exact bytes
received, not reserialized JSON. Receivers reject timestamps more than five
minutes old or in the future beyond that window, then compare in constant time.
import hashlib
import hmac
import re
import time
def verify_agent_completion(*, keys: list[str], raw_body: bytes,
timestamp_header: str, signature_header: str,
now_unix: int | None = None) -> bool:
if not isinstance(timestamp_header, str) or not isinstance(signature_header, str):
return False
if not re.fullmatch(r"[0-9]{1,12}", timestamp_header):
return False
if not re.fullmatch(r"v1=[0-9a-f]{64}", signature_header):
return False
timestamp = int(timestamp_header)
now = int(time.time()) if now_unix is None else now_unix
if abs(now - timestamp) > 300:
return False
message = timestamp_header.encode("ascii") + b"." + raw_body
for key in keys:
if not isinstance(key, str) or not key:
continue
expected = "v1=" + hmac.new(key.encode("utf-8"), message,
hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, signature_header):
return True
return False
Read the request's raw bytes before JSON decoding. After verifying, require the
expected event type, parse the body, and apply the task_id deduplication rule.
A valid signature proves possession of the configured key, not authorization to
modify an unrelated downstream object; validate the correlation ID locally.
Rotate without interrupting delivery¶
- Configure the receiver to accept both the previous and new signing keys.
- Replace the same named organization secret with the new key through
astro agent callbacks secret-set completion-key --file <new-key-file>. - Confirm a fresh delivery verifies with the new key before removing the old key from the receiver after its overlap window.
Astrolift reads the named secret for every attempt, so queued events use the current key without storing a plaintext key in the queue. Accepting both keys at the receiver covers in-flight requests during rotation. Retry signatures use a fresh timestamp; the previous event timestamp is not reused.
Sensitive data and retention¶
FULL results can contain sensitive data. Callback request and response bodies are excluded from control-plane logs, metrics, and traces. Operational records contain identifiers, final status, HTTP code, attempt count, and sanitized error categories. A queued FULL body is encrypted and has access no broader than the task result. It is removed after delivery or retry exhaustion. FULL delivery also stops and purges queued data when the source task or linked run is deleted, the result is erased, or the source result retention expires. The default retention is 72 hours; a shorter configured result lifetime bounds FULL delivery even when the normal retry window has not elapsed. Use NOTIFY to retain outage retries without keeping a short-lived result in the webhook path.
Choose NOTIFY when the webhook path should carry status and identifiers rather than the result or free-text error. Correlation IDs must be opaque identifiers; do not put sensitive descriptions into them. Receivers should apply equivalent body logging and retention controls.