Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/contributing/architecture/data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,13 @@ on write unless a migration backfills existing rows.
delivery details. Provider event ids are unique for idempotent Queue
ingestion; `email_messages.delivery_status` is the latest provider state,
separate from send-request `processing_status`.
- `webhook_endpoints` / `webhook_deliveries` (`0090-webhook-endpoints.sql`)
store per-user minted URL state for `package.json#kody.webhooks` and recent
delivery metadata, keyed by `(user_id, package_id, webhook_name)`. URL secrets
are SHA-256 hashed; verification secrets stay in the secrets primitive
(`secretName` at delivery time). Delivery rows never store payload bodies and
are pruned to the newest ~50 per minted webhook on insert. See
[Inbound webhooks](./webhooks.md).
- `system_email_daily_counters` (`0051-system-email-daily-counters.sql`) stores
fixed per-local daily receive counters for operator-owned system inboxes.
These counters are not user entitlements and are pruned by the system-email
Expand Down
3 changes: 3 additions & 0 deletions docs/contributing/architecture/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ to become.
Classify PR paths with
`node .agents/skills/visual-recap/scripts/classify-primitives.mjs`; validate
with `npm run primitives:check`.
- [Inbound webhooks](./webhooks.md): user-owned `POST /@:username/webhooks/...`
ingress that dispatches to a bound saved-package export (HMAC verification,
ack/sync modes, delivery log).
- [Remote connectors](./remote-connectors.md): generic outbound WebSocket
protocol, URLs, secrets, and MCP caller context for any `kind` / instance.
- [MCP client servers](./mcp-client-servers.md): user-added remote MCP servers
Expand Down
13 changes: 13 additions & 0 deletions docs/contributing/architecture/primitives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,19 @@ primitives:
- packages/worker/src/email/
- packages/worker/src/mcp/capabilities/email/

- id: webhooks
group: assistant
name: Inbound webhooks
summary: Package-declared webhooks with minted URLs dispatching to exports.
code:
- packages/worker/src/webhooks/
- packages/worker/src/mcp/capabilities/webhooks/
- packages/worker/src/app/handlers/webhook-ingress.ts
- packages/worker/src/package-registry/
docs:
- docs/use/webhooks.md
- docs/contributing/architecture/webhooks.md

