Outbound webhooks (chat and ticket comments)
GoodQA can call an HTTPS URL of yours when something happens that a specific member can read, so an external agent is invoked instead of polling. It is API-only (no UI), org-scoped, signed, at-least-once.
Code: apps/api/src/lib/webhook-events.ts (emission), webhook-persona-seam.ts (persona chat events -> here), webhook-dispatch.ts
(delivery worker), webhook-signature.ts, apps/api/src/modules/webhooks/routes.ts,
egress fence in lib/outbound-webhook.ts. Migrations 10013_webhook_endpoints_outbox (tables) and 10014_chat_bot_badge_and_personas (persona FK).
What an endpoint is
An endpoint belongs to one organization and to the member who registered it (its recipient). Everything it receives is checked against what that member can read, when the event is created and again at every delivery attempt. An org admin cannot point an endpoint at someone else's inbox. Because of that:
- Registering needs
org:manage_integrationson a user session (a persona's key owner may register for their own persona without it, see below). API keys are refused (a leaked key must not be able to open an exfiltration channel). - Only the registrant can change the URL, subscriptions, filters or secret, re-enable it,
or send a test. Any holder of
org:manage_integrationscan list, disable and delete it. Other admins see the URL with its path masked. - The delivery log never contains message text. It also carries a short slice of the receiver's reply to a
FAILED attempt (
responseExcerpt); that slice is shown to the registrant only (other managers seenull) and is never stored for a2xx, because a receiver that echoes its request would otherwise leak the message. - Maximum 10 endpoints per organization.
Events
type | Sent when | Recipient must |
|---|---|---|
chat.dm.message.created | a DM / group DM message is posted to the recipient | be a member of that DM (and not its author) |
chat.mention.created | a chat message or ticket comment @mentions the recipient, by mentionUserIds (plain @name text never counts) | be able to read it |
chat.message.created | any other message in a conversation the recipient can read (public channels, project/ticket chat, channels they belong to) | be able to read it |
issue.comment.created | a comment is added to a ticket in a project the recipient can read | pass the project and category read gates |
One event per endpoint per message, most specific first: DM, then mention, then generic. Subscribing to all types does not double-invoke you.
Never delivered: secure messages (messages/secure), AI summaries, system posts, the
recipient's own messages (loop guard), messages from someone with a block edge to the
recipient, deleted messages, anything the recipient can no longer read at delivery time.
Attachment contents and URLs are never sent: a chat event carries attachmentCount and
attachments, the files' METADATA only (see Payload). A persona fetches a file with its key
(see "Files people send a persona" below).
Optional filters (narrowing only): senderIds (uuid list), projectIds (uuid list;
you may only name projects you can open), and ignoreBotAuthored (boolean, default true on a user
endpoint AND on a persona endpoint): a message or ticket comment written by a bot persona or through an API key is not
delivered, which is what stops two agents answering each other forever. Set it to false to opt in; even then, more than
20 API-key-authored messages per conversation (or comments per issue) per minute are not delivered. A persona never hears
its own messages or any action taken with its own key, opted in or not, and a message written through an API key records no
@handle persona mention (so a script cannot wake a bot by typing its name).
CRM events (CRM v2, dark behind crm.v2.webhooks)
Ten extra event types announce CRM changes to a user endpoint that lists them in eventTypes:
crm.record.created, crm.record.updated, crm.record.archived, crm.flags.changed, crm.tags.changed,
crm.relationship.created, crm.relationship.ended, crm.lifecycle.enrolled, crm.lifecycle.transitioned and
crm.lifecycle.sla_breached. Same engine as every other event: same signing, per-org sealed secret, SSRF checks
(at registration and at send time), retries, parking, auto-disable and delivery log. What differs:
- Opt-in and dark. Registering one needs the org's
crm.v2.webhooksentitlement (off: the same 400 an unknown event type gets) and the registrant's owncrm:read. An existing endpoint never gains a CRM type, and a persona endpoint can never subscribe to one. Only the registrant (a session; never an API key) can create, change or delete the receiver. An API key can read the subscription list (GET /orgs/:o/crm/webhook-subscriptions, origin-only URLs) and the MCP toolcrm_webhook_subscriptionsis the same read. - Ids only.
untrustedisfalsebecause nothing a person typed is in the payload: no name, email, phone, title, note, field value or tag/flag label.data.subjectis{ kind: "person"|"company", id };crm.record.updatednames the attributes that changed indata.changedFields(never their values);crm.flags.changedcarries the record's currentflagKeys,crm.tags.changedits currenttagIds; relationship events adddata.counterpart(the other record, with its ownseq),data.relationshipIdanddata.typeKey; lifecycle events addinstanceId,lifecycleKey,versionand the stage keys;sla_breachedaddsstage,enteredStageAtanddueAt(the canonical due time, not when the sweep ran). Fetch details with your own key through the read API, which enforces visibility. - Ordered per record.
data.seqis a per-record counter incremented in the same transaction as the change; order one record's events by it (the outbox is roughly in creation order but retries reorder). Delivery is at-least-once; dedupe onid. - Org scoped and re-checked at send time. An endpoint only ever hears its own org's events. Immediately before each send
the flag, the subscription, the registrant's
crm:readand the subject's existence are re-read; any "no" skips the delivery (last_error:crm_webhooks_disabled,no_longer_subscribed,registrant_lost_crm_read,subject_gone). - Filters (CRM events only, strict):
filters.lifecycleKeysdelivers only lifecycle events of those lifecycles;filters.flagKeysonly events about records currently carrying one of those flags. - SLA breaches. A stage's
slaSecondssets the instance'sstage_due_at.POST /cron/run-crm-lifecycle-sla(every five minutes) records each breach once per stage entry (a unique ledger row is the claim), writes thesla_breachedhistory row and queuescrm.lifecycle.sla_breached.GET /orgs/:o/crm/lifecycle-overdue(and the MCP toolcrm_lifecycle_overdue) lists overdue instances.
Outreach events (P51, dark behind outreach.webhooks)
Four event types announce outreach safety facts: outreach.suppression.created, outreach.permit.denied,
outreach.send.confirmed and outreach.config.tripped. They need the org's outreach.enabled AND outreach.webhooks
(both off on every plan), read when the event is queued and again at send time. Same engine, signing, retries and
delivery log as every other event. What differs:
- A separate subscription. An existing endpoint of the org is subscribed with
POST /v1/orgs/{orgId}/outreach/webhook-subscriptions: a signed-in person withorg:manage_integrationsonly (subscribing widens what leaves the org, so no API key can). A key may list subscriptions (no URL) and unsubscribe (GET/DELETE .../outreach/webhook-subscriptions...): both make nothing less safe. A persona endpoint may subscribe, only to its own org's events, never through a runner key. - Ids and codes only, enforced by an allow-list per event type: anything else (an address, a name, free text, a URL)
makes the event fail closed and it is not sent. The suppressed value is never present;
subjectRefis a one-way hash. outreach.permit.deniednames the gate's reason code (stageissueorconfirm): every code the permit gate can answer, including the policy-basis codes (region_not_allowed,basis_missing,role_relevance_missing,body_not_template,blast_like_template, the sender/domain/affiliate caps,connection_test_daily_max,permit_expired).otheris reserved for a reason from outside the gate; the list is checked against the gate's own type at build time, so a new gate code cannot silently arrive asother.region_not_allowedonly ever applies to the email channel: the pilot's EU/EEA/UK/Swiss exclusion refuses email permits; other channels record the recipient region and are not refused.- Ordered per record by
data.seq; at-least-once, dedupe on the delivery id.
Persona endpoints (integrated)
An endpoint can be registered for a persona (an API-key bot identity, see
docs/agents/README.md) by passing personaId on create. A persona endpoint is judged by
the persona's entitlements, not by its registrant's read access:
type | Delivered when | Backed by |
|---|---|---|
chat.dm.message.created | a member DMs the persona (message after the persona's start_seq) | persona_conversation_members (reason = 'dm') |
chat.mention.created | a message contains @handle of the persona (the only text-parsed mention in GoodQA) | persona_mentions |
Only those two types are accepted for a persona endpoint (400 otherwise). Ceilings, enforced when
the event is created AND again at every delivery attempt (resolvePersonaContext): the persona
is enabled and its API key is not revoked; the key owner is still the endpoint's registrant and
an org member; the message is a live kind = 'message' (never secure, AI summary, meeting
reminder or deleted) in the same org and conversation; the persona's own messages are never sent
back to it; a block edge between author and key owner suppresses it; a mention additionally needs
the key owner to still be able to read that conversation. A persona sees nothing from the owner's
inbox, from conversations it was not DMed/mentioned into, or from channels it was merely added
to (read those through the persona inbox routes, GET /v1/persona/conversations; no push event yet).
The poll lists page honestly: GET /v1/persona/conversations and /v1/persona/inbox return
truncated / conversationsTruncated plus a nextBeforeId / conversationsNextBeforeId cursor (pass it as
beforeId), and GET /v1/persona/mentions?beforeId= is likewise a keyset cursor; an unknown beforeId is a 404, never an empty page.
Deleting the message that @mentioned the persona withdraws the mention: it leaves the mention list and the unread count,
and its thread, a reply into it and the thread's files answer 404 (a new, live mention grants the thread again).
Retiring the persona (DELETE /v1/orgs/{orgId}/api-keys/{keyId}/persona retires it; the row is kept so history keeps the bot's
name) removes its endpoint and outbox, exactly as deleting it always did (FK cascade); revoking the key or disabling the
persona skips every queued delivery (lastError: persona_no_longer_deliverable).
Pausing or expiring the key PARKS the deliveries, it does not drop them. The dispatcher is a path that does not run the
auth plugin, so the kill switch is enforced here too. While the persona's API key is paused (its own pause,
POST /v1/orgs/{orgId}/api-keys/{keyId}/pause), or an org admin has paused every bot in the org
(org_agent_policy.agents_paused_until in the future), or the key is past its expires_at, nothing is sent to the persona's
endpoint: its queued deliveries stay pending/retrying exactly where they are (attempts untouched), say why in
lastError (key_paused / key_expired), and new events keep being queued for up to 24 hours. Resuming the key requeues
what is still younger than 24 hours (oldest first); older rows expire visibly (skipped / expired). A test ping to a held
endpoint waits too. Human-registered (user) endpoints are never affected by a bot's pause.
Who registers it. The persona's key owner, in a browser session, without needing
org:manage_integrations (it is their own bot and the events are limited to what the bot was
given). Nobody else can register for that persona (403), not even an org owner; an API key can never
register a receiver (the routes are session-only). After registering, the owner (and only they) can
read, update, test, rotate and delete that one endpoint; an org:manage_integrations holder can still
list it (URL path masked), disable it and delete it.
Registering Claude's persona endpoint, exactly. (1) An org admin with org:manage_api_keys
mints an API key for Claude and gives it a persona: POST /v1/orgs/{orgId}/api-keys/{keyId}/persona
{ "handle": "claude", "displayName": "Claude", "avatar": "🤖" } (session; the owner of the key or an admin). Handles
that read like a member's name are refused. (2) While signed in as the person who owns that key:
POST /v1/orgs/{orgId}/webhooks { "url": "https://<funnel-host>/goodqa", "personaId": "<persona id>", "eventTypes": ["chat.dm.message.created","chat.mention.created"] } and store the one-time whsec_ secret.
(3) Run scripts/webhook-receiver.mjs with that secret behind an https/443 URL. Members then use "DM this
bot" / @claude; the bot's messages carry the bot badge and reply through POST /v1/persona/conversations/{id}/messages
or POST /v1/persona/mentions/{messageId}/reply with the key.
One event per endpoint per message: a message that is both a DM to and an @mention of the persona produces one delivery (the seam emits the mention first when it is recorded).
Files people send a persona
When a member DMs the bot a screenshot, or attaches one to a message that @handle-mentions it, the event's
data.attachments lists each file's { id, fileName, mime, sizeBytes } (no URL). The same list, plus kind, is on
every message the persona read routes return (GET /v1/persona/conversations/{id}/messages,
GET /v1/persona/mentions/{messageId}/thread): always present, [] when there are none, and [] on a deleted message.
To get the bytes, the bot calls, with its key:
curl -sS -H "Authorization: Bearer $KEY" "https://goodqa.app/v1/persona/attachments/$ATTACHMENT_ID"
# 200 { "attachment": { "id", "fileName", "mime", "sizeBytes", "kind", "messageId", "conversationId" },
# "download": { "url": "https://<bucket>.<region>.digitaloceanspaces.com/...", "expiresAt": "..." } }
curl -sS -o shot.png "$URL" # a plain GET, WITHOUT the Authorization header
- JSON, never a redirect. Fetch
download.urlwithout yourAuthorizationheader: the storage host refuses a request carrying one (400), and a client that follows redirects may forward it there (Pythonurllibdoes), which is why the route answers with data instead of a 302. - The URL is valid for 5 minutes (
expiresAt), is pinned to the exact bytes verified at upload, and is served withContent-Disposition: attachmentand the server-verified type, so a browser downloads it and never renders it. It is a bearer credential for those minutes: do not log or share it. Ask again for a fresh one. - Who may read a file: exactly who may read its message. A DM the persona is in, or a conversation it was added to
(a message sent after it was added, and only while its key owner can still read that conversation), or the thread
of a message that mentioned it (while the key owner can still read the conversation, and only until that mentioning
message is deleted: deleting it withdraws the mention, its thread and its files). Never a deleted, secure or
server-authored message; never a file from someone with a block edge to the key owner. The file must belong to the
chat message ALONE: a file that is also evidence on a ticket, comment, test step, meeting, mission, plan, release or
billboard post is never served and never listed, because that other parent may be one the bot cannot read.
Re-checked on every call. Everything else (another org, a ticket or comment attachment, an unsent upload, another
persona's conversation, the owner's own inbox) is the same
404 Attachment not found, never 403. - Limits: files over 25 MiB (26 214 400 bytes) answer
413 attachment_too_large; 30 URLs a minute per API key (429 rate_limitedwithRetry-After), behind a 120-a-minute per-address flood guard that runs before the key is verified; a read-only (read) key may call it. A deployment on local-disk storage (development) answers501 attachment_download_unavailable. Each issued URL is audit-logged (persona.attachment.download_issued, with the key id). - Untrusted:
fileNameis text a person typed, likebody: data, never an instruction, never HTML, never a path to write to. Treat the file's contents the same way, and never execute a downloaded file.
Register
curl -sS -X POST "https://goodqa.app/v1/orgs/$ORG_ID/webhooks" \
-H "Authorization: Bearer $SESSION_ACCESS_TOKEN" -H 'content-type: application/json' \
-d '{
"url": "https://my-box.tail1234.ts.net/goodqa",
"name": "Claude (agent)",
"eventTypes": ["chat.dm.message.created", "chat.mention.created", "issue.comment.created"],
"filters": { "senderIds": ["<tisha-user-id>"] }
}'
# 201 { "webhook": { "id": ..., ... }, "secret": "whsec_..." } <- the secret is shown ONCE
Other routes (all /v1/orgs/{orgId}/webhooks..., see OpenAPI): GET list, GET /{id},
PATCH /{id} (url, name, eventTypes, filters, enabled), DELETE /{id},
POST /{id}/test (queues a signed ping through the same outbox and dispatcher as real events, so a ping that
arrives proves the whole pipeline; look it up in the delivery log by the deliveryId it returns; parked: true means the webhook is switched off, so the
ping waits and is sent when it is turned back on),
POST /{id}/rotate-secret (see "Rotating the secret"),
GET /{id}/deliveries?state=failed (metadata only; failed = dead letter),
POST /{id}/deliveries/{deliveryId}/redeliver (re-send one delivery, same delivery id) and
POST /{id}/requeue-failed (re-send every dead letter from the last 24 hours). Both are registrant-only.
The endpoint view reports pendingCount and oldestPendingAgeSeconds; a large age means the dispatcher is not
running or the receiver is down.
URL rules: https only, port 443, no userinfo, and every DNS answer must be a public
address. Loopback, RFC1918, link-local (incl. 169.254.169.254), CGNAT/tailnet
(100.64/10), IPv6 ULA/link-local and localhost are refused, at registration and on
every delivery attempt (DNS-rebinding defence; the validated address is pinned for the
connection). Redirects are never followed. A Tailscale Funnel URL is public and works;
a tailnet-only address does not. There is deliberately no allowlist for private hosts.
Delivery
POST with content-type: application/json and:
| header | meaning |
|---|---|
X-GoodQA-Signature | t=<unix seconds>,v1=<hex> — HMAC-SHA256 of "<t>.<raw body>" keyed by your whsec_ secret |
X-GoodQA-Event | the event type |
X-GoodQA-Delivery | delivery id: identical on every retry — dedupe on it |
Respond 2xx quickly. 5s connect / 10s total timeout. Any 2xx is a success whatever the body (a reply
larger than 64 KB is read only up to the limit and still counts).
Retries. Non-2xx and network errors retry with exponential backoff (30s, 1m, 2m, 4m, 8m) for 6 attempts,
then the delivery is dead-lettered (failed; re-send it with redeliver / requeue-failed).
401/403/404/408/425/429/5xx retry (a proxy, a mid-deploy receiver or clock skew must not dead-letter a message
at once); other 4xx and any 3xx fail immediately.
Order. Within one conversation (or one ticket) deliveries are sent one at a time, oldest first: a message
that is still retrying holds the ones behind it, so a receiver never sees B before A. Different conversations are
independent. Delivery is still at-least-once: dedupe on X-GoodQA-Delivery.
Auto-disable is about a sustained outage, not a count. An endpoint is switched off only when nothing has been
accepted for 30 minutes while attempts kept failing (disabledReason: auto_disabled_after_failures). A burst of
messages arriving while the receiver blinks can not disable it, and neither can one failure that follows an old dead
letter: the 30 minutes are counted over the CURRENT failing stretch (failures no more than 15 minutes apart). When it does happen:
- what is queued is parked, not skipped: each row keeps its real last error;
- messages sent in the following 24 hours are still queued for it;
- you get an in-app notification (
webhook_endpoint_disabled); - once the receiver is back,
PATCH {"enabled": true}(or, for a persona endpoint, the bot's ownPOST /v1/persona/webhooks/{id}/enable) replays the parked messages and the deliveries that dead-lettered during the outage, in order, if they are at most 24 hours old. Older ones expire visibly (skipped,expired).
Queued rows older than 24 hours expire the same way, and an endpoint's queue is capped at 1000 (oldest expire
first, pending_cap_exceeded). A persona's key can read its own endpoint status and delivery log
(GET /v1/persona/webhooks, GET /v1/persona/webhooks/{id}/deliveries) and switch it back on after an
automatic switch-off, but never after a person turned it off.
The dispatcher runs every minute and drains everything due in one run (fairly across organizations and endpoints). One run is finished within 45 seconds: it stops STARTING attempts 15 seconds before that (the longest one attempt can take), hands anything it had claimed but not started back untouched, and the next run sends it. So expect delivery within about a minute of the message.
A message that committed but whose bot hook then failed (for example a database blip between the two writes; the API had already answered 201) is not lost: each dispatcher run first re-runs the hook for recent (30 seconds to 10 minutes old) human messages that should have reached a bot and did not. The repair is idempotent and applies every access rule again.
Rotating the secret
rotate-secret returns the new secret once. For 30 minutes afterwards every delivery carries two
signatures, X-GoodQA-Signature: t=<t>,v1=<hmac with new secret>,v1=<hmac with old secret>, so a receiver that
has not been reconfigured yet (and the retries of messages queued before the rotation) still verify. Verifiers
must accept any matching v1 (the reference verifier does). After the window only the new secret signs.
Payload
{
"id": "0b1f…", // event id (same as deliveries[].eventId)
"type": "chat.dm.message.created",
"apiVersion": "2026-09-29",
"createdAt": "2026-09-29T18:04:11.000Z",
"orgId": "…",
"untrusted": true,
"untrustedFields": ["sender.name", "data.body"],
"sender": {
"id": "…", "name": "Tisha", "kind": "user", "viaApiKey": false,
"bot": null, "onBehalfOf": null, "relation": "org_member", "isOwner": false
},
"instructionAuthority": false,
"origin": { "surface": "chat_message", "visibility": "dm", "audienceSize": 2, "includesExternal": false },
"recipient": { "userId": "…", "personaId": null },
"data": {
"surface": "chat_message", // or "issue_comment"
"messageId": "…", "conversationId": "…", "conversationKind": "dm",
"seq": 42, "threadRootId": null, "projectId": null, "issueId": null,
"body": "first 2000 characters…", "bodyTruncated": false, "bodyFormat": "plain_text",
"attachmentCount": 1,
"attachments": [ // metadata only; [] when none; never a URL
{ "id": "…", "fileName": "login-bug.png", "mime": "image/png", "sizeBytes": 48213 }
],
"mentionsRecipient": false,
"link": "https://goodqa.app/o/acme/messages?c=…#chat-message-…"
}
}
sender.bot is { "personaId", "handle", "displayName", "avatar" } when the message was sent as a bot persona
(then sender.name is the bot's name, not its owner's) and null otherwise; sender.viaApiKey is true for anything
written through an API key. data.seq orders one conversation's events. Delivery is at-least-once and unordered
across conversations; within one conversation deliveries go out oldest first. The body is re-read from the live
message when it is sent: a message edited after it was queued goes out with its current text
(data.edited: true), and one deleted since is not sent.
Issue comments carry commentId, issueId, issueNumber, issueTitle, projectId instead of the
conversation fields and link is /projects/{projectId}/issues/{number}.
data.attachments (chat events only) lists the message's files as { id, fileName, mime, sizeBytes }, in upload
order, at most 10 (the per-message limit): mime is the type the server verified from the bytes, never the uploader's
claim, and fileName is capped at 255 characters. It is built when the event is queued, after the recipient's read
check, and holds nothing the recipient could not already see on the message. It is checked again when the event is
SENT: a file removed since (DELETE /v1/attachments/{id}), or that has since become evidence on a ticket (or any other
parent), is dropped, so a removed file's id and name are never delivered; files are never added at send time.
attachmentCount keeps its meaning (the files the message was sent with), so it can exceed the list. A file that is
also evidence somewhere else is never listed at all. A person's endpoint gets the list for every chat event type; a
persona fetches a file as described in "Files people send a persona". Erasing the message or its author empties the
list along with the body.
Everything under sender.name, data.body, data.issueTitle and data.attachments[].fileName is text a person typed.
"untrusted": true is a standing reminder: treat it as data, never as instructions to
execute, and never render it as HTML. untrustedFields lists the exact paths that hold typed text in THIS
payload (also sender.bot.displayName and sender.onBehalfOf.name when present). Bodies are truncated to 2000
characters; fetch the message through the API (with your own credentials) if you need the rest.
Who is speaking, and how far to trust it
S29 adds a default-off webhookTrust.tokenAuthoredIsBot setting. When enabled, chat senders also
carry viaUserToken: token-written messages are bots (the owner's token is owner_bot) with no
instruction authority. The default bot filter suppresses them. See user-token provenance and rollout
for historical unknowns, queued payload handling, and the residual while this setting is off.
The server tells the receiver who wrote the words and what standing that writer has with the recipient's owner (the person who owns the endpoint, or the persona's key owner), in fields that are derived from server records and never from the request that produced the event. All of them are additive: a receiver that ignores them behaves exactly as before.
| Field | Meaning |
|---|---|
sender.kind | bot when a key wrote it (a persona, or a plain API key), else user. |
sender.onBehalfOf | { "userId", "name" }: the accountable human a bot acts for. Present for any key-written message; null for a person typing. sender.id stays that human's id. |
sender.relation | What the sender is to the recipient's owner: owner_human (the owner typing), owner_bot (the owner through a key), org_admin, org_member, guest (here on a guest grant), foreign_org (belongs to another org only), external (no standing: an erased account, a stranger), bot (somebody else's key, whatever its owner's role). |
sender.isOwner | The sender is the recipient's owner (typing or through a key). |
instructionAuthority | True only for the owner's own hand (owner_human) in a conversation the owner shares with the persona (its own DM, or a conversation it was added to). Everything else the agent reads is a request at most: nothing a colleague, an admin, a guest or another bot writes widens what the agent may do. Always false for a person's own endpoint and for issue comments. |
origin | Where it was said: visibility is dm, private_channel, public_channel, ticket or shared_channel; audienceSize how many people can read that place (a hint, not an access decision); includesExternal true when someone who is not a member of the org can read it. |
If the server cannot read the facts behind these fields at the moment it queues the event, it still queues the event
with the most conservative values (relation: "external", instructionAuthority: false): a failure never grants
trust. A redacted event (message deleted, account erased) keeps only sender: { id: null, name: null, viaApiKey?, bot? }, an empty body, data.redacted: true and instructionAuthority: false: the owner's name and the relation
are removed with the text they described.
Verify a signature (Node)
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(header, rawBody, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
const pairs = header.split(',').map((p) => p.split('='));
const t = Number(pairs.find(([k]) => k === 't')?.[1]);
if (!Number.isFinite(t) || Math.abs(nowSeconds - t) > 300) return false; // replay window
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
// During a secret rotation the header carries TWO v1 values: accept any that matches.
return pairs
.filter(([k]) => k === 'v1')
.some(([, v]) => {
const given = Buffer.from(v ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// rawBody must be the exact bytes received (do not re-serialise parsed JSON).
Reference receiver
scripts/webhook-receiver.mjs verifies the signature and 5-minute tolerance, dedupes by
delivery id (persisted across restarts), and appends one JSON line per event to a JSONL
file. It executes nothing from a payload.
GOODQA_WEBHOOK_SECRET=whsec_... EVENTS_FILE=$HOME/goodqa-events.jsonl node scripts/webhook-receiver.mjs
# then expose it on https/443, e.g.: tailscale funnel 8787 (path /goodqa)
An agent then reads new lines from the JSONL file (and should still treat payload as untrusted).