Imported from cosscom/coss (
packages/webhooks/AGENTS.md). Install upstream withnpx skills add cosscom/coss --skill webhooks. Copyright stays with the author.
packages/webhooks — webhooks, the delivery engine, log drains
Read together with the root AGENTS.md (conventions, tenancy rule, tests). Paths are from the
repository root.
- Webhooks: deliver only through
createWebhookSender(every resolved address must be public; the socket is pinned to the checked address; no redirects; 10 s timeout for coss.com's own webhooks and log drains, 15 s for Hooks; 1 KB response excerpt).allowPrivateNetworks/insecure URLs are for non-production only. Payloads never include bodies, headers, cc or bcc. Secrets are AES-256-GCM encrypted withWEBHOOK_SECRETS_KEY, a key ring (parseSecretsKeys): stored asv2:<key id>:<iv>:<ciphertext>:<tag>(thev2:<key id>prefix is the AAD; ids are derived from the keys), encrypted with the first key, decrypted with the one named. Keylessv1:rows from before are still read by trying every key untilwebhooks:reencryptrewrites them. Every write of a secret column goes throughencryptSecret, andreencryptWebhookSecretsmust cover every encrypted column (today the webhook and Hooks endpoints'secret_ciphertextandprevious_secret_ciphertext,log_drain.secret_ciphertextandrisk_settings.captcha_secret_ciphertext). Rotation runbook: README.md, "RotatingWEBHOOK_SECRETS_KEY". The initial upgrade from v1-only readers must use README.md's "Upgrading from v1 webhook secret storage" coordinated cutover, not a rolling deployment. - Delivery engine (
@coss/webhooks/engine): webhook deliveries, log drain batches (and future products) shareorganizationNotHeld(suspended or deleting orgs are held),deliveryOutcome(retry schedules; jitter only with an injectedrandom),sendAttempt,endpointHealthandrunClaimed; each product keeps its own tables and SQL. A claimed row'slease_expires_atis its fencing token: always bind it from a JSDate(leaseUntil), finish with astillOwnedupdate first, and write attempt rows, log lines and health changes only when that update matched, so a worker whose lease was taken over records nothing. - Endpoint health (
webhook_endpoint.failing_since,disabled_reasonmanual|failing,disabled_at; no CHECK ties them toenabled, since code running during a deploy's migration setsenabledalone, so a disabled endpoint without a reason reads asmanual): a delivery that failed every attempt starts a streak, any success ends it (both written only when they change), and one failing for good 5 days into a streak (DISABLE_FAILING_ENDPOINT_AFTER_MS) disables the endpoint with a systemwebhook_endpoint.disabledaudit event. Enabling it clears the streak.recoverWebhookEndpoint(POST /v1/webhook_endpoints/{id}/recover,webhook:update) requeues failed deliveries since a time, ≤ 1,000 per call, locking their parent messages first (in id order) likeretryWebhookDelivery, so retention can't purge what it requeued. - Secret rolls (
rollWebhookEndpointSecret,POST /v1/webhook_endpoints/{id}/roll_secret, the dashboard's Roll secret,webhook:update): one UPDATE moves the current secret toprevious_secret_ciphertextwithprevious_secret_expires_at(default a day, at most a week,0= none; both columns or neither, a CHECK) and stores a new one. Until then deliveries carry both signatures inwebhook-signature(signingSecrets, checked at each attempt), whichverifyWebhookalready accepts. A second roll drops the older previous secret, so rolls are not idempotent and the SDK never retries them. The new secret is returned only in that response; reads exposeprevious_secret_expires_at, never a secret. - Log drains (
log_drain(idsdrain_),log_drain_record(drainrec_),log_drain_batch(drainbat_),log_drain_attempt(drainatt_); management and delivery in@coss/webhooks, the outbox in@coss/logs): an environment's log lines (exactly theGET /v1/logsobjects) and its environment-scoped audit events ({ object: "audit_event", … }; org-wide events never reach drains), filtered bysources.appendLogEntriesandrecordOrgAuditEventselect the environment's enabled drains underFOR KEY SHAREin the transaction that writes the line and its records (a savepoint inside the caller's); management and exhaustion takeFOR UPDATEbefore changing eligibility — so everylog_entrywrite path queues drain records without racing disable/delete; record ids ascend in write order and order delivery. The worker'slog-drainsloop (runDueLogDrainDeliveries, system-level) forms one open batch per drain at a time (partial unique index; the drain is lockedFOR NO KEY UPDATE SKIP LOCKEDso it never blocks lines being queued), ≤ 500 lines / 1 MB (takeBatch), and posts it throughcreateWebhookSenderasapplication/x-ndjson, Standard Webhooks-signed withwebhook-id= the batch id (stable across retries). Retries back off over ~a day (LOG_DRAIN_RETRY_DELAYS_MS); an exhausted batch disables the drain (disabled_reason = 'failing'), drops its backlog and records a systemlog_drain.disabledaudit event. Disabling by hand also drops the backlog; nothing is queued for a disabled drain. Suspended organizations' drains are held. Drain deliveries are never written tolog_entry(that would feed itself). Retention (purgeExpiredLogDrainBatches): finished batches afterRETENTION_DAYS.logDrainBatches, unbatched lines afterlogDrainBacklog. Lines are not versioned (likeGET /v1/logs). Permissions:logDrain(members read; API keys all, backfilled by0040); revealing a secret needslogDrain:update. - Webhook listeners (
coss listen;webhook_listener(idswhlis_),webhook_listener_delivery(whlisdel_),src/listeners.ts,/v1/webhook_listeners*,coss.webhookListeners): coss.com can't POST tolocalhost, so the CLI pulls. A listener is not an endpoint (own tables), so the worker's delivery loop, plan limits, endpoint health, recover and the endpoint API never see one;enqueueWebhookMessagefans out to the environment's open listeners (expires_at > now(),FOR KEY SHARE) next to its endpoints, and writes the message if either subscribed. Listeners stay open while polled (touchWebhookListener: +LISTENER_IDLE_TTL_MS, 10 min) and are closed softly (expires_at = now, never a DELETE racing a fan-out insert);purgeExpiredWebhookListenersremoves them a day after expiry (cascading their deliveries; a purged message takes its listener deliveries too). At mostMAX_LISTENERS_PER_ENVIRONMENT(5) open per environment.pollWebhookListener: touch, record acks, then claim/wait (intervalMssleeps outside any connection; ≤ 25 s, ends on the request's or the server'sshutdownSignal). Claims are leased (leaseUntil, 2 min) and the lease is the delivery'sack_token; acks are fenced by it (stillOwned) and write onewebhook.forwardlog line each — nowebhook_attempt, no retry (a failed forward is final), no endpoint health. Unacknowledged deliveries are handed out again (sameevent_id) untilMAX_HAND_OUTS. Bodies are rendered per listenerapi_versionand returned as a string (never rewritten by response versioning). Listeners hold no secret: the CLI signs what it forwards (Standard Webhooks) with its ownwhsec_, soreencryptWebhookSecretshas nothing to cover. Polls are not request-logged (apps/api/src/middleware/request-log.ts) nor traced (@coss/observability); creating one is audited (webhook_listener.created).
