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:

  • name is a slug unique within the package.
  • export must reference a declared package.json#exports entry (validated at save/publish). One webhook name ↔ one export. There is no wildcard export.
  • responseMode is ack (default) or sync.
  • inputMode is request (default) or params. Vendor handlers stay on request. First-party trusted clients that send invoke-shaped JSON use params (see Trusted clients).
  • rateLimitPerMinute is optional. Default 60. Maximum 600 (gateway fan-in). A leaked URL is still bounded; there is no unlimited setting.
  • verification.secretName references 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 omit verification and rely on the URL secret.
  • verification.signedPayload is 'body' (default) or 'timestamp.body'. Use 'timestamp.body' when the provider HMAC covers `${timestamp}.${rawBody}`.
  • replay is 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 send Idempotency-Key instead of replay.deliveryIdHeader.

Declaring a webhook does not open ingress by itself.

Mint a handle

Use the MCP webhooks domain:

  1. Save/publish the package with kody.webhooks.
  2. Store the HMAC secret with secretSet under the name used in verification.secretName (for example sentryWebhookSecret).
  3. Call webhookUrlMint with the scoped package name (or package_id when the name is not known) and webhookName.
  4. Call webhookUrlApply with the returned handle and a first-class destination. GitHub repository hooks use the connected github integration (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 MB413.
  • Rate limit per minted webhook → 429. Default 60/min; override with rateLimitPerMinute up 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.