Skip to content

Guides

Outbound Webhooks

Receive signed approval lifecycle events at an HTTPS endpoint, verify them with the SDK, and recover after an outage.

An application can register HTTPS endpoints that Alter calls when a held request is created, decided, expires, executes or fails. A webhook turns the human-in-the-loop approval flow from something an application polls into something it is told about: a chat bot posts the request to reviewers the moment it is held, and an agent run learns that its writes went through without waiting on a status read.

Deliveries are signed, retried for about a day, and logged per endpoint. The payloads are thin: identifiers, the status, who decided, the approver list and the approver-facing summary, never the held request itself.

An endpoint belongs to one application and subscribes to one or more of the five approval events (approval.requested, approval.decided, approval.expired, approval.executed, approval.failed); an endpoint subscribed to none is refused (webhook_event_types_required). webhook.test is not a subscription: Send test event (or alter webhooks test) delivers it to an endpoint on request, whatever it subscribes to. Register the endpoint from the application’s Settings → Webhooks card in the developer portal, or with the CLI:

Terminal window
alter webhooks create --app <app-id-or-name> \
--url https://bot.example.com/alter/webhooks \
--events approval.requested,approval.decided,approval.expired,approval.executed,approval.failed \
--output json > endpoint.json

The answer carries the endpoint’s signing secret in the signing_secret field, once. Store it where the receiver runs; it cannot be read back later, only rotated. The portal shows it in a dialog the same single time.

Every change to an endpoint — registering it, changing its URL, events, description or headers, enabling or disabling it, rotating its secret, deleting it, resending or recovering deliveries, sending a test event — is an organization-admin action, like the identity-provider webhook controls. In the developer portal a member of the organization sees the endpoints and their delivery logs read-only, with a note saying so, and the full URL is shown only to someone who may change the endpoint. Deleting an endpoint deletes its delivery history with it, which cannot be recovered; the endpoint itself can be registered again later, with a new secret.

The URL must be an absolute https:// URL without embedded credentials, a query string or a #fragment. Endpoint lists, audit records, logs and the auto-disable email show the URL without its path, followed by the endpoint’s own id (https://hooks.example.com#ep_1a2b3c4d), because some receivers put a secret in the path; a host whose first label looks like a generated token is shown with that label masked (https://***.receiver.example.com#ep_1a2b3c4d). Nothing derived from the path is shown, and two endpoints on one host keep their different ids. The full URL is returned only to someone who may change the endpoint: the card’s edit form for a signed-in organization admin, or alter webhooks show with a token whose owner is an organization admin and that holds dashboard_app_webhooks:write or :admin. Everyone else — a member’s portal session, a member-owned token, a token with only :read — gets the redacted form (url_redacted: true); the list, create and update responses carry the redacted form for every caller. Even so, a receiver token belongs in a custom header rather than the URL. Alter resolves and checks the host when the endpoint is saved and again before every delivery; private, loopback and link-local addresses are refused in every environment, staging and preview deployments included (only Alter’s own test harness delivers to a loopback receiver). If the host cannot be resolved at save time because DNS is unavailable, the save is refused with HTTP 503 webhook_url_resolution_unavailable; retry it. Changing an existing endpoint’s URL needs the same :rotate permission as creating one (see Required permissions).