- id: remote-connectors
group: assistant
name: Remote connectors
Expand Down
13 changes: 10 additions & 3 deletions docs/contributing/architecture/request-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,27 @@ Requests are handled in this order:
- `/.well-known/oauth-protected-resource/mcp`
5. MCP endpoint:
- `/mcp` (requires OAuth bearer token)
6. Remote connector session endpoints (internal-only Worker routes that proxy
6. Public `@username` ingress handled in `packages/worker/src/index.ts` before
the OAuth provider / app router (needs `ExecutionContext` for background
work):
- `POST /@{username}/api/package-invocations/:kodyId/:exportName` — bearer
token package invocations
- `POST /@{username}/webhooks/:packageKodyId/:webhookName/:urlSecret` —
inbound package webhooks (see [Inbound webhooks](./webhooks.md))
7. Remote connector session endpoints (internal-only Worker routes that proxy
WebSocket upgrades and JSON-RPC helper requests to the remote connector
session Durable Object):
- `/@{username}/connectors/:kind/:instanceId...` — username + **`kind`** +
instance

See [Remote connectors](./remote-connectors.md).

7. Static assets:
8. Static assets:
- Served from `ASSETS` for `GET` and `HEAD` when available
- Matching files under `packages/worker/public/` are asset-first at the edge
(they do not enter this Worker list). That includes OpenAI Apps domain
verification at `/.well-known/openai-apps-challenge`.
8. App server routes:
9. App server routes:
- Everything else is handled by `packages/worker/src/app/handler.ts`

## Package service runtime
Expand Down
65 changes: 65 additions & 0 deletions docs/contributing/architecture/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Inbound webhooks

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`](../../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
credential-in-URL sibling of per-user [email](../../use/email-primitives.md)
inboxes, declared alongside other package surfaces in
`package.json#kody.webhooks` (same family as `kody.subscriptions`).

## 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`, optional
`responseMode` (`ack` default / `sync`), and optional HMAC `verification` that
references a secret-store name (`secretName`) — never an inline secret. Parsing
and export existence checks live in `parseAuthoredPackageJson` /
`listPackageWebhooks` (`packages/worker/src/package-registry/`).

Declaring a webhook does **not** open ingress. A minted URL secret in D1 does.

## Ingress path

Route: `POST /@:username/webhooks/:packageKodyId/:webhookName/:urlSecret`

1. Worker `fetch` in `packages/worker/src/index.ts` matches the path early so
`ExecutionContext.waitUntil` is available for ack-mode dispatch. The path is
also registered in `routes.ts` / `router.ts`, and `/@*/webhooks/*` is in
`run_worker_first` for all Wrangler environments.
2. Resolve username → user; resolve `packageKodyId` to a saved package owned by
that user; load minted row keyed by `(user_id, package_id, webhook_name)`.
3. Unminted, disabled, missing declaration (after republish rename/remove), or
URL-secret mismatch → **404** (indistinguishable).
4. Constant-time compare the URL secret against `url_secret_hash` (SHA-256).
5. Enforce per-webhook rate limit (~60/min) → **429**; payload cap 1 MB →
**413**.
6. When verification is declared, resolve `secretName` from 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.
7. Dispatch via `invokePackageExport` with a synthetic internal token scoped to
the owning user / package / export, `source: 'webhook'`.
8. `ack`: **202** + `waitUntil`. `sync`: await (30s) and return export JSON,
**502** on failure.
9. Every request records a `webhook_deliveries` row (no payload body); ~50 rows
retained per minted webhook.

## Isolation

- Every D1 row carries `user_id`. Capabilities always bind
`requireMcpUser(...).userId`.
- Ingress may look up by username + kody id + webhook name, then immediately
re-scopes by the owning user.
- Account deletion/export include `webhook_deliveries` then `webhook_endpoints`.
Export redacts `url_secret_hash`.
- Plaintext URL secrets and verification secrets are never logged. URL secrets
are hashed; verification secrets stay in the secrets primitive.

## Storage

See migration `0090-webhook-endpoints.sql` and
[Data storage](./data-storage.md).
2 changes: 2 additions & 0 deletions docs/use/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Read in order for a full tour, or jump to a topic.
- [Secrets, values, and host approval](./secrets-and-values.md)
- [Email primitives](./email-primitives.md) — the per-user inbox, notify-self
sends, and replies
- [Inbound webhooks](./webhooks.md) — user-owned HTTPS endpoints that dispatch
provider POSTs to a saved-package export
- [Mutating actions and confirmations](./mutating-actions.md)
- [Privacy](./privacy.md) — what Kody stores and what deployment admins can see
- [Troubleshooting](./troubleshooting.md)
Expand Down
10 changes: 10 additions & 0 deletions docs/use/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ Important fields:
- `kody.services` — optional package-owned service runtimes
- `kody.subscriptions` — optional event-topic subscriptions with package-local
handlers
- `kody.webhooks` — optional inbound webhook declarations bound to package
exports (mint a credential URL separately; see
[Inbound webhooks](./webhooks.md))
- `kody.jobs` — optional package-owned schedules

`package.json` is the manifest.
Expand Down Expand Up @@ -407,6 +410,13 @@ to packages saved by admin users; its payload adds an `admin_url` link to the
message in the admin interface. See [Email primitives](./email-primitives.md)
for the full payload shapes.

## Package webhooks

Inbound HTTP webhooks are declared under `package.json#kody.webhooks` and bound
to a package export. Declaring a webhook does not open ingress — mint a URL with
`webhook_url_mint` first. Full contract, signature examples, and payload shape:
[Inbound webhooks](./webhooks.md).

## Package-owned jobs

Packages can own jobs, and Kody also supports schedules that are not owned by a
Expand Down
1 change: 1 addition & 0 deletions docs/use/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ does not let admins browse:
- Private packages and their source
- Jobs
- Email inboxes and messages
- Inbound webhook endpoints and delivery logs
- Chat threads
- Durable storage contents
- Remote connector configuration
Expand Down
145 changes: 145 additions & 0 deletions docs/use/webhooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Inbound webhooks

Kody inbound webhooks are **package-centered**: you declare them in
`package.json#kody.webhooks`, mint a per-user credential URL, then point a
provider (Sentry, GitHub, Stripe, or any generic sender) at that URL. Each
delivery invokes the bound package export.

This is the HTTP sibling of [email primitives](./email-primitives.md). Unlike
[package invocation bearer tokens](../contributing/package-invocation-api.md),
webhook URLs work with providers that cannot set custom `Authorization` headers.

Treat every minted URL as a **credential**. The URL secret is returned only on
mint/rotate and is never stored in plaintext.

## Declare a webhook in the package manifest

```json
{
"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).
- `responseMode` is `ack` (default) or `sync`.
- `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.

Declaring a webhook does **not** open ingress by itself.

## Mint the URL

Use the MCP `webhooks` domain:

1. Save/publish the package with `kody.webhooks`.
2. Store the HMAC secret with `secret_set` under the name used in
`verification.secretName` (for example `sentryWebhookSecret`).
3. Call `webhook_url_mint` with the package id/kody id and `webhookName`.
4. Store the returned `url` immediately and paste it into the provider.

Other capabilities: `webhook_list` (declarations joined with minted/enabled
state), `webhook_url_rotate`, `webhook_enable`, `webhook_disable`, and
`webhook_delivery_list` (metadata only; bodies are never stored).

## Ingress URL

`POST https://<origin>/@<username>/webhooks/<packageKodyId>/<webhookName>/<urlSecret>`

- Unknown / unminted / disabled / renamed-away / wrong secret → **404** (no
distinction).
- Payload > **1 MB** → **413**.
- About **60 requests/minute** per minted webhook → **429**.
- `ack`: **202** `{ "ok": true }` immediately; export runs in the background.
- `sync`: waits for the export JSON result (**502** on failure).

## Payload shape seen by the package export

```ts
{
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:

```js
export async function handleSentryWebhook(input) {
const event = input.request.json
// ... automate ...
return { ok: true }
}
```

## Signature verification examples

### Sentry

```json
{
"type": "hmac-sha256",
"header": "sentry-hook-signature",
"secretName": "sentryWebhookSecret",
"encoding": "hex"
}
```

### GitHub

```json
{
"type": "hmac-sha256",
"header": "x-hub-signature-256",
"secretName": "githubWebhookSecret",
"encoding": "hex",
"prefix": "sha256="
}
```

## Lifecycle

Republishing a package that removes or renames a webhook deactivates that
ingress (unknown name → 404). Disable with `webhook_disable` without deleting
the mint; re-enable with `webhook_enable`. Rotate the URL secret with
`webhook_url_rotate` when a credential may have leaked.

## Related

- Packages: [Packages](./packages.md)
- Architecture: [Inbound webhooks](../contributing/architecture/webhooks.md)
- Secrets: [Secrets, values, and host approval](./secrets-and-values.md)
3 changes: 3 additions & 0 deletions docs/use/what-can-kody-do.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ credentials, saved code, schedules, and execution.
- **A personal email inbox** — every user gets an address; inbound mail can
trigger automations, and your assistant can send you notifications. See
[Email primitives](./email-primitives.md).
- **Inbound webhook endpoints** — HTTPS URLs for Sentry, GitHub, Stripe, or any
provider that cannot set Bearer tokens; each endpoint dispatches to a
saved-package export. See [Inbound webhooks](./webhooks.md).
- **Long-term memory** — durable memories that surface across conversations and
across every agent connected to the same account. See
[Memory and conversation context](./memory.md).
Expand Down
45 changes: 45 additions & 0 deletions packages/worker/migrations/0090-webhook-endpoints.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- Per-user minted URL state for package.json#kody.webhooks declarations.
-- Declaring a webhook in the manifest does not open ingress; minting a URL
-- secret does. Verification secrets are never stored here — they are resolved
-- by secretName from the user's secret store at delivery time.

CREATE TABLE IF NOT EXISTS webhook_endpoints (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
package_id TEXT NOT NULL,
webhook_name TEXT NOT NULL,
url_secret_hash TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
created_at TEXT NOT NULL,
rotated_at TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_webhook_endpoints_user_package_name
ON webhook_endpoints(user_id, package_id, webhook_name);

CREATE INDEX IF NOT EXISTS idx_webhook_endpoints_user_created_at
ON webhook_endpoints(user_id, created_at);

-- Debug log of ingress outcomes. Payload bodies are never stored.
CREATE TABLE IF NOT EXISTS webhook_deliveries (
id TEXT PRIMARY KEY,
endpoint_id TEXT NOT NULL,
user_id TEXT NOT NULL,
package_id TEXT NOT NULL,
webhook_name TEXT NOT NULL,
received_at TEXT NOT NULL,
outcome TEXT NOT NULL CHECK (outcome IN ('delivered', 'rejected', 'failed')),
http_status INTEGER NOT NULL,
error TEXT,
payload_bytes INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (endpoint_id) REFERENCES webhook_endpoints(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_endpoint_received_at
ON webhook_deliveries(endpoint_id, received_at DESC);

CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_user_received_at
ON webhook_deliveries(user_id, received_at DESC);

CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_user_package_name_received_at
ON webhook_deliveries(user_id, package_id, webhook_name, received_at DESC);
Loading
Loading