Inbound webhooks
September 12, 2026 · View on GitHub
Package-centered HTTP ingress that dispatches third-party POST payloads to a
bound saved-package export. End-user setup lives in
docs/use/webhooks.md.
Why this exists
Package-invocation HTTP endpoints require Authorization: Bearer. Many webhook
providers cannot set custom Authorization headers. Webhook endpoints are the
external HTTP knock: credential-in-URL sibling of per-user
email inboxes, declared alongside other package
surfaces in package.json#kody.webhooks (same family as kody.subscriptions).
First-party trusted clients use the same path (URL secret, no Bearer).
Invocation tokens are an unadvertised drain; see
0048.
Manifest contract
Packages declare webhooks as an array under kody.webhooks. Each entry has a
slug name, an export that must exist in package.json#exports (one name ↔
one export; no *), optional responseMode (ack default / sync), optional
inputMode (request default / params), optional rateLimitPerMinute
(default 60, max 600), optional HMAC verification that references a
secret-store name (secretName) — never an inline secret — and optional
replay for timestamp windows and delivery-id dedupe. Parsing and export
existence checks live in parseAuthoredPackageJson / listPackageWebhooks
(packages/worker/src/package-registry/).
HMAC verification signs the raw body by default (signedPayload omitted or
'body'). Set signedPayload to 'timestamp.body' when the provider HMAC
covers `${timestamp}.${rawBody}` (Stripe). Replay protection is
opt-in: body-only HMAC without replay does not bind a timestamp or
delivery id, so a captured signed payload can be replayed until the URL secret
is rotated.
replay fields:
timestampHeader— header that carries the timestamp (required withtimestampFormat)timestampFormat—unix-seconds|unix-millis|iso-8601|stripe-signature(stripe-signaturereadst=<unix>from a Stripe-style header). Unknown formats fail publish-time validation.toleranceSeconds— optional; defaults to 300 whentimestampHeaderis setdeliveryIdHeader— unique delivery id; dispatches usesha256(userId + packageId + webhookName + deliveryId)as the package-invocation idempotency key. The keyed ledger matches that key alone (idempotencyParamsHash: 'ignore'): retries changereceivedAt(and often headers), and a later body for the same id is still that delivery. A different delivery id hashes to a different key, so it cannot reuse another event's acknowledgement.
inputMode: "params" is the first-party trusted-client contract. The bound
export's first argument is the parsed JSON object. When that object has a
params property that is itself a JSON object, the platform unwraps it
(invoke-token envelope). Idempotency-Key (or JSON idempotencyKey in params
mode) maps to the same package-invocation ledger with payload hashing
(include): same key + same first argument replays. On sync, mismatch and
in-progress are 409. ack returns 202 after enqueue; the queue consumer
applies the same ledger asynchronously. Request-mode caller keys hash the JSON
body so receivedAt does not break retries. Delivery-id keys stay ignore.
HMAC stays optional; the URL secret is enough for a trusted client.
rateLimitPerMinute overrides the default 60/min ceiling per minted endpoint.
600/min is the documented maximum for gateway fan-in. The limiter still bounds a
leaked URL.
Declaring a webhook does not open ingress. A minted URL secret in D1 does.
Ingress path
Route: POST /@:username/webhooks/:packageKodyId/:webhookName/:urlSecret
- Worker
fetchinpackages/worker/src/index.tsmatches the path early. The path is also registered inroutes.ts/router.ts, and/@*/webhooks/*is inrun_worker_firstfor all Wrangler environments. - Resolve username → user; resolve
packageKodyIdto a saved package owned by that user; load minted row keyed by(user_id, package_id, webhook_name). - Unminted, disabled, missing declaration (after republish rename/remove), or URL-secret mismatch → 404 (indistinguishable). URL-secret mismatches do not record delivery history (avoids log-flush DoS and rate-limit side channels).
- Constant-time compare the URL secret against
url_secret_hash(SHA-256) and, during rotate overlap, the previous hash. The previous URL stays active for 24 hours or until the first POST on the new URL that is accepted for dispatch (ack enqueue or sync invoke), whichever comes first. HMAC, rate limit, payload, and declaration rejects do not retire the previous URL. An expired previous hash is treated as unknown. - After a matching URL secret, enforce per-webhook rate limit (declared
rateLimitPerMinutewhen the name is still live, otherwise the default 60, max 600) → 429 (no delivery history on the limited path). Missing declaration after republish rename/remove still 404s and records a rejected delivery, but only after that limit. Payload cap 1 MB → 413. - When verification is declared, resolve
secretNamefrom the owner's secret store (user/package scope via package storage context). Missing secret or HMAC mismatch → 401, with a clear delivery-log error for missing secrets. Whenreplay.timestampHeaderis declared, a missing, unparseable, or stale timestamp is rejected with the same generic 401 before dispatch (and before any run record that implies acceptance). Whenreplay.deliveryIdHeaderis declared, a missing id is rejected the same way; present ids become the invocation idempotency key. - Dispatch via
invokePackageExportwith a synthetic internal token scoped to the owning user / package / export,source: 'webhook'. ack: await enqueue tokody-webhook-dispatch, then return 202. The queue consumer owns the full invocation and its terminal writes, so work is not tied to the post-responsewaitUntilwindow. A failed enqueue returns 503 so the provider can retry. Queue messages omit reconstructedparams.request.json(the consumer parsesbody) and spillbodytoBUNDLE_ARTIFACTS_KVunderwebhook-dispatch-payload:v1:{userId}:{deliveryId}when the serialized message would exceed a conservative 120 KB ceiling beneath Cloudflare Queues' 128 KB limit.sync: await (30s) and return export JSON, 502 on failure.- Authenticated deliveries (and post-auth rejects such as HMAC / size / missing
declaration) record a
webhooksurface run record (no payload body). See Run records. URL-secret mismatches and pre-auth rate limits still write no delivery history.
Ack messages carry the accepted delivery id, idempotency key, scoped endpoint
identity, export name, and already-authenticated payload (inline body, or a
user-scoped KV key when the body was spilled). Queue retries reuse that exact
idempotency key. Request-mode caller Idempotency-Key messages set
callerIdempotency so the consumer hashes the JSON body (same as sync).
Unique-key ack claims omit that flag and hash the { webhook, request }
envelope. Transient ledger lookup/terminal-persistence failures and
still-in-progress replays are retried; terminal package errors are recorded and
acknowledged. A missing spilled body is a terminal failure
(ack_queue_payload_missing). The package export sandbox retains its normal
~90s budget, so genuinely longer package work ends as an explicit timeout rather
than an unknown interrupted outcome.
The Queue consumer batch size is one. Processing is sequential and one export can consume the full sandbox budget, so larger batches could exceed the Queue consumer's 15-minute wall-clock limit before later messages are acknowledged.
Isolation
- Every D1 row carries
user_id. Capabilities always bindrequireMcpUser(...).userId. - Ingress may look up by username + package name leaf + webhook name, then immediately re-scopes by the owning user.
- Account deletion/export include
webhook_endpoints(minted URL state). Delivery history lives in run records and is covered with the rest ofRunLogexport/deletion. Export redactsurl_secret_hash,url_secret_encrypted, andprevious_url_secret_hash. - Plaintext URL secrets and verification secrets are never logged. URL secrets
are hashed for ingress and stored encrypted for
webhookUrlApplyand the owner reveal in package settings. MCP mint, rotate, list, and apply never return the credential URL. Verification secrets stay in the secrets primitive.
Owner UI
Webhooks belong to the package that declares them, so the owner surface is the
Webhooks section of package settings (/@:username/:kodyId/settings,
packages/worker/client/routes/package-webhook-settings.tsx with one
package-webhook-card.tsx per declared webhook). Its JSON companion is
/profiles/:username/packages/:kodyId/webhooks.json
(packages/worker/src/app/handlers/package-webhooks.ts). The handler is
owner-only: the signed-in user must be :username and own :kodyId, otherwise
it answers 404 without naming the package or its webhooks. GET returns
listWebhooksForUser filtered to the package and joined with urlRecoverable
and previousUrlActiveUntil, never the URL. POST { intent, webhookName }
intents mint, rotate, and reveal return the refreshed list plus a
revealed entry built by revealWebhookUrlForWebsite (decrypt
url_secret_encrypted, rebuild the ingress path from the request origin);
enable / disable return the list only. Every intent writes an account
audit event (webhook_url_mint, webhook_url_rotate, webhook_url_reveal,
webhook_enable, webhook_disable). mint refuses an already-minted webhook
so a stray click cannot rotate a provider's URL; reveal refuses mints without
url_secret_encrypted and points at Rotate. The client keeps revealed URLs in
memory only and drops them on Hide or when the settings page changes package.
/account/webhooks (packages/worker/client/routes/account-webhooks.tsx,
packages/worker/src/app/handlers/account-webhooks.ts) is a thin cross-package
index. GET /account/webhooks.json lists every declared webhook (never a URL)
and each row deep-links to /@:username/:kodyId/settings#webhook-<name>. The
account API has no mutating intents.
revealWebhookUrlForWebsite is website-only: no MCP capability, execute
binding, or apply result may return the credential URL.
Storage
Minted endpoint state lives in the D1 webhook_endpoints table defined by
packages/worker/migrations/0001-squashed-init.sql, with url_secret_encrypted
added in 0057-webhook-url-secret-encrypted.sql and rotate-overlap columns in
0062-webhook-url-rotation-grace.sql. Rotate copies the outgoing hash to
previous_url_secret_hash with previous_url_secret_expires_at 24 hours out.
The previous ciphertext is not stored: reveal and apply always rebuild the
current URL. webhookUrlMint / webhookUrlRotate return an opaque handle
(whh_<id>) and url_host. webhookUrlApply resolves the handle inside Kody
and registers the URL through a first-class destination adapter (GitHub
repository hooks via the user GitHub integration or a host-approved GitHub
token). The credential is injected into the provider API field; apply does not
accept an arbitrary outbound URL. Delivery history is in the per-user RunLog
Durable Object (webhook surface), not in D1. See
Data storage and Run records.