A receiver that needs its own authentication can be given up to 10 custom request headers, stored in Alter’s secret store and sent with every delivery; they are write-only afterwards. A header name is an HTTP token (letters, digits and !#$%&'*+-.^_|~ and the backtick), and two names that differ only in case are refused. A value is printable ASCII (spaces and tabs allowed inside it), must not be empty, and must not start or end with whitespace. On the CLI, pass header values with --header-file <path> rather than --header "Authorization: Bearer <receiver-token>", so they stay out of shell history and the process list; the CLI warns when a value is given on the command line. An application may hold at most 10 endpoints.

Alter does not publish fixed egress addresses for deliveries. Authenticate deliveries by verifying the signature (below), and, when the receiver sits behind a gateway that needs a credential, by a custom header.

Every delivery is a POST with a JSON body and three headers in the Standard Webhooks format:

HeaderValue
webhook-idThe message id. The same message keeps the same id across retries and resends, so a receiver can de-duplicate on it.
webhook-timestampUnix seconds when the attempt was sent.
webhook-signatureOne or more space-separated v1,<base64> signatures: HMAC-SHA256 of {id}.{timestamp}.{body} under the signing secret. After a rotation there is one signature per secret still valid (see Rotating the secret). Accept the delivery when any one of them verifies.

Verify with the SDK’s helper, which checks the signature against the secret, rejects a timestamp more than 300 seconds from now, and parses the event. Pass the raw request body: re-serialising the JSON changes the bytes the signature covers.

from alter_sdk import WebhookSignatureError, verify_webhook
@router.post("/alter/webhooks")
async def receive(request: Request) -> dict[str, bool]:
try:
event = verify_webhook(await request.body(), request.headers, SIGNING_SECRET)
except WebhookSignatureError:
raise HTTPException(status_code=400)
if event.type == "approval.requested":
notify_reviewers(event.data["approval_id"], event.data["approvers"])
return {"ok": True}
import { WebhookSignatureError, verifyWebhook } from "@alter-ai/alter-sdk";
export async function receive(request: Request): Promise<Response> {
const body = Buffer.from(await request.arrayBuffer());
let event;
try {
event = verifyWebhook(body, request.headers, process.env.ALTER_WEBHOOK_SECRET ?? "");
} catch (error) {
if (error instanceof WebhookSignatureError) return new Response("bad signature", { status: 400 });
throw error;
}
if (event.type === "approval.requested") {
await notifyReviewers(event.data.approval_id, event.data.approvers);
}
return new Response("ok");
}

The format is the Standard Webhooks one, so the standardwebhooks library for any language verifies a delivery too: construct its Webhook with the whsec_ secret and call verify(body, headers).

Respond with any 2xx status within 15 seconds. Do the work after acknowledging: a slow handler is retried as a failure. Respond 400 to a delivery that fails verification and do not act on it.

alter webhooks rotate-secret (or Rotate secret on the card) mints a new secret and shows it once. The secret it replaces keeps signing for 24 hours, so every delivery carries one signature per valid secret, and a receiver that verifies with any of them keeps working while the new secret is rolled out: update the receiver, then forget the old secret. The answer’s previous_secret_valid_until is when deliveries stop choosing the replaced secret (24 hours after the rotation, or the moment the rotation committed with --expire-previous); a delivery that chose its secrets just before that instant can still carry the old signature until its own send completes, at most the per-attempt delivery timeout plus a few seconds later.

Each old secret keeps its own deadline. Rotating again inside the 24 hours adds another secret without extending the earlier ones, so a delivery can carry several signatures at once (the newest secret’s first), and the count falls back to one as the old deadlines pass. The SDK helpers accept a delivery when any signature verifies against the secret they are given.

When the old secret may be compromised, end the overlap: alter webhooks rotate-secret --expire-previous (or Expire the old secret now in the rotate dialog) stops every previous secret signing immediately, so deliveries carry only the new secret’s signature. Update the receiver first, because deliveries signed under the new secret fail a receiver still holding the old one.

Every delivery is an envelope around the event’s data:

{
"id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"type": "approval.decided",
"occurred_at": "2026-11-03T10:15:30.120000+00:00",
"sequence": 2,
"api_version": "2026-11-03",
"data": { "...": "..." }
}

sequence increases with each event about the same approval, so a receiver that applies events out of order can drop one older than the last it applied. api_version names the payload shape. One version exists today, 2026-11-03, and every endpoint receives it.

EventWhendata carries, beyond the common fields
approval.requestedA call was held for approval. One event per gate of a multi-party request.—
approval.decidedA listed approver approved or denied it, in the Wallet or through the API — or Alter denied it, because another gate of the same request was denied.decision (approved or denied), decided_by, actor_assurance (verified_by_alter or asserted_by_integration; see what each proves — verified_by_alter means a valid, unexpired ID token for that person was presented, not that they consented to this decision), reason when one was given. decided_by and actor_assurance are null when Alter denied the gate because a sibling gate was denied (reason says so), or for a decision recorded before this release.
approval.expiredThe window elapsed, or the request was voided because its grant or agent was revoked, its managed secret deleted, or a sibling gate ended the group.expiry_reason: window_elapsed, grant_revoked, grant_expired, agent_revoked, secret_deleted or sibling_gate
approval.executedThe approved call ran and the provider answered.decided_by, actor_assurance, executed_at, result_available_until (until when the application can fetch the result)
approval.failedThe approved call could not be completed.decided_by, actor_assurance, execution_error
webhook.testSent on request from the card or alter webhooks test (never subscribed to).endpoint_id, app_id, message

The common fields of every approval event:

FieldMeaning
approval_id, approval_group_idThe approval and its group (every gate of one held request shares the group).
statusThe approval’s status when the event was recorded.
approversThe people who may decide this gate, lower-cased. Any one of them may.
expires_atThe window’s end.
request_digestThe digest of the held request a batch decision binds to.
contextThe run_id and thread_id the caller bound to the request, when it did.
summary_version, summaryThe approver-facing summary, trimmed for a webhook: the method, host, path, the managed operation’s identity and how many sensitive inputs the request carries. A payload never carries the request’s values or its parameters as sent. summary_version is the shape summary is served in.
required_summary_versionThe lowest summary_version an approve of this request accepts, as the status read and the list report it. The payload never carries the request’s values, even when the two versions are equal, so a receiver never decides at the payload’s summary_version: read the status at required_summary_version (the status read sent a summary_version serves the full summary, values included), render that summary, then decide at it.
approvableWhether any summary version can approve this request. false for a request no version can show (its values unavailable): it accepts only a denial, and its required_summary_version names the newest version as a placeholder. Read this field, not the gap between the two versions, to tell the two cases apart.

Nothing in a payload is the held request: its headers, body, the values it sends or the approver’s masked view never leave Alter this way. A receiver that needs more reads the approval through the SDK (get_approval_status / getApprovalStatus), authenticated as the application.

These are additive within api_version 2026-11-03; no field was renamed or removed.

  • 2026-11-09: required_summary_version can exceed summary_version for a request that can be approved. A request is now judged by the full summary a decide rebuilds (its operation and the values as sent), recorded when the request is held. A body-carrying write on a custom integration, for example, now reports summary_version 1 and required_summary_version 2. Before this release the two differed only for a request no version could show. Who is affected: a receiver that treated required_summary_version above summary_version as “can only be denied”. How to recover: read the new approvable field instead; when it is true, read the status at required_summary_version, render that summary and decide at that version. Requests held before this release report as before, and a decide of one can still answer approval_summary_version_required, whose required_summary_version names the version to read at.
  • 2026-11-09: approvable added to every approval event (see the table above).

event.data is the raw object, and stays available. The SDKs also give each event type a typed model, so a receiver reads fields by name instead of by key: ApprovalRequestedData, ApprovalDecidedData, ApprovalExpiredData, ApprovalExecutedData, ApprovalFailedData and WebhookTestData, with the fields of the tables above (snake_case in Python, camelCase in TypeScript). event.typed_data (Python) / event.typedData (TypeScript) returns the model for the event’s type, or None / null for a type this SDK build does not know, so a newer event never breaks a receiver. WebhookEventType names the six types as a literal.

from alter_sdk import ApprovalDecidedData
event = verify_webhook(body, headers, SIGNING_SECRET)
data = event.typed_data
if isinstance(data, ApprovalDecidedData):
record_decision(data.approval_id, data.decision, data.decided_by, data.actor_assurance)
import { ApprovalDecidedData } from "@alter-ai/alter-sdk";
const event = verifyWebhook(body, headers, secret);
const data = event.typedData;
if (data instanceof ApprovalDecidedData) {
recordDecision(data.approvalId, data.decision, data.decidedBy, data.actorAssurance);
}

A state change and its deliveries are recorded in the same transaction, so an event exists exactly when the status it announces does, and an endpoint registered later never receives earlier events. A background drainer sends each delivery and records the outcome. Only a 2xx answer is a success: redirects are not followed, and a 3xx, 4xx, 5xx, a timeout (15 seconds) or a refused connection is a failure.

A failed delivery is retried 7 times, after about 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours (each with a little jitter), then marked exhausted. A Retry-After header on a 429 or 503 is honoured up to an hour. A 421 Misdirected Request from a receiver behind an address shared with another host is re-sent once, within the same attempt, on a fresh connection before it counts: only a second 421 is a failed attempt. Each endpoint’s log (alter webhooks deliveries, or Deliveries on the card) shows every delivery’s status, attempts, last HTTP status or typed error (status:503, transport:timeout, blocked:blocked_destination), and next attempt; response bodies are never stored. The card shows each status and error in words (the raw code stays in the row’s tooltip); the CLI prints the codes.

An endpoint is disabled by Alter when 20 events in a row exhaust every delivery attempt with no success in between, or when every attempt has failed for 5 days (the defaults; a deployment can change both, and the endpoint list reports the values in force). Its deliveries are then recorded as skipped_disabled instead of sent, the organization’s administrators are emailed, and an audit row webhook_endpoint.auto_disabled is written. Fix the receiver, re-enable the endpoint (alter webhooks update --enable, or its switch on the card), and recover what was missed.

alter webhooks resend --delivery <id> (or Resend on a delivery row) queues a fresh delivery of one message. After an outage, alter webhooks recover --since <iso-8601> (or Recover failed since in the log) queues a fresh delivery of every message since that time whose latest delivery to the endpoint did not succeed, 1,000 messages per call; the answer’s next_since, when present, is the time to pass on the next call. A disabled endpoint cannot be recovered: re-enable it first, or the call is refused with HTTP 409 webhook_endpoint_disabled.

In the developer portal, every change to an endpoint needs an organization admin’s session (see Register an endpoint); a member reads. Managing endpoints from the CLI needs a personal access token whose owner is an organization admin for every change — a member-owned token is refused whatever scopes it holds — with dashboard_app_webhooks:read to list endpoints and read delivery logs (URLs redacted), :write to update, test, resend and recover, :admin to delete, and :rotate for anything that returns a signing secret or re-points deliveries — endpoint creation and changing an endpoint’s URL (each with :write), and rotation. An endpoint’s full URL is read by alter webhooks show only with a token whose owner is an organization admin and that holds :write or :admin. The reads stay open to every member. rotate is never granted by a wildcard; type it at alter auth login --scopes. See Scopes.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.