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_integrations on 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_integrations can 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 see null) and is never stored for a 2xx, because a receiver that echoes its request would otherwise leak the message.
  • Maximum 10 endpoints per organization.

Events

typeSent whenRecipient must
chat.dm.message.createda DM / group DM message is posted to the recipientbe a member of that DM (and not its author)
chat.mention.createda chat message or ticket comment @mentions the recipient, by mentionUserIds (plain @name text never counts)be able to read it
chat.message.createdany 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.createda comment is added to a ticket in a project the recipient can readpass 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.webhooks entitlement (off: the same 400 an unknown event type gets) and the registrant's own crm: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 tool crm_webhook_subscriptions is the same read.
  • Ids only. untrusted is false because nothing a person typed is in the payload: no name, email, phone, title, note, field value or tag/flag label. data.subject is { kind: "person"|"company", id }; crm.record.updated names the attributes that changed in data.changedFields (never their values); crm.flags.changed carries the record's current flagKeys, crm.tags.changed its current tagIds; relationship events add data.counterpart (the other record, with its own seq), data.relationshipId and data.typeKey; lifecycle events add instanceId, lifecycleKey, version and the stage keys; sla_breached adds stage, enteredStageAt and dueAt (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.seq is 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 on id.
  • 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:read and 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.lifecycleKeys delivers only lifecycle events of those lifecycles; filters.flagKeys only events about records currently carrying one of those flags.
  • SLA breaches. A stage's slaSeconds sets the instance's stage_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 the sla_breached history row and queues crm.lifecycle.sla_breached. GET /orgs/:o/crm/lifecycle-overdue (and the MCP tool crm_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 with org:manage_integrations only (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; subjectRef is a one-way hash.
  • outreach.permit.denied names the gate's reason code (stage issue or confirm): 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). other is 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 as other. region_not_allowed only 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:

typeDelivered whenBacked by
chat.dm.message.createda member DMs the persona (message after the persona's start_seq)persona_conversation_members (reason = 'dm')
chat.mention.createda 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.url without your Authorization header: the storage host refuses a request carrying one (400), and a client that follows redirects may forward it there (Python urllib does), 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 with Content-Disposition: attachment and 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_limited with Retry-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) answers 501 attachment_download_unavailable. Each issued URL is audit-logged (persona.attachment.download_issued, with the key id).
  • Untrusted: fileName is text a person typed, like body: 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:

headermeaning
X-GoodQA-Signaturet=<unix seconds>,v1=<hex> — HMAC-SHA256 of "<t>.<raw body>" keyed by your whsec_ secret
X-GoodQA-Eventthe event type
X-GoodQA-Deliverydelivery 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 own POST /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.

FieldMeaning
sender.kindbot 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.relationWhat 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.isOwnerThe sender is the recipient's owner (typing or through a key).
instructionAuthorityTrue 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.
originWhere 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).

We use essential cookies to keep you signed in. See our Privacy Policy for details.