Inbound Webhooks¶
An external system can post events to the platform. Each webhook source has
its own address, POST /hooks/{source}, and its own way of proving a request
came from the sender. The platform writes what it receives to object storage
within about a second and answers 202 only after that write. The events can
be queried through Trino moments after they are acknowledged, and each window
of them, an hour unless the source sets a shorter one, is compacted into one
Parquet file.
Ingestion does not depend on whatever reads the data. A source receives, compacts and applies retention to its events whether or not anything reads them, and readers come and go without the source changing: a script that reads yesterday once a night, a script that picks up new events every minute, an analyst running a query.
A source can also take its events from a mailbox instead of from requests: the platform checks the mailbox and files each message from an allowed sender as one event, its attachments as resources the event names. See A mailbox as a source. Everything after that, the table, compaction, retention and alerts, is the same for both.
Events never go into the platform database. The database holds the source definitions and the record of which windows are compacted.
What a source needs¶
A source writes everything into the managed-resources bucket and creates its
table in the scratch catalog of a Trino connection. That catalog has to read the
managed-resources store and allow register_partition, and the connection's
Trino user needs a procedures rule to call the catalog's partition procedures;
the complete setup is on Scratch Catalog. Creating a
source checks all three and refuses the source, naming the setting to add, when
one is missing. Testing the connection
(POST /api/v1/admin/connection-instances/trino/{name}/test)
runs the same checks before any source exists.
Creating a source¶
Administrators manage sources in the portal under Admin > Webhooks, and over
the admin API at /api/v1/admin/webhooks/sources:
curl -X POST https://platform.example.com/api/v1/admin/webhooks/sources \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{
"name": "email-events",
"connection": "scratch",
"auth": {
"mode": "hmac",
"secret": "whsec_example",
"signature_header": "X-Signature",
"prefix": "sha256=",
"timestamp_header": "X-Timestamp",
"signed": "timestamp.body"
},
"config": {
"split": "$",
"event_id_path": "$.event_id",
"event_type_path": "$.event",
"key_path": "$.email",
"persona": "marketing"
}
}'
The sender is then given https://platform.example.com/hooks/email-events.
| Field | Meaning |
|---|---|
name |
The path segment: /hooks/{name}. Lowercase letters, digits and -, starting with a letter. The table is webhook_{name}, with each - as _. |
connection |
The Trino connection whose scratch catalog holds the table. |
enabled |
A disabled source answers 404, the same answer as a name that is not a source. Default true. |
auth |
How a request proves it came from the sender. See Authentication. |
config.handshake |
The sender setup check the source answers: none, cloudevents, challenge_echo, zoom, discord or graph_validation. A preset sets it. See Setup checks. |
config.challenge_field |
The body field a challenge_echo check carries. Default challenge. |
config.max_body_bytes |
Default 1 MiB. A larger body gets 413 and is not stored. |
config.split |
Unset: the body is one event. A JSON path to an array: each element is its own event, for senders that batch. $ is a body that is itself an array. |
config.event_id_path |
JSON path to the sender's event id, used to remove duplicates. Unset or absent from an event: the SHA-256 of the event's JSON (keys sorted) is used. |
config.event_type_path |
JSON path to the event type, stored as a column. |
config.key_path |
JSON path to the entity the event is about (a contact id, an email address), stored as a column so a reader can keep only the latest event per key before it spends API quota. |
config.persona |
The persona whose members see the source's compacted files in Resources and search. Unset: the administrator persona (admin.persona), so only administrators. Fixed when the source is created. |
config.compact_every_minutes |
The length of the window events are partitioned and compacted by. Default 60. One of 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30 or 60, so windows start on the hour. See Compaction. |
config.flush_max_events |
Default 5,000. |
config.flush_max_interval_ms |
Default 1,000. |
config.flush_max_bytes |
Default 8 MiB. |
config.buffer_limit |
Events one replica holds in memory for the source before it answers 503. Default 50,000. |
config.rate_limit_per_minute, config.rate_limit_burst |
Optional. The limit is on the source, not on a client address, and each replica holds it for itself: with N replicas behind a load balancer, a sender can be admitted up to N times the rate and the burst. Size it as what one replica should take for this source, as buffer_limit is. |
config.raw_retention_days |
How long a compacted window's raw segments are kept. Default 1 (7 before #2134). The segments are a safety margin only: a compacted window's events are all in its Parquet file, and a recompaction reads that file. Every day kept is objects the raw table and retention list. |
config.compacted_retention_days |
How long a compacted window is kept. Default 400; 0 keeps it forever. |
config.index |
How search indexes each compacted window's file: full, metadata or none. Absent leaves it to the deployment's resources.index rules, which can match these files with origin: [webhook] (Search indexing). Applies from the next compaction on. The portal's source editor shows it as Search. |
A JSON path is $ for the document, .name or ['name'] for a member, and
[n] for an array element: $.data.events, $.contact['id'], $[0].id.
[*], every element of an array, is read only by auth.token_path.
The name, the connection and the persona do not change after creation. The table was created on that connection under that name, and the windows already written are in that persona's library.
Authentication¶
Presets¶
auth.preset names a sender, and the platform fills the mode, the headers, the
signed bytes, the encodings and the setup check that sender uses. A setting the
source sets itself is kept, so tolerance_seconds can be changed under a
preset; the preset's mode always applies. The portal's source editor offers the
presets under Sender.
auth.preset |
The sender | The secret | What is checked | Setup check |
|---|---|---|---|---|
standard_webhooks |
A Standard Webhooks sender | The signing secret (whsec_ and base64) |
webhook-signature: v1,<base64> over {id}.{timestamp}.{body} |
|
github |
GitHub | The webhook secret | X-Hub-Signature-256: sha256=<hex> over the body |
|
stripe |
Stripe | The endpoint's signing secret | Stripe-Signature: t=...,v1=<hex> over {timestamp}.{body} |
|
platform |
Another deployment's hmac connection | The signing secret | X-Signature: sha256=<hex> over {timestamp}.{body}, X-Timestamp |
|
slack |
Slack | The app's signing secret | X-Slack-Signature: v0=<hex> over v0:{timestamp}:{body}, X-Slack-Request-Timestamp |
challenge_echo |
zoom |
Zoom | The app's secret token | x-zm-signature: v0=<hex> over v0:{timestamp}:{body}, x-zm-request-timestamp |
zoom |
mattermost |
A Mattermost outgoing webhook | The outgoing webhook's token | The token field of the form or JSON body |
|
teams |
A Microsoft Teams outgoing webhook | The security token, as Teams shows it (base64) | Authorization: HMAC <base64> over the body, keyed by the decoded token |
|
discord |
Discord interactions | The application's public key, in hex | Ed25519 X-Signature-Ed25519 over {timestamp}{body}, X-Signature-Timestamp |
discord |
monday |
monday.com, for a webhook created with an integration app token | The app's signing secret | An HS256 JWT in Authorization, its exp honored |
challenge_echo |
shopify |
Shopify | The app's client secret | X-Shopify-Hmac-SHA256: <base64> over the body |
|
linear |
Linear | The webhook's signing secret | Linear-Signature: <hex> over the body, and webhookTimestamp (milliseconds) in the body within 60 seconds |
|
gitlab |
GitLab, with a signing token | The signing token (whsec_ and base64) |
As standard_webhooks |
|
jira |
Jira Cloud | The webhook's secret | X-Hub-Signature: sha256=<hex> over the body |
|
microsoft_graph |
Microsoft Graph change and lifecycle notifications | The clientState the subscription was created with |
Every item of value[] carries the clientState |
graph_validation |
A Graph source usually also sets config.split: "$.value", so each
notification is its own event. A sender that expects a reply posted back into a
chat (a Slack slash command, a Mattermost or Teams outgoing webhook, a Discord
interaction that needs a response) is an endpoint's
job: a source stores what it is sent and answers 202.
The requests were built for the platform's tests from each sender's published documentation; the first delivery from a real account is what confirms a sender sends what its documentation says.
Modes¶
No source stores its secret in its events (#2353, #2354): once a request is
verified, every value in its body equal to the source's secret (or the
previous secret, during a rotation's overlap) is removed, at any depth, before
its events, ids and content hashes are made: an object member is deleted, an
array element becomes null. That is the token a body_token sender puts at
token_path, and a token any other sender repeats in its body. So no stored
event, compacted file or event run holds it, and a delivery verified before
and after a rotation is the same event. A value that is not the secret is the
sender's own and is kept. An ed25519 source's secret is the sender's public
key and is left in place.
Every mode's secret is encrypted when stored, and a deployment with no
ENCRYPTION_KEY refuses a save that writes one (#2362), as it refuses a
form's sensitive fields: creating a source with a secret, or rotating it, is
answered 400 naming ENCRYPTION_KEY. An ed25519 source's secret is the
sender's public key and is saved either way. A source stored before the key
was removed keeps working, and a save that leaves its secret as it is
succeeds.
auth.mode |
What a request carries | Settings |
|---|---|---|
hmac |
A signature of the body in a header | algorithm (sha256 default, sha1, sha512), signature_header, encoding (hex default, base64), secret_encoding (empty for the secret as written, or base64 for the bytes it decodes to), prefix (such as sha256=), signed (see below), the timestamp from one of timestamp_header, timestamp_path or a header_pattern naming {timestamp}, with tolerance_seconds (default 300), id_header, and the signature header's layout: header_format (empty, or stripe for t=<timestamp>,v1=<signature> with the timestamp inside the signature header) or header_pattern. |
ed25519 |
An Ed25519 signature in a header | The secret is the sender's public key, 32 bytes in hex. signature_header, encoding, signed, timestamp_header or timestamp_path, tolerance_seconds. |
header_token |
The secret itself in a header | header |
body_token |
The secret itself in the body | token_path: where in a JSON body, or which field of a form body. A path through [*] (value[*].clientState) requires every element to carry the token; one element without it refuses the whole request. The token is removed from what is stored, as every mode's secret is (see below). |
jwt |
An HS256 JSON Web Token in a header, with or without Bearer |
header (default Authorization). The token is verified with the secret; its exp is honored when present. |
basic |
HTTP Basic credentials, the secret as the password | username |
path_token |
The secret as a further path segment, /hooks/{name}/{secret}, for a sender that can set nothing but a URL |
none |
Upgrading from v1.152.1: a body_token source (the mattermost and
microsoft_graph presets) stored its token in every event it received on that
release, and any source stored a secret its sender repeated in the body, in
the raw segments, the compacted files and the script_runs.event of each
event run. Rotate the secret of each such source, with no overlap, and
change it at the sender; the copies already stored then no longer authenticate
anything.
signed is body (the default), timestamp.body, id.timestamp.body, or a
template over {id}, {timestamp} and {body} with the text between them:
v0:{timestamp}:{body} is Slack's, {timestamp}{body} Discord's. The three
named forms are the templates {body}, {timestamp}.{body} and
{id}.{timestamp}.{body}. {body} is required, and each placeholder appears
at most once.
header_pattern lays out a signature header that carries more than a prefix
and a signature, over {signature} (required) and {timestamp} (optional),
with literal text between them: {timestamp}|{signature}, or
HMAC {signature}. It replaces prefix and header_format. A pattern naming
any other placeholder, naming one twice, or putting two placeholders side by
side is refused when the source is saved. A sender that writes
<timestamp>|<signature> and signs <body>|<timestamp> is configuration:
{
"mode": "hmac",
"secret": "...",
"algorithm": "sha256",
"encoding": "hex",
"signature_header": "Verkada-Signature",
"header_pattern": "{timestamp}|{signature}",
"signed": "{body}|{timestamp}"
}
The template and the pattern are compiled when the source is loaded, and the signed bytes are written into the HMAC piece by piece, so the body is never copied; the timestamp window is checked before any hashing.
With a timestamp read from a header, from the body, or from the signature
header, a request whose
timestamp is further than the tolerance from the platform's clock is refused
even when its signature is right, which is what makes a replayed request fail.
A missing timestamp is refused naming where it was expected: "the timestamp
header is missing" for a header, and for a body field (Linear's
webhookTimestamp) "the timestamp field is missing from the body: expected at
whsec_ and base64) is used as that standard reads it when signed is
id.timestamp.body.
The secret is encrypted at rest and never returned. A view of the source says whether one is set. Every comparison of a secret is constant-time.
Rotating the secret. Send a new auth.secret with
rotation_overlap_seconds. The previous secret is accepted alongside the new
one until the overlap ends, so the sender can be moved to the new one with no
window in which its requests fail. An update without auth.secret keeps the
stored secret.
Setup checks¶
Several senders check an address before they deliver to it. A source answers
the one config.handshake names. A check is answered 200, counted under the
outcome setup_check (neither accepted nor refused), and writes nothing to the
spool or the tables.
handshake |
The check | Answered | Verified first |
|---|---|---|---|
cloudevents |
OPTIONS carrying WebHook-Request-Origin (the CloudEvents abuse-protection handshake) |
WebHook-Allowed-Origin echoing the origin, and WebHook-Allowed-Rate when the source has a rate limit |
no |
challenge_echo |
A JSON body whose challenge field (challenge_field) is a string and which carries nothing else but token and type: url_verification (Slack's url_verification, monday.com's challenge) |
{"challenge": "<the challenge>"} |
no: monday.com does not sign its challenge |
zoom |
{"event": "endpoint.url_validation", "payload": {"plainToken": ...}} |
{"plainToken": ..., "encryptedToken": <hex HMAC-SHA256 of the plain token under the secret>} |
yes: answered unverified, it would hand anyone the HMAC of any text, which forges Zoom signatures |
discord |
A PING, {"type": 1} |
{"type": 1}; an unsigned PING gets 401, which Discord's own check expects |
yes |
graph_validation |
A request carrying ?validationToken= (a subscription's notification URL and its lifecycle notification URL alike) |
200 text/plain with the decoded token, never inside HTML or JSON |
no: the validation carries no clientState |
A source without cloudevents answers OPTIONS with 405.
A challenge, plain token or validation token holding <, >, &, ' or " is
not answered as a check: no answer writes such a value back. The senders'
values are opaque text without those characters.
Sending to a webhook¶
The platform signs outbound requests too. An api connection with
auth_mode: hmac signs every request with the
same settings, under hmac_ names, and the two are built on one
implementation, so a script can deliver to Standard Webhooks, GitHub, Stripe
and other HMAC receivers, and to another deployment's sources. To deliver to a
source configured as in Creating a source, give the
connection hmac_preset: platform and the source's secret:
{
"base_url": "https://other-platform.example.com",
"auth_mode": "hmac",
"credential": "whsec_example",
"hmac_preset": "platform"
}
A script then posts with
platform.call("api_invoke_endpoint", {"connection": "...", "method": "POST", "path": "/hooks/email-events", "body": {...}})
and is answered 202. A notification channel of the
webhook kind with format: json delivers through such a connection, signed.
A form's source¶
A source with config.input: form holds a form's accepted submissions, one
event each (#2178). The platform makes it when a form script is bound to an
endpoint (manage_script endpoint_set), named form-<endpoint>, and it is
never posted to: /hooks/form-<endpoint> answers 404, and the create route
refuses input: form. config.form names the script and endpoint it collects
for and the personas a script may decrypt the form's sensitive fields as
(decrypt_personas); it takes no auth, and its event id, type and key are the
platform's, so no field (a sensitive one included) names them. An
administrator changes its windows, retention and search setting like any
source's. See Endpoints.
A mailbox as a source¶
A source with config.input: imap is not posted to. The platform signs in to a
mailbox over IMAP, reads the folders the source names, and files each new
message from an allowed sender as one event (#2247). A dedicated address, for
example [email protected], that people and systems send or forward
mail to (meeting summaries, vendor notices, reports a system emails out) becomes
a table and a folder of files, with no person or model reading the mailbox.
curl -X POST https://platform.example.com/api/v1/admin/webhooks/sources \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{
"name": "meeting-summaries",
"connection": "scratch",
"auth": { "mode": "imap", "username": "[email protected]", "secret": "app-password" },
"config": {
"input": "imap",
"imap": {
"host": "imap.company.example",
"folders": ["INBOX"],
"allow_senders": ["[email protected]"],
"trusted_authserv_id": "mx.company.example"
},
"persona": "analyst"
}
}'
The mailbox's login is the source's auth: mode imap, the username, and the
password as secret, stored encrypted and never read back like every source
secret. Saving the source signs in and opens each folder; a mailbox the platform
cannot read is refused with the server's answer rather than failing every check
after.
| Field | Meaning |
|---|---|
config.input |
http (the default), imap or postgres (below); form is a form's source, which the platform makes. Fixed when the source is created. |
config.imap.host, port, tls |
The server. tls is tls (IMAPS, port 993, the default) or starttls (port 143). Plain IMAP is refused: the password would cross the network in the clear. |
config.imap.tls_ca_bundle_pem |
PEM certificates to trust, for a server whose certificate a private CA signed. |
config.imap.folders |
The folders read. Required; there is no all-folders default. Each keeps its own place. |
config.imap.allow_senders |
Required. Addresses and @domain entries (a domain entry does not admit its subdomains) whose mail becomes an event. Mail from anyone else is counted and never stored. |
config.imap.require_auth_pass |
Default true: an allowed sender's message is kept only when the receiving server recorded an SPF, DKIM or DMARC pass aligned with the From domain. |
config.imap.trusted_authserv_id |
Required while require_auth_pass is on. The id the receiving server writes at the start of its Authentication-Results header. Only the topmost header carrying it is read, since a sender can write that header too. |
config.imap.poll_seconds |
How often the mailbox is checked. Default 60, between 15 and 86,400. |
config.imap.max_message_bytes |
Default 10 MiB. A larger message is filed with its headers and no body or attachments, and says why. |
config.imap.max_attachment_bytes |
Default 25 MiB. A larger attachment is listed on the event and not saved. |
The request settings (handshake, split, the JSON paths, max_body_bytes,
the flush and buffer settings and the rate limit) do not apply to a mailbox
source. compact_every_minutes, the retention settings, index and persona
do.
What is read and what is kept¶
Each folder is opened read-only (EXAMINE) and each message fetched with
BODY.PEEK: the platform never marks a message seen, moves, deletes or sends
anything. A folder's place is the highest UID read and the folder's
UIDVALIDITY; a server that renumbers the folder is read again from the start,
and the event id keeps those messages from being filed twice once a window is
compacted. One check of one folder files at most 200 messages, oldest first; the
rest wait for the next. A check that fails leaves the folder's place where it
was. Each source is checked by one replica at a time, under an advisory lock.
A message from a sender allow_senders does not name, or one without the
required authentication, is counted on its folder and nothing else of it is
kept: not its subject, its sender or its size.
Each kept message is one event, written to the source's raw segments exactly as the HTTP receiver writes a request's events:
| Column | For a message |
|---|---|
received_at |
When the mail server received it |
event_id |
Its Message-ID, or imap:{folder}:{uidvalidity}:{uid} for a message with none |
event_type |
mail |
key |
The folder |
payload |
The message, below |
The payload holds what the platform read of the message (folder, uid,
uid_validity, message_id, in_reply_to, references, date, arrived,
from_address, sender_auth, size, body_format, body_skipped,
attachments) and, under external, what its sender wrote (from, to, cc,
subject, body) beside a notice saying so. Sender-written text has
invisible, bidirectional and control characters removed; an HTML body is reduced
to its visible text, hidden elements dropped. A reader that hands external to a model should treat it as what a
stranger wrote, which is what it is.
SELECT received_at,
json_extract_scalar(payload, '$.external.subject') AS subject,
json_extract_scalar(payload, '$.external.body') AS body
FROM webhook_meeting_summaries
WHERE dt = '2026-10-21'
Attachments¶
Each attachment is saved as a managed resource in the source's library (the
persona's, or the administrators'), at
tagged webhook, mail and the source's name, and indexed for search by the
source's index setting. The event's attachments list names each one with its
uri and resource_id, or with skipped saying why it was not saved. Reading
the same message again replaces the file with a new version rather than adding a
second one. Attachments are not removed by compacted_retention_days: they stay
until someone deletes them or the source is deleted, which removes the whole
attachments folder with the source's tables and windows.
A message the mail client cannot read¶
Some servers describe a message's structure in a form the IMAP protocol does not allow, such as NIL where a list of parts is required. When a batch of headers fails that way, the platform reconnects and fetches each message alone, and fetches a message that still fails without its structure, reading its attachments from the message itself. A message that cannot be read either way is skipped: the folder's place moves past it, so it is never retried, and it is recorded once in the source's rejections list with outcome unreadable, its folder and UID, and what the client could not read, which names protocol tokens and nothing of the message. It is counted in webhook_requests_total{outcome="unreadable"}. The other messages in the folder are filed as usual. A failure in one folder never stops the others: a connection the failure closed is opened again before the next folder.
The source's page¶
A mailbox source's page shows the server, the login, the allowed senders and, for
each folder, the messages stored, the ones withheld because the sender is not
allowed and because the sender was not authenticated, when it was last checked
and the last error. GET /api/v1/admin/webhooks/sources/{name} returns the same
under imap, one entry per folder checked.
An application database as a source¶
A source with config.input: postgres is not posted to either. It reads an
application database and files what it reads as events (#2408),
which land, compact and run the scripts naming the source like every other
source's.
curl -X POST https://platform.example.com/api/v1/admin/webhooks/sources \
-H "Authorization: Bearer $ADMIN_KEY" -H "Content-Type: application/json" \
-d '{
"name": "orders-outbox",
"connection": "scratch",
"config": {
"input": "postgres",
"postgres": { "connection": "orders", "mode": "outbox", "table": "public.outbox" }
}
}'
It takes no auth: nothing is posted to it, and it reads the database as the
connection's own role, on a connection of its own outside the pool. Saving the
source checks the database: in mode outbox, that the outbox exists and the
role may SELECT and DELETE on it; in mode notify, that it can LISTEN on
each channel.
| Mode | What becomes an event | Delivery |
|---|---|---|
outbox |
Each row of an outbox table, which manage_schema's outbox template creates with a trigger on each table it captures |
Each row is filed and then deleted in the same transaction. A notification wakes the source (unless the template was added with notify: false, for a table many sessions write at once), and a poll every poll_seconds drains the rows whose notification no one heard or that sent none, so rows written while no replica listens are filed once a replica listens again. A commit that fails after the rows were filed files them again under the same event id, which compaction keeps once |
notify |
Each notification sent with NOTIFY or pg_notify on the named channels |
At most once: PostgreSQL delivers a notification only to a session listening when it is sent, and one sent while no replica listens is lost |
| Field | Meaning |
|---|---|
config.postgres.connection |
The application database. Required. |
config.postgres.mode |
outbox or notify. Required. |
config.postgres.table |
outbox: the outbox as schema.table, in lowercase. Its channel is the same name. |
config.postgres.channels |
notify: between 1 and 20 channels. |
config.postgres.poll_seconds |
outbox: how often the outbox is drained with no notification. Default 30, between 1 and 3,600. |
config.postgres.max_payload_bytes |
Default 64 KiB, at most 1 MiB. A larger notification payload or outbox row is refused, listed under the source's rejections with outcome too_large, and never stored; a refused outbox row is deleted with the rest. |
Each event's payload is what was read in an envelope saying where it came from,
so event_id_path, event_type_path, key_path and split read the same
names for every source of the input:
| Mode | Envelope | Event id when event_id_path is unset |
|---|---|---|
outbox |
{"outbox_id", "table", "op", "row", "created_at"}, row being the row as JSON |
outbox-<id> |
notify |
{"channel", "payload", "pid"}, payload being the notification's JSON, or its text as a string |
One per notification, so two with one payload are two events |
One replica listens per source, under an advisory lock; another takes over on
its next pass, within five seconds of the lock being free. A payload is written
by whoever can write the table or call pg_notify: it is data, bounded in size,
and never read as configuration. A connection through PgBouncer in transaction
mode cannot listen; the connection test says so.
The source's page shows a Database card: the connection, the mode, the channels or table, the replica listening and since when, the last event, how many events were filed and refused, and the last failure.
What a sender is answered¶
Each request is handled in this order, and stops at the first refusal:
| Step | Refusal |
|---|---|
| The source exists and is enabled | 404, the same body for both |
OPTIONS on a cloudevents source is answered here |
|
A graph_validation check is answered here |
|
The body is at most max_body_bytes |
413 |
A challenge_echo check is answered here |
|
| The request authenticates | 401; nothing is stored |
A zoom or discord check is answered here |
|
| The rate limit | 429, Retry-After |
The body is JSON (for a form post, its payload field is, or, with no payload field, the form's fields are the event), and split names an array |
400 |
The request holds no more events than buffer_limit |
413; a batch the buffer can never hold is not worth retrying |
| The events fit in the source's buffer | 503, Retry-After: 1 |
| The segment holding the events is written to object storage | 503, Retry-After: 5 |
202, {"accepted": <events>} |
A 202 means the events are in object storage. A replica that stops while it
holds events it has not written answered nobody for them, so their senders send
them again. Backpressure is the sender's own retry queue, told to wait: the
platform keeps no backlog beyond each source's buffer limit.
A local spool¶
By default a 202 waits for object storage, so a sender's wait is object
storage's latency at that moment. Many senders give up within a few seconds,
and an object store slows down for ordinary reasons: a bulk copy or a backup
reading the same cluster, healing, a slow credential refresh. A receiver given
a spool directory (webhooks.receiver.spool.dir, #2121) answers once the events
are synced to its own disk instead, and drains them to object storage behind
the answer:
| Step | Refusal |
|---|---|
... the steps above, through buffer_limit ... |
|
The spool holds less than max_bytes, and its oldest event waiting is younger than max_age |
503, Retry-After: 5, outcome spool_full |
| The events are appended to the spool and synced | 503, Retry-After: 5, outcome write_failed |
202, {"accepted": <events>} |
A 202 then means the events are on this replica's disk, synced. Each source
has one file being appended to, sealed by the same rules a buffer is flushed by
(flush_max_events, flush_max_bytes, flush_max_interval_ms), and a drainer
writes each sealed file to object storage through the same windows and layout,
retrying with backoff (one second, doubling, at most a minute) until the write
succeeds, then removes the file. A file is written under its own name, so a
file drained again after a crash, between its write and its removal, overwrites
what the first drain wrote instead of adding to it: every acknowledged event
lands once. The spool holds the events the request was read into, after it
authenticated, not the raw request, so a drain never checks a signature again:
a timestamped signature past its tolerance, or a secret rotated since, cannot
refuse a request that was already acknowledged.
A replica that is stopped seals what it holds and drains for as long as
write_timeout allows; one that is killed, or stopped while object storage
does not answer, drains what it holds when it starts again, before it serves
its first request. A record whose sync did not finish was never acknowledged,
and the next start cuts it off. Past max_bytes or max_age the receiver
answers 503 with Retry-After rather than accept a request it cannot keep.
A source deleted while its events wait in a spool has had its tables and
objects removed with it, so its spooled file is discarded at its next drain
with a WARN line, webhooks: spooled events of a deleted source discarded,
rather than failing every drain. The Webhooks page shows each replica's spool.
How long a sender waits¶
A sender should expect its answer within the source's flush interval
(flush_max_interval_ms, one second by default) plus one write to object
storage and one database write that records the segment against its window;
with a spool, within one write to local disk.
The first segment a replica writes into a window records the window once more
before the write. Nothing else is done before the answer: registering a window's
partition with the query engine runs on the replica's own loop. For a source
that wrote into the current window, the loop registers the next one a minute
before it starts (half the window ahead for a window shorter than two minutes),
so its first event is queryable as soon as it is acknowledged. A window nobody
saw coming, such as a new source's first, is registered right after its first
202, so its events become queryable moments later. A registration that fails
is retried every 15 seconds, never while a sender waits, and the compactor's
partition sync, every 10 minutes, registers any window still missing. The source's page
shows how long requests waited (see The source's page).
A sender that gives up¶
A sender whose own timeout passes before its answer disconnects. The platform
counts that request sender_gone, not write_failed, because its write goes
on and usually stores the events. The request is logged at WARN
(webhooks: request not accepted, outcome=sender_gone) and kept in the
rejections with its event ids and body hash. When the write finishes, a second
WARN line, webhooks: write for a disconnected sender finished, says whether
the events were stored (stored=true or stored=false, with the error) and how
long each stage took. A sender that retries delivers the same events again;
compaction removes the duplicate.
What is logged¶
Every request not answered 202 is logged at WARN as webhooks: request not
accepted, with the source, the replica, the outcome and reason, how long since
the request arrived (elapsed_ms), and the number of events. Once the body has
been read into events the line also names the windows they fall in, the
SHA-256 of the body (request_hash) and the ids of the first 20 events
(event_ids). A request whose write ran adds how long its events waited in the
buffer (buffer_ms), recorded their window (mark_ms) and took to write
(put_ms). A write_failed line carries the storage error, which the sender's
answer never does.
A source created on any replica is served by every replica from its first
request. A replica asked for a name it does not hold reads the source list
again before answering 404, and every such request waits for a read that
started after it arrived. Those reads are shared and at least a second apart,
so a caller inventing names costs the database at most one read a second, and
waits up to a second for its 404 (#2108).
The rate limit, the buffer and the 503 they answer are each replica's own.
They protect the replica holding the source's buffer, not the sender's quota,
so they take no shared state on the path a request takes. A sender spread over
N replicas can be admitted up to N times the configured rate and burst.
Delivery is at least once, because a sender retries anything it did not see acknowledged. Duplicates are removed at compaction.
A form post, application/x-www-form-urlencoded, carries its JSON in a field
named payload. The signature is checked over the raw body as sent.
Where events land¶
A replica collects a source's events for up to flush_max_interval_ms,
flush_max_events or flush_max_bytes, whichever comes first, and writes them
as one gzipped JSON-lines segment, one per window the events were received
in. A window is compact_every_minutes long and named by the UTC date, hour and
minute it starts at:
{managed-resources bucket}/webhooks/{source}/raw/dt=YYYY-MM-DD/hour=HH/minute=MM/{replica}-{start}-{sequence}.jsonl.gz
Every replica writes its own segments, named by itself, so replicas never coordinate on a write.
Reading the open window¶
Until a window is compacted, a reader reads it from its raw segments, one
object each. A busy source writes thousands an hour (70 events a second written
every 200 ms by three replicas is about 3,700 segments an hour), so a query
over a recent window costs one object read per segment. A source whose readers
feel that cost takes a shorter compact_every_minutes: each window is
compacted sooner and holds fewer segments.
The view keeps one event per event id in each window it reads from the raw side: the earliest received, then the earliest written, which is the event compaction keeps (#2140). A sender's retry is in the raw segments twice until compaction, and the view returns it once, so a window answers the same before and after it is compacted.
Raw segments are never deleted while their window is open. A query lists a
window's directory and then reads what it listed, so a file deleted between
the two fails the query. v1.146.1 merged the open window's segments into one
object and deleted them afterwards (#2132), which failed queries that listed
them and, until the delete, returned each merged event twice; that merge is
gone. The objects it wrote stay until the window's raw files are released, the
view reads each event in them once, and the hidden .rollup-{time}.keys list
beside each is skipped by the query engine and by compaction.
Compaction¶
A window is compacted once it has ended and webhooks.compactor.grace has
passed (default two minutes, for segments still being written). A window events
are still landing in is never compacted. One replica compacts it:
- It reads every segment of the window, and the window's previous Parquet file if there is one.
- It keeps one event per event id, the earliest received.
- It writes the result as the window's Parquet file, a managed resource in the
folder
webhooks/{source}/{dt}named{HH}-{MM}.parquet, in the library of the source's persona. A later compaction of the window is a new version of the same resource. - It registers the window's partition at that resource's directory.
The window length is a trade between how soon duplicates are removed and how many files a source keeps. Every event is queryable as soon as it is acknowledged whatever the setting, so a short window is only worth it when readers need duplicates removed, or the Parquet file, within minutes. An hour gives 24 files a day; a minute gives 1,440.
Changing compact_every_minutes takes effect for events received after the
change. A window that already holds segments keeps the longer of its old and
new lengths, so raising the setting never compacts a window early.
The file opens in the portal's Parquet viewer and is found by search like any
resource the reader can see, with the source's table attached. Its contents are
not indexed for search.
A segment written for a window after it was compacted, by a replica that was slow or that recovered, makes the window owed another compaction, which starts again from everything the window holds. Rewriting a window is idempotent. A window that fails to compact is retried with a growing delay and shown as failing on the source's page.
The table¶
Readers name one table, webhook_{source}, in the scratch schema of the
source's connection. It is a view over two tables that are storage, not
something a reader chooses between:
webhook_{source}_compacted: Parquet, one partition per compacted window.webhook_{source}_raw: the raw segments.
For each window, the view reads the compacted table once the window's partition
is registered, and the raw segments until then. An event is queryable once its
window's raw partition is registered: ahead of the window for a source receiving
steadily, otherwise moments after the window's first 202 (see
How long a sender waits). No window is missing or
served twice. Rows read
from the raw side have not had duplicates removed yet.
| Column | Type |
|---|---|
received_at |
timestamp(6), UTC |
landed_at |
timestamp(6), UTC: when the segment holding the event was written |
event_id |
varchar |
event_type |
varchar |
key |
varchar |
content_hash |
varchar: SHA-256 of the event's JSON |
replica |
varchar: the replica that received it |
payload |
varchar: the event's JSON |
dt |
varchar: YYYY-MM-DD |
hour |
varchar: HH |
minute |
varchar: MM, the minute the event's window starts at; 00 for a source compacting by the hour |
The schema is fixed. A sender adding a field never breaks anything, and a
reader takes what it needs with json_extract_scalar(payload, '$.field').
The table is recorded as a registered table, so it is listed under Scratch Tables. It is removed with its source, not unregistered on its own. Who can query it is decided by who is granted the connection, as for every table in the scratch schema.
Reading the data¶
The receiving side knows nothing of its readers. A managed script reads the view like any other table, on whatever schedule it has.
The examples name the view without its catalog and schema. That resolves
through the connection's catalog and schema settings, which are the Trino
session's defaults for every query on it; on a connection without them, write
the full name, <catalog>.<schema>.webhook_email_events.
Once a day. Read yesterday's events:
day = run.params["day"]
rows = platform.query(connection="scratch",
sql="SELECT event_type, count(*) AS n FROM webhook_email_events WHERE dt = :day GROUP BY 1",
params={"day": day})["rows"]
Every few minutes. Keep a landed_at watermark in the script's
state, read from
a little before it, and remove duplicates on event_id:
since = run.state.get("landed_through", "1970-01-01 00:00:00.000000")
rows = platform.query(connection="scratch", sql="""
SELECT event_id, landed_at, payload FROM webhook_email_events
WHERE landed_at > CAST(:since AS timestamp(6)) - INTERVAL '5' MINUTE
ORDER BY landed_at""", params={"since": since})["rows"]
seen = set(run.state.get("recent_ids", []))
new = [r for r in rows if r["event_id"] not in seen]
# ... process new ...
if rows:
platform.save_state({
"landed_through": rows[-1]["landed_at"],
"recent_ids": [r["event_id"] for r in rows],
})
State is saved only when a run succeeds, so a run that fails reads the same
events again. The overlap covers clock differences between replicas and a
segment written late; the event_id check makes reading an event twice
harmless. A watermark on received_at would be wrong: a segment written late
carries received_at values earlier than events already read.
Retention¶
Retention runs in the compactor, per source.
- Raw segments of a compacted window are deleted once the window's last
segment is older than
raw_retention_days. A window owed a compaction is never touched, so retention never deletes an event that is not yet in a Parquet file. The window's raw partition is unregistered once its segments are deleted. Retention lists a window's segments after choosing it and deletes them only when the listing is exactly the set its last compaction read (#2151): a segment a spool drained into the window in between, after an outage longer than raw retention, is in no Parquet file yet, so the window is left for the compaction it is now owed and released by a later pass. - Raw partitions are kept by name from the window records (#2133): a pass
registers the raw partition of every window a segment landed in during the
last two retention intervals, which covers a window a
receiver could not register and one a late segment reopened. A pass never
lists the raw table's location, so what it costs is the windows that changed,
not every segment raw retention keeps.
POST /api/v1/admin/webhooks/sources/{name}/sync-partitions(the Resync partitions action on the source's page) runs the full listing (sync_partition_metadataFULL) as a repair, for example after a restore. - A whole window older than
compacted_retention_daysis removed: its partition is unregistered first, so the view stops serving it, then its resource and any raw segments are deleted. Each step is recorded in the platform database, so a pass that stops part-way is finished by the next.
Payloads usually carry personal data. The source's page shows both settings and
the oldest window still held, and a shorter setting takes effect on the next
pass. A bucket lifecycle rule is not the mechanism: it would delete objects the
metastore still lists. An operator may add one as a backstop, set longer than
compacted_retention_days.
The Webhooks page¶
Admin > Webhooks is the status of every source, read in one request (#1979):
- Requests: request volume by outcome, stacked, for all sources or one, over the last hour (one bar a minute), the last 24 hours (one bar every fifteen minutes), the last 7 days (one bar an hour), or a custom range of up to 31 days, with the total of each outcome beneath.
- Sources: one row per source with its health, when it last received an event, the requests accepted and rejected in the last hour and the last day, how long accepted requests waited for their answer (Response time: the 99th percentile, with the slowest beneath, over the last hour or, when the hour had none, the last day), the windows owed a compaction (Pending), and the windows whose last compaction failed (Failing) with the newest one's error, and the raw files recorded against the open window (Raw files (current window); hover for the 95th percentile per window over the last day). Clicking a row opens the source's page.
- Spool, where a receiver has one (#2121): one row per replica with the events it acknowledged and has not yet drained (Waiting), their size, how long the oldest has waited, the events drained and the drains that failed since the replica started, and the newest drain error. A replica that has not reported for two minutes is marked Not reporting: it stopped, and drains what it holds when it starts again.
- History (#2150), over the same range: Responses by status (the HTTP
status each request was answered with; No answer is a sender that went away
first), Received vs written (each receiver's events accepted against the
events it wrote to object storage: written below received is a burst the
spool is holding, above it the spool catching up), Spool depth (events
waiting and the oldest one's age), Acknowledgement (p50 and p99, and the
average time a delivery spent in each stage: buffer, mark, and put, which
with a spool is the sync to disk), Compaction (each window's lag behind
its end and its duration), Window size (the window that took the most
memory to compact against this replica's memory limit, warned about at half
of it, since the compactor holds a whole window), and Storage by source
(raw segment bytes and files not yet released, compacted bytes and windows).
The page says plainly what it cannot see: an answer a sender got from
something in front of the platform, such as a
502or504from an ingress while a replica restarts, never reached the platform and is not counted. - Recent rejections: the rejected requests across every source within the range, the newest 50 rows of each outcome, each with its source, time, outcome, reason, the events it refused (Events) and how many requests it stands for. Never the body. Choosing a source in the Requests chart reads that source's rejections alone. An outcome filter narrows the list.
Rejections are kept per outcome, so a burst of rate_limited refusals never
pushes out the unauthorized rows that show someone sending forged requests.
A rejection with the same outcome and reason as that outcome's newest row,
within a minute of it, is added to the row rather than written as a new one:
the row's count says how many requests it stands for, first_at the first
of them and at the latest. A burst is one row.
A rejected request whose body was read into events names them: request_hash
is the SHA-256 of its body and event_ids the ids of its first 20 events, the
ids the source's table holds in event_id. Such a row is one request and is
never added to a run. A request refused before its events were read (401,
413 for the body size, 429, 400) has an empty request_hash and
event_ids: [].
A source's health is one of:
| Health | When |
|---|---|
| Disabled | An administrator turned the source off. |
| Failing | One or more of its windows failed their last compaction. |
| Silent | Enabled, and no event received in the last 24 hours, including a source that never received one. |
| Receiving | Enabled, an event received in the last 24 hours, and no window failing. |
When more than one applies, the first in that order is reported: a disabled source receives nothing by choice, and a failing one is losing what it did receive, which outranks silence. A sender that stops produces no error on the receiving side, so Silent is how a stopped sender shows.
The page reads GET /api/v1/admin/webhooks/status?range=hour|day, and
&source=<name> to read one source (a name no source has is 404):
{
"generated_at": "2026-09-29T12:00:00Z",
"range": "hour",
"from": "2026-09-29T11:00:00Z",
"bucket_seconds": 60,
"silent_after_seconds": 86400,
"sources": [
{
"name": "esp-events", "enabled": true, "health": "failing",
"auth_mode": "hmac", "connection": "acme-scratch-resources",
"table": "webhook_esp_events", "last_event_at": "2026-09-29T11:59:58Z",
"last_hour": {"accepted": 1832, "unauthorized": 3},
"last_day": {"accepted": 40211, "unauthorized": 12},
"pending": 2, "failing": 1,
"last_error": "segment webhooks/esp-events/...: gzip: invalid header",
"ack_last_hour": {
"count": 1832, "p50_ms": 1000, "p99_ms": 2500, "max_ms": 2380,
"slowest": {"at": "2026-09-29T11:00:21Z", "total_ms": 2380, "buffer_ms": 990, "mark_ms": 1310, "put_ms": 80}
},
"ack_last_day": {"count": 40211, "p50_ms": 1000, "p99_ms": 2500, "max_ms": 2380, "slowest": {"at": "2026-09-29T11:00:21Z", "total_ms": 2380, "buffer_ms": 990, "mark_ms": 1310, "put_ms": 80}},
"segments_open_window": 412,
"segments_per_window_p95": 3650
}
],
"volume": [
{"at": "2026-09-29T11:59:00Z", "source": "esp-events", "outcome": "accepted", "count": 31}
],
"rejections": [
{"source": "esp-events", "first_at": "2026-09-29T11:58:40Z", "at": "2026-09-29T11:58:40Z", "count": 1, "outcome": "sender_gone", "reason": "the sender disconnected after 30s, before its events were written", "request_hash": "9f2c4e7a...", "event_ids": ["evt_01J9Z6Q4"]},
{"source": "esp-events", "first_at": "2026-09-29T11:58:12Z", "at": "2026-09-29T11:58:12Z", "count": 1, "outcome": "unauthorized", "reason": "the signature does not match", "request_hash": "", "event_ids": []},
{"source": "esp-events", "first_at": "2026-09-29T11:57:01Z", "at": "2026-09-29T11:57:03Z", "count": 42, "outcome": "rate_limited", "reason": "the source's rate limit was reached", "request_hash": "", "event_ids": []}
]
}
volume is read from the per-minute request counts at or after from, the
bound each source's last_hour (range hour) or last_day (range day) is
read with, so the series sums to those counts. A bucket starts at a multiple of
bucket_seconds since the Unix epoch, and one with no requests has no point.
Per-minute counts are kept for 48 hours, which is why the longest range is a
day. rejections holds the rows whose latest rejection (at) is at or after
from, so a range of an hour shows the last hour's. With no sources, every
list is [].
ack_last_hour and ack_last_day are how long the requests answered 202
waited for that answer. Each replica counts them per minute in fixed buckets
(10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000 and 30000 ms, and one for
anything slower). p50_ms and p99_ms are the upper bound of the bucket the
percentile falls in, capped at max_ms, so each is the most it could be.
slowest is the slowest request of the span with how long its events waited in
the buffer (buffer_ms), recorded their window (mark_ms) and took to write to
object storage (put_ms); it is null when nothing was accepted. A response
time creeping toward a sender's timeout shows here before requests start to
fail. The counts are kept for 48 hours, like the request counts.
segments_open_window is how many segments were recorded against the source's
open window, and segments_per_window_p95 the 95th percentile of that count
over the windows that started in the last day (#2132): the objects a reader of
a window reads before it is compacted, which is the read cost to watch before
readers time out. A receiver records its first write of a window twice,
so a count can exceed the segments written by one per replica. The source's
own GET /api/v1/admin/webhooks/sources/{name} carries both in its status.
A create or a change of a source is answered with the source and, when there
is something to say, warnings (#2134): the read of the raw table's
partitions the save makes took 5 seconds or more, which an uncached file
metastore causes (Scratch Catalog), or
failed (the save waits at most 15 seconds for it and reports a read still
running then as slow). A warning never refuses the save. The portal shows them as Saved, with
warnings before it opens the source.
The source's page¶
A source's page shows:
- the URL to give the sender, and the table readers query;
- request counts by outcome for the last hour and the last day;
- how long accepted requests waited for their answer over the last hour and the last 24 hours (median, 99th percentile, slowest), and the slowest request's time split into Buffer, Recording and Writing;
- when the source last received an event (a sender that stops produces no error on the receiving side, so this is how a quiet source shows);
- the window length, the last compacted window, the windows owed a compaction, and the last failure;
- the oldest window held, beside the retention settings;
- the raw files in the current window, and the 95th percentile per window over the last day, with Resync partitions, which runs the full listing of the raw table's location as a repair (see Retention);
- the rejected requests, the newest 50 rows of each outcome: when, the outcome, why, the events refused (their ids, with the body's SHA-256 on hover and a copy button), and how many requests a row stands for. Never the body.
Deployment¶
webhooks:
receiver:
enabled: true # default; serve /hooks/ on this replica
address: "" # optional: also serve /hooks/ on a listener of its own
write_timeout: 30s # a segment not written by then is answered 503
spool:
dir: "" # set to answer from local disk and drain behind the answer (#2121)
max_bytes: 1073741824
max_age: 1h
compactor:
enabled: true # default; compact and apply retention on this replica
grace: 2m # wait after a window ends for segments still in flight
poll: 30s
lease: 10m
batch: 4
retry_backoff: 1m
retention_every: 10m
telemetry_retention_days: 14 # the page's history (#2150); per minute for 48h, per hour after
compactor.rollup_check_every and compactor.rollup_delete_delay paced the
v1.146.1 merge of the open window (#2140). A configuration that sets them still
starts; each is logged as deprecated_key and does nothing. Remove them.
Every source is served on one port and routed by path, so a deployment needs one
Ingress rule for all of them. A sender whose configured URL cannot change is
moved with an Ingress path rewrite from its old path to /hooks/{source}.
Serve /hooks/ from an Ingress without CORS. An Ingress with CORS enabled
(ingress-nginx's nginx.ingress.kubernetes.io/enable-cors: "true", which a
platform serving browser MCP clients often has) answers every OPTIONS
request itself with 204, and the request never reaches the platform. A
cloudevents source then never answers the handshake, and a sender that
requires it never delivers; a source without the handshake never answers its
405. Annotations apply per Ingress and the longest path prefix wins, so a
second Ingress for /hooks/ on the same host, with no CORS annotations and the
same TLS secret, takes those requests while the rest of the host keeps its
CORS. Senders are servers, so /hooks/ never needs CORS:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mcp-data-platform-hooks
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
ingressClassName: nginx
rules:
- host: platform.example.com
http:
paths:
- path: /hooks/
pathType: Prefix
backend:
service:
name: mcp-data-platform
port:
number: 8080
tls:
- hosts: [platform.example.com]
secretName: platform-tls # the main Ingress's certificate; no cert-manager annotation here
A deployment that wants bursts kept off the replicas serving MCP and the portal
turns webhooks.receiver.enabled off there and runs a receiver-only
deployment of the same image behind the webhook Ingress, with address set if
it should listen on a port of its own.
Where the spool lives¶
The spool is a directory on the replica, one per replica: a second process opening a directory another holds is refused at start. Its volume decides what survives:
- An
emptyDirsurvives a container restart, not the pod being rescheduled: what was acknowledged and not yet drained when the pod is deleted is lost. - A volume of the replica's own keeps it across rollouts, evictions and node
drains: run the receiver as a StatefulSet with a
volumeClaimTemplatesentry mounted at the spool directory. A ReadWriteOnce volume is tied to its node; if the node is lost, that replica's events wait until the volume is attached again. They are delayed, not lost.
A replica drains only its own spool. Another replica does not take over a stopped replica's volume, so its events wait for it to start. The Webhooks page shows such a replica as not reporting, with what it holds.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mcp-data-platform-hooks
spec:
serviceName: mcp-data-platform-hooks
template:
spec:
containers:
- name: platform
volumeMounts:
- name: spool
mountPath: /var/lib/mcp-data-platform/spool
volumeClaimTemplates:
- metadata:
name: spool
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 2Gi
with webhooks.receiver.spool.dir: /var/lib/mcp-data-platform/spool. A spool
directory that cannot be opened (not writable, or held by another process)
stops the platform at start, naming it: an operator who asked for acknowledged
events to be kept on disk is told then, not answered without it.
Alerts¶
Every condition that ends in lost or late events is posted to notification
channels at a warning and a critical level (#2149), configured under Admin >
Settings > Alerts > Webhooks or GET|PUT /api/v1/admin/settings/webhook-alert.
One replica evaluates them each minute (MCP_PLATFORM_WEBHOOK_ALERT_INTERVAL
overrides it). A condition is posted under the key
webhook:<condition>:<source|replica>:<level>:<unix time the level began>, so
the channel's repeat window holds back the repeats of one episode while a move
from warning to critical, or a condition that clears and returns, posts at
once; a condition that clears is posted once more under
...:resolved:<unix time>. A replica that stopped reporting for two minutes
is judged only by whether it holds events, never by its last report. A post
names the source or replica, the value, the threshold and the page, and never
a payload.
| Condition | Warning (default) | Critical (default) |
|---|---|---|
| Spool fill, any replica | 50% of max_bytes |
80% |
| Oldest event waiting in a spool | 25% of max_age |
60% |
| Drain failures in a row, any replica | 3 | 10, or failing for 5 minutes |
| A replica holding events stopped reporting | 120 seconds | |
Refusals (spool_full, write_failed, buffer_full), per source |
any in 5 minutes | one in every minute of 5 |
unauthorized share of a source's requests |
10% over 15 minutes | |
| Acknowledgement p99, per source | 1 s | 5 s |
| Compaction behind (past the window's end and the grace), per source | 1 minute, or a window failing | 30 minutes |
| A source silent | per source, off unless set | |
| Spool volume free space, any replica | 25% free | 10% free |
The per-source rows have defaults and a source's own overrides. A zero threshold is off. The spool volume is also a store on the Storage page.
Metrics¶
| Series | Labels |
|---|---|
webhook_requests_total |
source, outcome: accepted, unauthorized, too_large, rate_limited, buffer_full, spool_full, write_failed, sender_gone, unknown_source, invalid_body, unreadable (a mailbox source's message the mail client could not read) |
webhook_events_total |
source |
webhook_segments_written_total |
source |
webhook_compactions_total |
source, result: compacted, failed |
webhook_duplicates_dropped_total |
source |
webhook_buffer_events |
source: events held in memory on this replica |
webhook_ack_seconds |
source: request to acknowledgement |
webhook_spool_bytes, webhook_spool_events, webhook_spool_files |
none: what this replica's spool holds |
webhook_spool_oldest_age_seconds |
none: age of the oldest event waiting; rising means the spool is not draining |
webhook_spool_drained_events_total |
source: events drained to object storage |
webhook_spool_drain_failures_total |
source: drains that failed and are retried |
webhook_responses_total |
source, http_status: the status each request was answered with; 0 is a sender that left first (#2150) |
webhook_written_events_total, webhook_written_bytes_total |
source: events and segment bytes this replica wrote to object storage, from its spool or its buffer |
webhook_ack_stage_seconds |
source, stage: buffer, mark, put (with a spool, the sync) |
webhook_compaction_duration_seconds, webhook_compaction_lag_seconds |
source: one window's compaction, and its finish behind the window's end |
webhook_window_held_bytes |
source: a window's events as the compactor held them in memory |
webhook_source_bytes |
source, kind: raw (segments not yet released) or compacted; every replica reports the same value |
A request to a name that is not a source is counted with an empty source
label, so a caller cannot create series by inventing names.