Inbound webhooks
September 13, 2026 · View on GitHub
Kody inbound webhooks are package-centered: you declare them in
package.json#kody.webhooks, mint an opaque handle with webhookUrlMint, then
register the credential with webhookUrlApply (GitHub repository hooks) or copy
the URL yourself from the package's
settings page. MCP and execute never
return the credential URL or url_secret. Each delivery invokes the bound
package export.
This is the HTTP sibling of email primitives. Webhooks are the external HTTP knock: vendor providers (Sentry, GitHub, Stripe) and first-party trusted clients (gateway proxies, CLIs) both POST to a minted URL. Package invocation bearer tokens are an unadvertised drain; new callers use webhooks.
There is no * / multi-export URL. One declared webhook name binds one export.
Treat every minted URL as a credential. Mint and rotate return an opaque
handle (and url_host), never the URL or url_secret. Register the URL with
webhookUrlApply so the credential stays inside Kody, or open the package's
settings page and copy it yourself
for providers without an apply adapter. MCP and execute never return the URL;
the owner does that from package settings.
Declare a webhook in the package manifest
{
"name": "@you/sentry-bridge",
"exports": {
"./handle-sentry-webhook": "./src/handle-sentry-webhook.ts"
},
"kody": {
"id": "sentry-bridge",
"description": "Forward Sentry webhooks into automations",
"webhooks": [
{
"name": "sentry",
"export": "./handle-sentry-webhook",
"responseMode": "ack",
"verification": {
"type": "hmac-sha256",
"header": "sentry-hook-signature",
"secretName": "sentryWebhookSecret",
"encoding": "hex"
}
}
]
}
}
Rules:
nameis a slug unique within the package.exportmust reference a declaredpackage.json#exportsentry (validated at save/publish). One webhook name ↔ one export. There is no wildcard export.responseModeisack(default) orsync.inputModeisrequest(default) orparams. Vendor handlers stay onrequest. First-party trusted clients that send invoke-shaped JSON useparams(see Trusted clients).rateLimitPerMinuteis optional. Default 60. Maximum 600 (gateway fan-in). A leaked URL is still bounded; there is no unlimited setting.verification.secretNamereferences a named secret in your secret store — never an inline secret value. The platform resolves it at delivery time; if the secret is missing, the delivery is rejected and logged. First-party trusted clients omitverificationand rely on the URL secret.verification.signedPayloadis'body'(default) or'timestamp.body'. Use'timestamp.body'when the provider HMAC covers`${timestamp}.${rawBody}`.replayis optional. Without it, body-only HMAC is replayable: anyone who observes one legitimate signed delivery can POST it again. Opt in per webhook with a timestamp window and/or a unique delivery id. Trusted clients sendIdempotency-Keyinstead ofreplay.deliveryIdHeader.
Declaring a webhook does not open ingress by itself.
Mint a handle
Use the MCP webhooks domain:
- Save/publish the package with
kody.webhooks. - Store the HMAC secret with
secretSetunder the name used inverification.secretName(for examplesentryWebhookSecret). - Call
webhookUrlMintwith the scoped package name (orpackage_idwhen the name is not known) andwebhookName. - Call
webhookUrlApplywith the returnedhandleand a first-class destination. GitHub repository hooks use the connectedgithubintegration (or a host-approved GitHub token). For any other provider, tell the owner to copy the URL from the package's settings page (/@<username>/<packageKodyId>/settings#webhooks).
Other capabilities: webhookList (declarations joined with minted handle /
enabled state), webhookUrlRotate, webhookEnable, webhookDisable, and
webhookDeliveryList (metadata only; bodies are never stored). The same
delivery history also appears under Activity
(/account/activity and the runs capabilities). List, mint, rotate, and apply
never return the credential URL.
Manage webhook URLs in package settings
Webhooks belong to the package that declares them, so the signed-in owner
manages their URLs on that package's settings page:
/@<username>/<packageKodyId>/settings, Webhooks section (for example
/@kentcdodds/raycast/settings#webhooks). The section lists every webhook the
package declares, joined with its minted state, one card per webhook:
- Mint URL issues the first credential for a declared webhook and shows the URL once so you can paste it into the provider.
- Reveal URL shows a minted URL again, with a copy button. Each reveal is written to the account audit log.
- Rotate URL replaces the secret. The previous URL stays active for 24
hours, or until the first accepted delivery arrives on the new URL. Rerun
webhookUrlApply(same handle) or paste the new URL into the provider. The card shows “Previous URL active until …” during that overlap. - Disable / Enable toggle ingress without deleting the mint. Disabled webhooks answer 404.
The URL is the human path. Agents get the handle and url_host through MCP and
either apply the handle to a supported destination or ask the owner to copy the
URL from package settings. Mints that predate encrypted secret storage cannot be
shown; the card offers Rotate for those.
/account/webhooks (account rail → Webhooks) is a read-only index of every
webhook across your packages. Each row links to the owning package's Webhooks
section; nothing is minted or revealed from the index.
Apply a handle to a destination
webhookUrlApply resolves the handle inside Kody and registers the URL through
a first-class destination adapter. The model never sees the secret. Apply does
not accept an arbitrary outbound URL: substituting the credential into a
caller-chosen request would let the model exfiltrate it.
GitHub (creates POST /repos/{owner}/{repo}/hooks on api.github.com):
await kody.webhooks.webhookUrlApply({
handle,
destination: {
type: 'github',
owner: 'acme',
repo: 'api',
events: ['push', 'pull_request'],
integration: 'github',
},
})
Authorize with the user's GitHub OAuth integration (default github) or a
host-approved GitHub token (secretName). Additional vendor adapters can follow
the same pattern (known host + known API). Other providers still POST to the
minted ingress URL; apply does not register them — the owner copies the URL from
the package's settings page instead.
Returns { ok, url_host, http_status, remote_id, error }. Remote bodies that
echo the hook URL are stripped.
Ingress URL
POST https://<origin>/@<username>/webhooks/<packageKodyId>/<webhookName>/<urlSecret>
- Unknown / unminted / disabled / renamed-away / wrong secret → 404 (no distinction).
- Payload > 1 MB → 413.
- Rate limit per minted webhook → 429. Default 60/min; override with
rateLimitPerMinuteup to 600. ack: 202{ "ok": true }after Kody durably queues the delivery; the export runs in the background. Bodies use the same 1 MB cap as sync; oversized queue messages spill to ephemeral storage until the consumer runs. A temporary queue failure returns 503 so the provider can retry without Kody claiming acceptance.sync: waits for the export JSON result (502 on failure).
Background delivery retries keep the same idempotency key, so a transient platform persistence failure does not duplicate a completed export. Package exports still have the normal execution limit (about 90 seconds); packages that exceed it receive an explicit timeout failure and should split or checkpoint their work.
Payload shape seen by the package export
{
webhook: {
packageKodyId: string
name: string
receivedAt: string // ISO timestamp
}
request: {
method: string
contentType: string | null
headers: Record<string, string> // safe allowlisted subset, lowercase keys
body: string // raw text
json: unknown | null
}
}
Example export:
export async function handleSentryWebhook(input) {
const event = input.request.json
// ... automate ...
return { ok: true }
}
Signature verification examples
Sentry
{
"type": "hmac-sha256",
"header": "sentry-hook-signature",
"secretName": "sentryWebhookSecret",
"encoding": "hex"
}
GitHub
{
"type": "hmac-sha256",
"header": "x-hub-signature-256",
"secretName": "githubWebhookSecret",
"encoding": "hex",
"prefix": "sha256="
}
GitHub HMAC covers the raw body only. Add replay.deliveryIdHeader so a
replayed X-GitHub-Delivery is acknowledged without running the export again:
{
"name": "github",
"export": "./handle-github-webhook",
"verification": {
"type": "hmac-sha256",
"header": "x-hub-signature-256",
"secretName": "githubWebhookSecret",
"encoding": "hex",
"prefix": "sha256="
},
"replay": {
"deliveryIdHeader": "X-GitHub-Delivery"
}
}
Stripe
Stripe signs `${t}.${rawBody}` and sends both the unix timestamp and HMAC
in Stripe-Signature. Declare signedPayload: "timestamp.body" and a timestamp
window:
{
"name": "stripe",
"export": "./handle-stripe-webhook",
"verification": {
"type": "hmac-sha256",
"header": "Stripe-Signature",
"secretName": "stripeWebhookSecret",
"encoding": "hex",
"signedPayload": "timestamp.body"
},
"replay": {
"timestampHeader": "Stripe-Signature",
"timestampFormat": "stripe-signature",
"toleranceSeconds": 300
}
}
stripe-signature reads t=<unix> from the header. Deliveries whose timestamp
is missing, unparseable, or older than toleranceSeconds (default 300) are
rejected with the same generic 401 as a bad HMAC.
Trusted clients
A first-party caller (Discord gateway proxy, YouTube WebSub worker, Raycast
extension, social-launch client) mints a webhook URL and POSTs JSON. No
Authorization: Bearer. One webhook per export they actually call.
{
"name": "message-created",
"export": "./dispatch-message-created",
"responseMode": "sync",
"inputMode": "params",
"rateLimitPerMinute": 600
}
Use sync when the caller needs the export JSON (or a 409 idempotency
conflict). Use ack when the caller only needs acceptance; the queue consumer
applies the same idempotency ledger.
inputMode: "params" passes a JSON object as the export's first argument,
matching invocation-token params. If the body is the invoke envelope
({ "params": { … }, "idempotencyKey": "…" }), the platform unwraps params. A
top-level JSON object without a params object is the first argument as-is.
Arrays and non-objects are 400 invalid_params. Default
inputMode: "request" is unchanged: the export still receives
{ webhook, request }.
Send Idempotency-Key (standard header). In params mode, JSON
idempotencyKey is accepted when the header is absent. Same key + same payload
replays the stored result. On sync, a different payload is 409
idempotency_mismatch and an in-progress key is 409
invocation_in_progress. ack still returns 202 after enqueue; the
consumer records the ledger outcome. This works without HMAC — the URL secret is
the credential.
Caller keys use the same package-invocation idempotency ledger as
replay.deliveryIdHeader. Delivery-id keys still match by id alone (vendor
retries change receivedAt). Caller keys hash the payload: in params mode
that is the export first argument; in request mode it is the JSON body so
receivedAt does not break retries.
Do not declare a * webhook. A client that calls several exports gets one
webhook declaration per export.
Lifecycle
Republishing a package that removes or renames a webhook deactivates that
ingress (unknown name → 404). Disable with webhookDisable without deleting the
mint; re-enable with webhookEnable. Rotate the URL secret with
webhookUrlRotate when a credential may have leaked, then call
webhookUrlApply again with the same handle so providers get the new URL. The
settings card describes the 24-hour
/ first-accepted-delivery overlap. The same disable, enable, and rotate actions
are in that Webhooks section.
Related
- Packages: Packages
- Architecture: Inbound webhooks
- Secrets: Secrets and host approval