Skip to content

M2-27 Incoming webhooks native and alertmanager

No Date Ruling Reason
D-M2-27-1 2026-10-20 The Bearer variant of 11 8.1 and 10.3 is its own operation postIncomingWebhookWithBearer on POST /api/v1/hooks/{webhook_id}, besides postIncomingWebhook on POST /api/v1/hooks/{webhook_id}/{token}; both have the action webhook_in.post, the class rl.webhook_in, the exemption client_id, the request body IncomingWebhookPost and the same responses. One operation cannot have two paths, and the plan row asks for both.
D-M2-27-2 2026-10-20 The token check is the chain stage httpserver.StageWebhookToken, directly after StageRateLimit and before StageMaintenance and the body stages, so it runs after rl.webhook_in and before any body is read or validated; it acts only on operations with action webhook_in.post, and every failure (malformed token, unknown ID, wrong hash, revoked webhook, installation not active) gives the same 404 not_found; 02 9.1 gets the step. Place: apps/adapters/httpapi/incoming_stage.go (TokenStage), wire/incoming.go. 11 8.4 orders the checks rate limit, then token, then size and content; a chain stage keeps that order for both listeners.
D-M2-27-3 2026-10-20 The actor of a verified post (apps/app.WebhookActor) is the app principal (ID = incoming_webhooks.app_id, Kind app) with the InstallationID of its active installation, GrantedScopes = the installation's granted scopes intersected with messages:write, and no TokenID, SessionID or DeviceID. A stolen URL may only post into its channel (T-TK-03); message.create and thread.create need exactly messages:write (05 2.6).
D-M2-27-4 2026-10-20 The requirement of webhook_in.post is All(AppKind, AtLeast(LevelWrite)) on the channel of the webhook, without scope, and no longer deferred; an anonymous principal gets unauthenticated. The use case authorizes webhook_in.post on every post, replays included, and the messaging use case then authorizes message.create (and thread.create) for the same actor; every outcome other than allow (not_found, forbidden with any reason, unauthenticated, step_up_required) becomes the 404 not_found of an unknown webhook, internal errors stay 500 internal_error. Place: authz/domain/requirements_table.go, apps/app/incoming.go (asNotFound). 07 4.2 allows anonymous only for the right public, and this right is "public with token; the app needs write"; 11 8.4 sends the branch "authorize app: message.create on the channel" with "no" to 404, so a denial reveals nothing.
D-M2-27-5 2026-10-20 The format is decided per request from the body (incoming.Classify), not stored per webhook; the request body is one closed schema IncomingWebhookPost with the native members (content_md, title, thread_id, client_msg_id) and the Alertmanager members (version, groupKey, truncatedAlerts, status, receiver, groupLabels, commonLabels, commonAnnotations, externalURL, alerts) and no required list, plus the schema AlertmanagerAlert. A body with an Alertmanager member is alertmanager and must hold no native member, else 400 validation_failed with the pointer of the first native member and format_mixed; every other body is native; a format not in webhook_in.formats_enabled gives 400 validation_failed with an empty pointer and format_disabled. 03 incoming_webhooks has no format column (core contract), openapi has no format member, and rule/oneof-needs-discriminator forbids a oneOf without discriminator while the Alertmanager payload has none.
D-M2-27-6 2026-10-20 A fixed thread is used only while it belongs to the webhook's channel (then thread_id and title of the body are ignored); when it was moved to another channel, the post gives 404 not_found. Place: apps/domain/incoming/target.go (ChooseTarget). A webhook writes only into its own channel (11 8.1 "for a channel", T-TK-03).
D-M2-27-7 2026-10-20 A native post with title into a text channel writes the message into the main thread (chat.TriggerChannel.MainThreadID) and starts a new thread from it (parent_message_id = the message), like auto_thread of 09 3.7; the answer names the new thread in thread_id, and a replay answers the thread of the first answer (chat.ThreadsByParent) and creates none. In a forum a title creates a forum post with the message as first message. In a text channel a thread grows from a message (01 4, M1-55 D1 requires the parent), and 11 8.2 says one message per call.
D-M2-27-8 2026-10-20 The request hash of a native post uses messaging.CreateMessageCmd.HashContext = "webhook:" + webhook_id + "\|thread:" + <thread_id of the body or empty> + "\|title:" + <title or empty> (incoming.HashContext); the message is written with SkipMentions. A replay (same author, client_msg_id and hash) answers 200 with Idempotent-Replay: true and publishes nothing. The hash covers the request as sent (04 5.1 point 3), and a client_msg_id repeated through another webhook of the same app gives 422 idempotency_key_reuse instead of a replay of another webhook's message.
D-M2-27-9 2026-10-20 After the commit TouchIncomingLastUsed sets last_used_at only when it is empty or older than identity.last_active_granularity, outside the unit of work; a failure is logged without the token and never fails the request. The same rule as api_tokens.last_used_at (03); concurrent posts never upgrade their share lock.
D-M2-27-10 2026-10-20 incoming.RenderAlertmanager writes a fixed layout, held by golden tests (apps/domain/incoming/testdata): a header **FIRING** or **RESOLVED** with the title (commonLabels.alertname, else groupLabels.alertname, else Alerts) and the alert count; one list item per alert with status, alert name, severity when labelled and summary (annotation summary, else description, else the other labels as sorted name=value, else no summary), with Runbook: and Source: lines; a last item - and <k> more alerts for truncatedAlerts and dropped items; the footer Alertmanager: with externalURL. Payload values have control characters replaced by a space and every ASCII punctuation character escaped; a URL is written only as an autolink of an absolute http or https URL with a host made only of printable ASCII without < and > (a character such as U+FEFF would end the autolink in a client and let the rest render as Markdown), else its line is left out; above message.max_content_bytes items are dropped from the end, then the footer, then the text is cut at a UTF-8 boundary with .... 11 8.2 lists the contents, not the layout, and docs/observability/alerting.md 2 adds severity and runbook link; the escaping keeps a label from adding formatting, links or images (SEC-OUT-1), and the server never fetches these URLs (SEC-OUT-2).
D-M2-27-11 2026-10-20 createIncomingWebhook answers 400 validation_failed with /app_id and app_not_installed when the app has no active installation, with /app_id and app_cannot_write when the webhook actor of the app may not message.create in the channel (checked in filter mode, no audit), and with /thread_id and not_in_channel when thread_id is unknown or a thread of another channel. 11 8.1: the webhook always writes as an app with write on the channel, and a manager sees the same through "Who has access?"; an unknown thread and a foreign one give the same answer.
D-M2-27-12 2026-10-20 listIncomingWebhooks lists only active webhooks, ordered by created_at, then id, without token or hash. The partial index of 03 serves exactly this list; the history of revoked webhooks is in the audit log.
D-M2-27-13 2026-10-20 While the app's installation is not active (suspended), its webhooks stay but every post gives 404 not_found; after app.resume the same URL works again. Suspend is an emergency stop for everything the app writes (11 4.3, R-EX-05, 07 4.1 app_not_installed).
D-M2-27-14 2026-10-20 apps/app.ArchiveSubscriber.OnChannelArchived sets revoked_at on the active webhooks of an archived channel in the archive transaction, without an audit entry of its own; uninstalling an app revokes its active webhooks in the unit of the uninstall (revokeIncomingForApp). Archiving is irreversible (05 has no unarchive), so such a webhook could never post again; the archive and the uninstall are the audited actions.
D-M2-27-15 2026-10-20 Both post operations are served on the internal listener in every role (api, worker, all) with identical behaviour; health stays as it is. Place: wire/incoming.go (mountInternalHooks). Senders inside the stack, such as Alertmanager, reach the routes without the public edge (16 4.2, 02 3.2, docs/observability/alerting.md 2 point 3).
D-M2-27-16 2026-10-20 IncomingWebhookCreated.url is absolute: server.public_url joined with /api/v1/hooks/{webhook_id}/{token}; Bearer senders drop the last segment and send the token in the Authorization header. The schema says "Full URL", and the sender needs nothing else to post.
D-M2-27-17 2026-10-20 postIncomingWebhookWithBearer has no operation-level security and so carries the root security; of the root schemes only bearerAuth applies to it, with the whi token, which the token check verifies, not the authenticator (httpserver.Operation.AnonymousCredentials). The route generator records only public or the root security (M1-17, M1-19).
D-M2-27-18 2026-10-20 The internal listener serves the two post operations through a chain of its own with empty trusted proxies (the client IP is the peer address, R-SE-09) and its own rate limiter, so rl.webhook_in and rl.public_auth count per listener; nothing else of the API is mounted there. Place: wire/incoming.go. Forwarded headers are never read on the internal listener (M1-21 design note 1), and the public chain must not serve that mux.
D-M2-27-19 2026-10-20 Two additive chat changes: the port chat.ThreadsByParent (ThreadByParentMessage) for the replay of a text-channel post with title, and chat.TriggerChannel.MainThreadID as the target of a post. The replay must answer the thread of the first answer; the webhook actor holds only messages:write, so channel.read and getChannel are closed to it.
D-M2-27-20 2026-10-20 The token check is the apps middleware TokenStage at httpserver.StageWebhookToken. For operations with AnonymousCredentials, StageAuthenticate runs neither the credentials precheck nor the authenticator, and the token stage takes the rl.public_auth token through ratelimit.Limiter.FailedCredentialGuard (an empty bucket gives 429 rate_limited before the check). A declared Content-Length above an operation's Operation.BodyLimit (webhook_in.max_body_bytes, named by BodyLimitKey) is refused at StageBody instead of StageRoute. A failed token is a failed credential (02 9.1, 05 3); the token check answers before the size (11 8.4: token before size).
D-M2-27-21 2026-10-20 The archive subscriber of apps (apps/app.ArchiveSubscriber, registered after the runs acceptor) uses ChannelArchived.ArchivedAt as revoked_at. The revocation carries the time of the archive it follows.
D-M2-27-22 2026-10-22 On main the table is 00055_apps_incoming_webhooks.sql, the next free number after 00054_instance_restore_identity.sql of M7-04; content and checksum are those of the build. goose refuses two migrations with the same version, and migration numbers follow the order of landing on main.
D-M2-27-23 2026-10-20 ParseAlertmanager checks the schema rules again (version, the status enums, at least one alert, truncatedAlerts not negative) with the codes of the schema layer (invalid_enum, too_few_items, too_small, required); an empty alertname, severity, summary or description counts as missing, and the last item - and <k> more alerts is always plural. The domain function holds its rules without relying on the HTTP layer, with the same codes a client sees from StageValidate; an empty value would render an empty field.
D-M2-27-24 2026-10-20 The integration and end-to-end tests of the brief run in services/server/test/integrations on the shared harness (the assembled server of internal/wire, role all, PostgreSQL 18 through pgtest, the matrix world built through the public API, the standard library HTTP client as sender), not in per-package worlds of internal/apps; the authz check of the webhook actor (TestWebhookActor_ScopesLimitedToMessagesWrite) runs in internal/apps/app on the apps world. TestPostIncomingWebhook_ChannelPostCondition is not built: the post-condition (the written message lies in the webhook's channel, else rollback and 404) has no seam to move a thread between the target choice and the write without a test hook in production code. One world through the real chain proves the order of checks, the uniform 404 and the listener behaviour where they happen; the post-condition stays in the code and is held by review.
D-M2-27-25 2026-10-22 The ticket was built on an integration branch whose spec chapters had German file names; the port to main writes the same changes into the English chapters (02-architecture.md, 06-api.md, 11-extensions.md, 16-operations.md). The default of webhook_in.formats_enabled is written ["native", "alertmanager"] in the chapter table, the form the configuration reference compares with the schema. main holds the English spec, and the rulings name its chapters.
D-M2-27-26 2026-10-22 A matched route writes a {token} segment of its template into Problem.instance as %7Btoken%7D, the percent-encoded template segment, replacing "kept as {token}" of M1-18/7. In the contract test G7 the two post operations of the incoming webhooks answer the uniform 404 not_found in SEC-T06, because the pass holds no webhook token and the token check runs before the body is read; their body rules are tested in test/integrations. The 404 equality check compares answers for well-formed IDs only. Place: platform/httpserver/problem.go, tools/contract/live_contract.py. The schema Problem declares instance as uri-reference, which has no braces, so G7 rejected the literal; 11 8.4 orders the token check before size and content.