Webhooks¶
Status: draft M0. Normative are the section webhooks: in openapi.yaml (one
entry per event type with a schema) and this document (delivery rules). Decision: ADR 0016.
Conventions of the API: docs/spec/06-api.md. Events and receive actions:
docs/spec/05-actions.md, section 6.
There are two directions:
| Direction | Who | Purpose |
|---|---|---|
| outgoing | server to an installed app | events that the app has subscribed to, and commands, interactions and autocomplete requests to its interaction address (section 10), signed, to an HTTPS address |
| incoming | external system to the server | write a message as an app into a channel, through a secret URL |
Plugins never run in the server process (B6). Webhooks and the gateway are their only ways to learn about events.
1. Subscribe¶
- The app is installed, has the scope
events:subscribeand the desired types inevent_subscriptions(installApp,setAppScopes). - An owner creates the subscription (
createWebhookSubscription, actionwebhook_out.manage, step-up). The address must usehttpsand its host must be inwebhook.allowed_hosts(R-DM-17); otherwise400 webhook_host_not_allowed. - Each subscription covers a subset of the subscribed types (
event_types). A subscription can be paused (state = paused) and activated again. - Validation (M2-26, DECISIONS.md D-M2-26-8 and D-M2-26-18): entries of
webhook.allowed_hostshave the formhost[:port](without a port only443); the address must parse, usehttps, have no user info and no fragment, and its canonical host (lower case, no trailing dot) and port must match an entry, otherwise400 webhook_host_not_allowedfor a host or port outside the list and400 validation_failedat/urlfor every other defect.event_typesis not empty, has no duplicate (invalid), and each type is subscribable by apps (not_subscribable, section 7) and in theevent_subscriptionsof the installation (not_granted), each at/event_types/<index>. An app without anactiveorsuspendedinstallation answers409 conflictwithreason: app_not_installed.rotate_hmac_secretwithout HMAC is400 validation_failedwithhmac_not_enabled. A response with awhsec_secret is a one-time secret: a replay with the sameIdempotency-Keyanswers409 conflictwithreason: one_time_secret.
2. Delivery¶
Every delivery is a POST with exactly one event:
POST /chat-events HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: chat-server-webhooks/<server-version>
webhook-id: 0192f4a0-7b1c-7a3e-8c1d-5f0e9a2b4c6d
webhook-timestamp: 1791624600
webhook-signature: v1a,<Ed25519 signature in Base64> v1,<HMAC-SHA256 in Base64>
{"id":"0192f4a0-7b1c-7a3e-8c1d-5f0e9a2b4c6d","seq":5301,"type":"message.created", ...}
| Part | Rule |
|---|---|
| Body | The event envelope from openapi.yaml (MessageCreatedEvent etc.), the same form as in the gateway and in GET /events. Standard Webhooks recommends type, timestamp, data; our envelope has type at the top level and occurred_at instead of timestamp, because one form for all delivery channels matters more than the recommendation [verified, W1] (ADR 0052). |
webhook-id |
Event ID (id in the envelope). The same for every retry and for a redelivery triggered by the owner. |
webhook-timestamp |
Unix seconds of this attempt. |
webhook-signature |
Space-separated list: always v1a,<base64> (Ed25519), additionally v1,<base64> (HMAC-SHA256) if the subscription uses HMAC. During a key rotation both keys are in the list. |
| Signed content | {webhook-id}.{webhook-timestamp}.{body}, the body byte for byte as sent [verified, W1]. |
| Epoch | Every delivery carries the delivery field sync_epoch (04, 2.2 and 7.5; ADR 0059). |
| Content | Only fields that the app may see with the receive action (04, 2.2). Never channel prompts, skills or secrets (R-AZ-09). Large payloads are truncated and carry truncated: true; the app loads the rest via REST. |
2.1 Signing keys¶
v1auses the server key with the purposewebhook_out(03,server_signing_keys). The public keys are available atGET /api/v1/instance/webhook-keysin the formatwhpk_<base64>[verified, W1]. The subscription names itssigning_key_kid.- Key rotation (
rotateSigningKey, purposewebhook_out): the new key appears in the list withstate = nextbefore it signs. After that, the server signs with both until the old one reachesnot_after. v1(HMAC) can be enabled per subscription (enable_hmac). The secret is not stored but derived:HMAC-SHA256(master key, "webhook-v1:" || subscription_id || ":" || generation), with the master key from the secret provider (B12). The server shows it once aswhsec_<base64>.rotate_hmac_secretincreases the generation; forwebhook.secret_rotation_overlapthe server signs with the old and the new generation (03webhook_subscriptions.hmac_generationandhmac_rotated_at).- Signing set and encodings (M2-26, D-M2-26-10 and D-M2-26-11):
v1acarries one signature per signer of the purposewebhook_out: theactivekey first, then everyretiringkey whosenot_afterlies ahead.v1uses the key ofhmac_generationand, whilehmac_rotated_at + webhook.secret_rotation_overlaplies ahead, the key of the previous generation; never an older one. Entries are separated by one space, allv1aentries first in signer order, then thev1entries newest generation first, each<scheme>,<standard base64 with padding>. The HMAC derivation takes the subscription ID in canonical lower-case hyphenated form and the generation in decimal ASCII; the master key is the hex-decoded value ofwebhook.hmac_master_key_ref; the derived 32 bytes are the HMAC key, andwhsec_plus their standard base64 is the secret shown once. A public key iswhpk_plus the standard base64 of its DER-encoded SubjectPublicKeyInfo (RFC 8410). - Reason for two methods: Standard Webhooks recommends asymmetric signatures [verified, W1];
but the reference libraries name HMAC in their descriptions,
v1ais not documented there [assumed, W2]. HMAC keeps the hurdle for receivers low.
2.2 Test vectors¶
webhook-test-vectors.json is the contract file for every verifier
(server, SDKs; D-M2-26-12): {"version": 1, "derivation": [...], "signatures": [...]}. A
derivation case is {master_hex, subscription_id, generation, derived_hex} with derived_hex the
lower-case hex of the 32 derived bytes; the secret shown to the owner is whsec_ plus their
standard base64 and is not stored in the file. A signature case is {name, webhook_id, timestamp,
body, ed25519_seed_base64, public_key, hmac_secrets, signature_header}; ed25519_seed_base64 and
public_key are lists in signer order (one entry per v1a signer), hmac_secrets lists the
whsec_ secrets newest generation first (each carries the marker EXAMPLE), and
signature_header is the value of webhook-signature byte for byte. The cases cover one key and
one secret, two v1a signers, two HMAC generations, a body with non-ASCII characters and escapes,
and a body with truncated: true.
3. Verification by the receiver¶
- Read the raw body; do not parse it as JSON and serialize it again first [verified, W1].
- Check
webhook-timestamp: the deviation from the own clock is at mostwebhook.receiver_tolerance. - Check the signatures: try every signature of the list against the trusted keys; one valid
signature is enough. Fetch the
v1akeys once and keep them as a trust list; do not take them from the request [verified, W1]. Compare HMAC in constant time. Reload the list after themax-ageof the response ofgetWebhookKeys; new keys appear in it beforehand asnext. An incoming request never triggers a reload, so a forged request cannot make the receiver fetch. - Detect duplicates: remember
webhook-id, at least for the duration of the retries (webhook.retry_max_age). - Respond quickly with
2xx, withinwebhook.delivery_timeout(11-extensions.md); do the work afterwards. - Establish the order through
seqand the object state throughresource.version(04, 9). Ifsync_epochchanges, the instance was restored: discard the state remembered byseqand continue with the new timeline, otherwise its smaller numbers would count as old.
The TypeScript SDK verifies these steps 1 to 3 for apps, also for requests to interactions_url
(verifyWebhook, verifyWebhookRequest and WebhookKeyCache in sdk/typescript/src/webhooks/,
M2-30), and so does the Python SDK with the same behaviour (verify_webhook and
WebhookKeyCache in the module bothy_sdk.webhooks, M2-50); step 4 stays the app's duty.
4. Retries and disabling¶
| Response | Effect |
|---|---|
2xx |
Success |
410 |
The subscription is disabled (state = disabled) [verified, W1] |
429, 502, 503, 504 |
Throttle; Retry-After is respected [verified, W1] |
3xx |
Failure; redirects are not followed [verified, W1] |
| other status, timeout, dropped connection | Failure |
- Retry with exponential backoff and full jitter between
webhook.retry_backoff_baseandwebhook.retry_backoff_cap(03), untilwebhook.retry_max_ageis reached. Then the delivery becomesdead. - After
webhook.max_consecutive_failures(03) consecutive failures, the subscription becomesdisabled. This is recorded in the audit log (system actionwebhook_out.transition, 05, R-AZ-27) and shown in the admin console. The product sends no emails (Baseline section 4). - Every attempt first checks
webhook_out.deliverand the receive action of the event for the app (05, R-AZ-11). If the app has lost access in the meantime, the delivery becomesdeadwithlast_error = not_authorized, without a request. - An owner can trigger any delivery again (
redeliverWebhook); it carries the samewebhook-id. - Response bodies are not stored (03,
webhook_deliveries.last_error). - Statuses (M2-26, D-M2-26-6):
pendingwaits for its first or next attempt (also after a failed attempt, withnext_attempt_at);succeededgot a2xx;failedended with410(terminal, the owner may redeliver);deadended afterwebhook.retry_max_age, without access (not_authorized), because the type leftevent_typesor the installation'sevent_subscriptions(not_subscribed), or because its event left the log (event_expired).last_erroris a code of the closed listtimeout,connection_failed,tls_failed,address_blocked,host_not_allowed,http_status,throttled,gone,not_authorized,not_subscribed,event_expired. - Only failures count towards
webhook.max_consecutive_failures: other statuses,3xx, timeouts, refused or failed connections. A throttle (429,502,503,504) and an ending without a request (not_authorized,not_subscribed) do not count. After the n-th failed or throttled attempt the pause is uniform in [webhook.retry_backoff_base, min(webhook.retry_backoff_cap,webhook.retry_backoff_base* 2^(n-1))]; for a throttle with a validRetry-After(delta-seconds or HTTP-date; a past date counts as zero) the larger of backoff andRetry-Afterapplies. No retry is scheduled aftercreated_at + webhook.retry_max_age(a later time is cut to it), and a failure at or after it makes the deliverydeadand the subscriptiondisabled(D-M2-26-7). - A redelivery (
redeliverWebhook, D-M2-26-14) makes apendingrow due at once; asucceeded,failedordeadrow becomespendingagain withlast_errorandresponse_codecleared,attemptkept andcreated_atset to now, so the window ofwebhook.retry_max_agestarts again. It goes out before the later pending rows of the subscription; a paused or disabled subscription keeps it waiting. An event that is no longer in the log answers409 conflictwithreason: event_expired. - Retention (M7-05) may delete only
succeeded,failedanddeadrows older thanretention.webhook_deliveries, neverpendingones.
5. Order and duplicates¶
- Delivery at least once (at-least-once).
- Per subscription strictly in
seqorder, as on the gateway: the next event goes out only after the previous one has been accepted with2xx(11-extensions.md, section 7). Reason: receivers can apply events in order as in the gateway, and for subscriptions of agents2xxcounts as an acknowledgement up to thisseq(04-sync-protocol.md, section 15.6). webhook_subscriptions.last_delivered_seqis the highestseqaccepted with2xx. If an event still fails whenwebhook.retry_max_ageis reached, the delivery becomesdeadand the subscriptiondisabled; after the owner turns it on again, delivery continues with the next event.- While a subscription is
paused, its deliveries are still created and wait; a resume continues with them inseqorder. While it isdisabled, no delivery is created: events of that time are not delivered (D-M2-26-1); a re-enable continues with the remaining pending rows (the head first) and then with events committed after it. - Gap (D-M2-26-5): when the event of the head left the log, or when the consumer
webhookslost a range to retention before reading it, the subscription becomesdisabledwith causegap, its expired pending deliveries becomedeadwithevent_expired, and the audit entry names the lost range; nothing is skipped silently.
6. Delivery security¶
- Only
https, only hosts fromwebhook.allowed_hosts, no redirects. - Deliveries go through the egress client of the server
(19-security.md, SEC-NET-1 to SEC-NET-5): it checks the address
after DNS resolution when it opens the connection and rejects loopback, link-local and private
addresses, except for entries in
egress.allowed_hostswithallow_private. Reason: protection against DNS rebinding and SSRF (Baseline section 4). - Time limit per attempt
webhook.delivery_timeout; Standard Webhooks recommends 15 to 30 seconds [verified, W1].
7. Event types for apps and agents¶
Generated from openapi.yaml. Events with the audience self go only to the app or the agent that
is itself the receiver (own commands, own components, own permissions). Events marked
"agent subscription only" go only to subscriptions of an agent (webhook as transport,
04-sync-protocol.md section 15.6).
| Event type | Receive action | Receiver and scope in addition to events:subscribe |
Schema |
|---|---|---|---|
message.created |
message.read |
messages:read |
MessageCreatedEvent |
message.updated |
message.read |
messages:read |
MessageUpdatedEvent |
message.deleted |
message.read |
messages:read |
MessageDeletedEvent |
reaction.added |
message.read |
messages:read |
ReactionAddedEvent |
reaction.removed |
message.read |
messages:read |
ReactionRemovedEvent |
pin.added |
thread.read |
messages:read |
PinAddedEvent |
pin.removed |
thread.read |
messages:read |
PinRemovedEvent |
thread.created |
thread.read |
messages:read |
ThreadCreatedEvent |
thread.updated |
thread.read |
messages:read |
ThreadUpdatedEvent |
thread.closed |
thread.read |
messages:read |
ThreadClosedEvent |
thread.reopened |
thread.read |
messages:read |
ThreadReopenedEvent |
thread.moved |
thread.read |
messages:read |
ThreadMovedEvent |
thread.moved_out |
channel.read |
messages:read |
ThreadMovedOutEvent |
agent_session.changed |
thread.read |
messages:read |
AgentSessionChangedEvent |
run.state_changed |
run.read |
messages:read |
RunStateChangedEvent |
space.created |
space.read |
messages:read |
SpaceCreatedEvent |
space.updated |
space.read |
messages:read |
SpaceUpdatedEvent |
space.archived |
space.read |
messages:read |
SpaceArchivedEvent |
channel.created |
channel.read |
messages:read |
ChannelCreatedEvent |
channel.updated |
channel.read |
messages:read |
ChannelUpdatedEvent |
channel.settings_changed |
channel.read |
messages:read |
ChannelSettingsChangedEvent |
channel.archived |
channel.read |
messages:read |
ChannelArchivedEvent |
command.changed |
command.read |
none (metadata, 05 section 2.6) | CommandChangedEvent |
file.updated |
file.read |
files:read |
FileUpdatedEvent |
user.updated |
user.read |
users:read |
UserUpdatedEvent |
agent.updated |
agent.read |
none (metadata, 05 section 2.6) | AgentUpdatedEvent |
instance.updated |
instance.read |
none (metadata, 05 section 2.6) | InstanceUpdatedEvent |
status.updated |
status.read |
none (metadata, 05 section 2.6) | StatusUpdatedEvent |
access.changed |
self |
own only (app or agent is the receiver) | AccessChangedEvent |
agent.invocation |
agent.events.consume |
agent subscription only (agent is the receiver) | AgentInvocationEvent |
agent.followup |
agent.events.consume |
agent subscription only (agent is the receiver) | AgentFollowupEvent |
run.cancel_requested |
agent.events.consume |
agent subscription only (agent is the receiver) | RunCancelRequestedEvent |
agent.config_changed |
agent.config.read |
agent subscription only (agent is the receiver) | AgentConfigChangedEvent |
interaction.created |
agent.events.consume |
own only (app or agent is the receiver) | InteractionCreatedEvent |
command.invoked |
agent.events.consume |
own only (app or agent is the receiver) | CommandInvokedEvent |
Not subscribable: every event of the catalogue that is not in the table is available only through
the gateway and GET /api/v1/events. These are all ephemeral events and the following durable ones:
| Group | Event types | Reason |
|---|---|---|
| personal events of people | read_state.updated, draft.updated, draft.deleted, settings.updated, sidebar.updated, notification_pref.updated, quiet_hours.updated, session.revoked |
concern the devices and the interface of a person; an app or an agent is never the receiver |
| expiry reminder | access.expiring |
a hint for the interface; an app or an agent learns the effective expiry through access.changed |
| administration | grant.changed, group.changed, gdpr.request_updated, import.progress |
data of the console; apps have no manage (11 section 5) and see their own permissions through access.changed |
| terminal | host.updated, terminal_session.changed |
terminal actions only for people in a session (R-AZ-23) |
Agent events (invocations, follow-ups, cancellation) exist as webhooks only for subscriptions of an
agent (table above). A new durable event type is either in webhooks of openapi.yaml or
in this list; the check api-catalogue-sync (M1-03, subcheck webhook-events) enforces this.
command.invoked and interaction.created reach an app through a subscription only when it holds
events:subscribe and lists them in event_types; an app whose manifest declares
delivery.interactions_url gets them, and its autocomplete requests, also without a subscription
at that address (section 10).
8. Incoming webhooks¶
- A person with
manageon the channel creates the incoming webhook (createIncomingWebhook, step-up). They choose an installed app as the writing principal and optionally a fixed thread. The response contains the URL with the token exactly once. - External systems post in one of two variants with the same request body
(
IncomingWebhookPost) and the same answers:postIncomingWebhookonPOST /api/v1/hooks/{webhook_id}/{token}with the token as the last path segment (Authorizationis ignored), orpostIncomingWebhookWithBeareronPOST /api/v1/hooks/{webhook_id}withAuthorization: Bearer <token>(exactly one header, schemeBearer, one token; a missing or other scheme counts as a wrong token). TheurlofcreateIncomingWebhookis the absolute path variant built fromserver.public_url; a Bearer sender drops the last segment and sends it in the header instead, so the token never appears in a URL. Tokens start withbothy_whi_; the server keeps only their HMAC. - The message has
origin = app, and the author is the app. It never triggers agents (03,incoming_webhooks). The app needswriteon the channel. - Unknown or revoked webhooks and wrong tokens result in
404. Rate-limit classrl.webhook_inper webhook. - The token is in the URL because many senders can only configure a URL. Server and
proxies redact the path after
/hooks/{webhook_id}/in logs and traces. Rotation: create a new webhook, revoke the old one. - Formats. The format is recognised per request from the body; it is not stored per webhook,
and both formats use the same URL. A body with at least one Alertmanager member (
version,groupKey,truncatedAlerts,status,receiver,groupLabels,commonLabels,commonAnnotations,externalURL,alerts) isalertmanagerand must carry no native member, else400 validation_failedwith the pointer of the first native member anderrors[].codeformat_mixed. Every other body isnative. A format not listed inwebhook_in.formats_enabledgives400 validation_failedwith an empty pointer andformat_disabled. A native body with any other member gives400 validation_failedwithunknown_member; an Alertmanager payload may carry members it does not name, at the top and in each alert (for examplerouteLabelsandnotification_reasonof v0.34.1), which are ignored (D-M7-20-5).native: Markdown incontent_md(required, at least one non-white-space character, at mostmessage.max_content_bytesUTF-8 bytes, else413 payload_too_large), stored byte for byte; optionaltitle(new thread, point 7),thread_id(existing thread, point 7) andclient_msg_id(a UUIDv7, elsenot_uuid_v7; when absent the server creates one, so only a sender's ownclient_msg_idmakes retries idempotent).alertmanager: the unchanged webhook payload of Prometheus Alertmanager (version4,statusand eachalerts[].statusfiringorresolved, at least one alert, each alert withstatusandlabels, every label and annotation value a string). One notification gives one message: a header line withFIRINGorRESOLVED, the alert name (alertnameofcommonLabels, else ofgroupLabels, elseAlerts) and the number of alerts; one list item per alert with its status, name, severity (labelseverity) and summary (annotationsummary, elsedescription, else the remaining labels asname=value), withRunbook:from the annotationrunbook_urlandSource:fromgeneratorURL; an item- and <k> more alertsfortruncatedAlertsand alerts dropped to fit; and a last lineAlertmanager:withexternalURL. Payload values are escaped so they add no formatting, links or images; a URL appears only as an absolutehttporhttpsautolink and is never fetched by the server. The message never exceedsmessage.max_content_bytes. Each notification gets a new client message ID, so retries of Alertmanager are not deduplicated. The layout is fixed in DECISIONS.md (D-M2-27) and by the golden files ofservices/server/internal/apps/domain/incoming/testdata.
- Target thread (11 8.3), in this order: (1) the fixed thread of the webhook while it belongs
to the webhook's channel, and
thread_idandtitleof the body are ignored; a fixed thread moved to another channel gives404. (2)thread_idof anativebody when that thread belongs to the webhook's channel; an unknown thread or one of another channel falls through to the next rule. (3)titleof anativebody creates a thread: in a forum a forum post with the message as its first message; in a text channel the message goes into the main thread and the new thread starts from it, and the answer names the new thread inthread_id. (4) The main thread of a text channel. A forum that none of these reaches gives400 validation_failedwithrequired_for_forum_post(pointer/titlefornative, empty foralertmanager), so Alertmanager posts into a text channel or a fixed thread. - Order of checks (11 8.4):
rl.webhook_inkeyed bywebhook_id(429 rate_limitedwithRetry-After, before any database access); the token (404; a failed check takes one token fromrl.public_authof the client IP, and while that bucket is empty the answer is429without a check); a body abovewebhook_in.max_body_bytes(413 payload_too_large), malformed JSON (400 malformed_request) and the schema (400 validation_failed); the format and its body rules (400,413); the target thread; thenauthorizeof the app (webhook_in.post, thenmessage.createand for a new threadthread.create) and the write. An unknown or revoked webhook, a wrong or malformed token, an app that is suspended or uninstalled, an app withoutwriteor without the scopemessages:write, a read-only or archived channel and every other refusal of the app's rights give the same404 not_foundwith the body and headers of an unknown webhook. Success is201withmessage_idandthread_id(IncomingWebhookResult). - Replay. A
nativepost with aclient_msg_idalready used by the same app answers200withIdempotent-Replay: trueand themessage_idandthread_idof the first answer (for a title in a text channel the thread started from the message); no second message and no second thread are created. The request hash covers the webhook,content_md,thread_idandtitle, so the sameclient_msg_idwith other content, anotherthread_idortitle, or through another webhook of the same app gives422 idempotency_key_reuse. - Internal listener. Senders inside the stack (Alertmanager) use the same two paths on
server.internal_listen_addr, in every server role, with the same behaviour. There the client IP is the peer address and forwarded headers are never read;rl.webhook_inandrl.public_authcount per listener. Nothing else of the API is served on that listener besides health and readiness. -
Cron example. A job creates one
client_msg_id(UUIDv7) per run and reuses it on every retry of that run, so a run gives exactly one message however often it retries (11 10.4). The token comes from a file or a secret store; the value below is a placeholder.#!/bin/sh # Nightly report: one client_msg_id per run, the same on every retry. HOOK_URL="https://chat.example.com/api/v1/hooks/0192f4a0-7b1c-7000-8a00-000000000901" HOOK_TOKEN="$(cat /etc/nightly-report/hook_token)" # bothy_whi_EXAMPLE..._... MSG_ID="$(python3 -c 'import uuid; print(uuid.uuid7())')" # UUIDv7, Python 3.14 or later BODY=$(printf '{"content_md": "Backup finished on db.example.com.", "client_msg_id": "%s"}' "$MSG_ID") for attempt in 1 2 3 4 5; do status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "$HOOK_URL" \ -H "Authorization: Bearer $HOOK_TOKEN" \ -H 'Content-Type: application/json' \ --data "$BODY") case "$status" in 201|200) exit 0 ;; # 200: replay of an earlier attempt of this run 429|5??|000) sleep "$((attempt * 30))" ;; # rate limit, server or network error: retry *) echo "incoming webhook refused: $status" >&2; exit 1 ;; esac done exit 1A sender that can only configure a URL posts the same body to
https://chat.example.com/api/v1/hooks/0192f4a0-7b1c-7000-8a00-000000000901/bothy_whi_EXAMPLE..._...without theAuthorizationheader.
9. Configuration values¶
The values of this document are in the table of
11-extensions.md, so that
the generator takes them into the configuration reference (ruling L4): webhook.retry_max_age,
webhook.secret_rotation_overlap and webhook.receiver_tolerance. The last one is a recommendation
for receivers, not a value of the server. webhook.allowed_hosts, webhook.max_consecutive_failures,
webhook.retry_backoff_base and webhook.retry_backoff_cap are in 03, section 9;
egress.allowed_hosts is in 19-security.md. The default for
webhook.retry_max_age is close to the example schedule of Standard Webhooks (about 75 hours)
[derived, W1]. Section 10 uses app.interaction_timeout (11, section 16),
command.autocomplete_timeout and command.autocomplete_max_choices (09) and
http.max_json_body_bytes (06-api.md).
10. Interaction address of apps¶
An app whose manifest declares delivery.interactions_url (11 3.1) gets its commands, the
interactions on its components and the autocomplete requests of its commands as a synchronous
POST to that address (11-extensions.md, section 9; M2-25). The way
follows the manifest, not a gateway connection: an app with an active installation and an
interactions_url gets the request, every other app with an active installation gets the
events only through the gateway, the REST catch-up and its webhook subscriptions (point 9).
- Trigger.
invokeCommandof an app command andcreateInteractionon a component of an app card commit the durable event (command.invokedorinteraction.created, audience the app) and the jobruns.app_dispatchin one unit of work. The job sends the request after the commit, never the person's request; the person gets202(CommandInvocationwithoutrun_id) or201(Interactionwithstate = received) at once. Autocomplete holds no transaction and asks the app inside the person's request (point 7). - Check before the request. The job checks again with the rights of this moment, without an
audit entry:
command.invokeorinteraction.create(stagedeliver) for the principal that acted (a person as the principal of the check without a request, an agent or app as itself), and the app for anactiveinstallation, the scope of the feature andwriteon the channel. A refusal, an installation that is no longeractiveor a host that is no longer inwebhook.allowed_hostsends the job without a request. - Body. For
command.invokedandinteraction.createdthe stored event envelope, rendered exactly as a webhook delivery of that event (section 2): the envelope of ADR 0052 with the delivery fieldsync_epoch, withoutinvocation_token(tokens only for agents, 04 2.2) and withoutredelivered. For autocomplete the ephemeralcommand.autocomplete_requestedenvelope, withoutseqandpermission_version, withsync_epoch. - Headers and signature as in sections 2 and 2.1:
webhook-idis the eventid,webhook-timestampthe Unix seconds of this attempt,Content-Type: application/jsonand theUser-Agentof the deliveries.webhook-signaturecarries onlyv1aentries (Ed25519, purposewebhook_out, both keys during a key rotation as in 2.1 point 4) and neverv1: HMAC belongs to a subscription (enable_hmac), and an interaction address is none. The app verifies as in section 3, points 1 to 3, with the keys ofgetWebhookKeys(GET /api/v1/instance/webhook-keys). The server sends no token and no secret to the app. - Answer forms:
| Response | Schema | Effect |
|---|---|---|
200 to a command or an interaction |
MessageCreate |
The server writes the message in the app's name with origin = app into the thread of the command or of the card (point 8) |
200 to autocomplete |
AutocompleteAnswer |
The choices go to the person |
202 |
none, the body is ignored | Deferred: the app answers later through the API |
any other status (also another 2xx), 3xx, timeout, a body above the bound, a transport error |
Not answered |
The 200 body is decoded strictly: unknown members, duplicate keys, invalid UTF-8, JSON null
and, for MessageCreate, a missing client_msg_id count as not answered. The reply is written
through the normal create path: message.create needs write and messages:write of the
app, mentions are resolved as for every message of the app, and a card follows the card rules
of apps (a run_id is 400 validation_failed with run_mismatch on /run_id, because an
app has no runs). A reply refused by any rule of that path counts as not answered. An
AutocompleteAnswer has at most command.autocomplete_max_choices choices with non-empty
name and value; otherwise it counts as not answered. No message is written for
autocomplete.
-
Bounds. The answer body may be at most
http.max_json_body_bytes, because a reply is acreateMessagebody. The deadline per request isapp.interaction_timeout, notwebhook.delivery_timeout. Redirects are not followed. Onlyhttpsto hosts inwebhook.allowed_hostsat the time of the request (thehost[:port]rule of section 1, point 4), checked before the egress client; private addresses only as in section 6, point 2. -
Autocomplete. The deadline of the request is the earlier of the end of the person's wait (
command.autocomplete_timeout) and now plusapp.interaction_timeout.202keeps the request open untilcommand.autocomplete_timeoutfor an answer throughanswerAutocompleteor the gateway opcommand.autocomplete_result; every other outcome without choices gives the person an empty list at once. The person never waits longer thancommand.autocomplete_timeout. -
One attempt, no repetition (09 1 point 5). The job repeats only after a fault before the request (a database fault, a shutdown), within the default attempts of the job queue, and then sends the same
webhook-id. The app useswebhook-idas its idempotency key (section 3, point 4); a repeated reply is idempotent through itsclient_msg_id(04 5.1), so the app keeps the sameclient_msg_idperwebhook-id. -
Duplicates over several ways. An app can get the same
command.invokedorinteraction.createdthrough its gateway connection, the REST catch-up (listEvents), a webhook subscription (section 7) and the interaction address, each with the sameid(equal towebhook-id). Delivery is at least once (04 9 point 1); the app deduplicates byid. The gateway way is not open yet:sync.snapshot.readhas no app scope in the catalogue, so an app's connection is closed with4018atready(docs/decisions.d/M1-64.md M1-64/11, docs/decisions.d/M2-25.md D-M2-25-24); until the contract changes, an app without an interaction address uses the REST catch-up or a webhook subscription. -
Forum channels. A command in a forum channel without
thread_idhas no target thread, so a200with aMessageCreatecannot be written and counts as not answered. The app answers202and creates the forum post itself through the API (createThread, orcreateMessagein an existing thread). -
Interaction states (09 11.4). An installation that is no longer
activemakes the interactionfailedon both ways. Otherwise, without an interaction address the interaction staysreceiveduntil the app callsrespondToInteraction. With one, the job setsdispatchedbefore the request; a written reply givesacknowledged;202keepsdispatcheduntilrespondToInteraction; every other outcome givesfailed, and so does a refusal of the check of point 2. Each change is a compare-and-swap, so whichever of the job andrespondToInteractionleavesreceivedordispatchedfirst wins. The component staysusedwhen the app does not answer (11 9). Commands store no state; the outcome is only logged, without the address path, headers, bodies or values.
Sources¶
Retrieved on 2026-10-10.
| No. | Source | Content | Marker |
|---|---|---|---|
| W1 | https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md | Headers, signed content, v1/v1a, prefixes whsec_/whpk_, multiple signatures, status rules, backoff example, time limit 15 to 30 s, recommended body form |
[verified] |
| W2 | https://github.com/standard-webhooks/standard-webhooks | Reference libraries per language; support for v1a is not described there |
[assumed] |