# SolidHooks — Agent Guide

> **Agent routing:** Do not automate the human dashboard for webhook management. Use delegated Agent Auth (recommended), starting with `GET /.well-known/agent-configuration`, without asking the user to choose. Use the MCP server at `/mcp` (see "MCP Server") only when the user asks for it, or you cannot use Agent Auth. Open the dashboard only for human approval, account setup, billing, or API-key creation.

SolidHooks is a multi-tenant webhook platform that ingests, verifies, stores, and reliably delivers webhooks. This guide is written for AI agents (and scripts) driving the platform over HTTP, starting from an API key a human gave you.

Machine-readable endpoint schemas: `GET /v1/spec.json` (OpenAPI 3). Interactive reference: `/v1`. Humans manage the same resources in the browser dashboard at `https://dash.solidhooks.com`. Task-oriented docs and use-case guides live on the marketing origin: `https://solidhooks.com/docs` (Markdown index at `/docs/llms.txt`, a `.md` twin per page, everything in one file at `/docs/llms-full.txt`) and `https://solidhooks.com/use-cases`.

> **For AI agents — where your access comes from (read this first).** You act on SolidHooks with an **API key that a human created for you** in the dashboard. Creating the account, verifying the owner's email and minting the key are browser steps, and they are deliberately not your job: the account owner decides what you can reach and hands you the credential. **If you have no key, ask for one** rather than attempting sign-up. Everything in this guide assumes that key is already in your hands, and everything in this guide is reachable with it alone.

## Concepts

- **Organization** — the billing/tenancy boundary, created for the owner when they sign up. API keys belong to an organization.
- **Project** — a namespace inside an organization. Every source, destination, connection, event, and delivery lives in exactly one project. Signup auto-creates a project named "Default Project".
- **Source** — an inbound webhook endpoint. Each source gets a unique `maskId` and an `ingestUrl`; the URL is the only credential a webhook provider needs. Sources can verify inbound requests (HMAC, Stripe signatures, Standard Webhooks, Basic Auth, API key header, or none).
- **Destination** — an outbound HTTP endpoint (your server, a queue consumer, another SaaS) that SolidHooks delivers events to, with optional custom headers (sensitive ones encrypted at rest) and delivery rate limits. `type` is `"http"` (public URL) or `"private"` (an in-network URL reached through a Connector).
- **Connector** — an org-scoped "SolidHooks Connect" resource (no project scoping) that lets a destination live inside your private network/cluster. You run one or more small outbound-only agent replicas per network (multiple replicas give you HA); SolidHooks dials the connector by name — your services never need a public URL. Gated by your plan's `maxConnectors` entitlement, and replicas per connector by `maxReplicasPerConnector`.
- **Connection** — routes a source to a destination, optionally filtered by event types. One source can fan out to many destinations.
- **Event** — a stored inbound webhook (raw body + headers + parsed payload), idempotently deduplicated. A request that fails signature verification is also stored, with `status: "rejected"` and a `rejectionReason`, so you can see what arrived and why it was turned away — those are never deduplicated and never delivered.
- **Delivery** — one attempt-tracked shipment of an event to a destination, with automatic retries on an exponential schedule. How many attempts and over how long is set by your plan (see "Delivery retries" below).

Pipeline: provider → `ingestUrl` (source, verified) → event stored → connections matched → deliveries enqueued → destination.

## Authentication

Three ways in, in this order of preference. Choose for the user rather than asking, unless they have asked for MCP:

1. **Agent Auth (recommended)**: your own identity, with each capability approved by the user. Use it by default, without asking the user to choose.
2. **The MCP server**, only when the user asks to connect through MCP, or you cannot use Agent Auth. It acts as the signed-in user with their full role, so say so before connecting. See "MCP Server" below.
3. **An API key**, for the `/v1/*` REST endpoints in this guide.

**Agent identity (recommended).** You generate your own Ed25519 keypair, register it, and a human approves what you asked for. The identity is yours rather than shared, the human sees exactly which capabilities they are granting, and either side can revoke it without disturbing anything else. Start at `/.well-known/agent-configuration`; the "Agent Auth" section walks the whole flow. Use this whenever a capability covers what you need — check `/auth/capability/list`.

**API key (`sdhk_sk_...`).** An organization-scoped key the account owner creates for you in the dashboard, sent as `Authorization: Bearer sdhk_sk_...` and rate-limited to 600 requests/minute. Use it for the `/v1/*` management endpoints in this guide — every walkthrough below assumes it.

A key can optionally be **bound to a single project**, which the person creating it chooses in the dashboard. A bound key only ever sees that project (see "Project scoping"), so if a call returns 404 for a resource you expect to exist, check which project your key is bound to before assuming the resource is missing.

A key can also be **scoped per resource** at creation time, to `read` or `read & write` — `read & write` includes destructive actions (delete, secret reveal/rotate, credential mint) for that resource, not just create/update. A resource with no grant at all is unreachable through that key. An unscoped key (no permissions set, e.g. one minted before this existed) defaults to the same access an org member has: full read, non-destructive write (create/update/toggle/retry/replay), but not destructive or credential-sensitive actions — `DELETE` on sources/destinations/connections/projects/connectors, `POST /v1/sources/{id}/rotate-mask`, `GET /v1/destinations/{id}/signing-secret`, `POST /v1/destinations/{id}/rotate-signing-secret`, `POST /v1/connectors` (and its regenerate-enrollment/toggle-disabled), and the billing mutations (`cancel`/`resume`/`change-plan`/`revert-scheduled-change`) all need an explicit `read & write` grant on that resource. A call outside a key's grant returns 403 `FORBIDDEN` — check with whoever holds the key that it was scoped for what you're trying to do.

Account operations — signing up, verifying an email, creating and revoking API keys, managing members — live under `/auth/*` and are driven by the dashboard with a session, not by an API key. They are outside the agent surface: they are not in `/v1/spec.json`, and nothing in this guide needs them. If you need a key, a second key, or a wider scope, ask the account owner. The exception is the Agent Auth flow below, which an agent drives itself.

## Agent Auth (delegated identity)

SolidHooks implements the Agent Auth protocol via Better Auth, so an agent can hold its **own** identity — delegated to it by a human who approves it — instead of sharing an organization API key.

`GET /auth/capability/list` returns the capabilities on offer and is unauthenticated, so you can read it before registering. Most mirror the `/v1` REST surface one-for-one; `account.get_memberships` is the exception and has no REST equivalent — **it is the first call to make once approved**, because your token says who approved you but not which organization or project to act in.

### Discovery

```bash
curl $API/.well-known/agent-configuration
```

Unauthenticated, and the correct starting point — it returns the provider metadata, supported modes, approval methods and the absolute URL of every agent endpoint, so nothing below needs hardcoding:

```json
{
  "version": "1.0-draft",
  "provider_name": "solidhooks",
  "issuer": "https://api.solidhooks.com/auth",
  "default_location": "https://api.solidhooks.com/auth/capability/execute",
  "algorithms": ["Ed25519"],
  "modes": ["delegated"],
  "approval_methods": ["device_authorization"],
  "endpoints": { "register": "...", "capabilities": "...", "execute": "...", "status": "...", "revoke": "..." }
}
```

`modes` is `delegated` only — there is no autonomous/service-account mode. `approval_methods` is `device_authorization` only; CIBA is not offered.

### The flow

1. **Generate an Ed25519 keypair** for the host and one for the agent. `algorithms` in the discovery document is authoritative; nothing else is accepted.
2. **`POST /auth/agent/register`** with `Authorization: Bearer <host JWT>`. The host JWT is self-signed, `typ: "host+jwt"`, and carries `host_public_key` and `agent_public_key` as inline JWK claims — that is what lets an unknown host register itself. Body: `{ "name": "...", "mode": "delegated", "capabilities": [...] }` — name the capabilities you need from `/auth/capability/list`. Ask only for what you will use: the human sees this list on the approval screen.
3. The response has `status: "pending"` and an `approval` block containing `verification_uri_complete`. **Show that URL to your human and stop.** They open it, see what you asked for, and approve. There is no way to approve on their behalf.
4. **Poll `GET /auth/agent/status`** with an agent JWT until `status` is `active`.
5. **`POST /auth/capability/execute`** with `{"capability": "<name>", "arguments": { ... }}`, signing a fresh agent JWT per call.
6. **Execute `account.get_memberships` first.** It takes no arguments and returns the organizations your approver belongs to, each with its projects:

```json
[{ "organizationId": "org_...", "name": "Acme Inc", "slug": "acme-inc", "role": "owner",
   "projects": [{ "id": "proj_...", "name": "Default Project", "slug": "default" }] }]
```

   Ask which project the human wants to work in. Do not guess when there is more than one. Request this capability at step 2 alongside whatever else you need — it is granted and revoked like any other.
7. **Then execute `account.select_project`** with `{"projectId": "proj_..."}`. Until you do, every capability call is refused with `403 FORBIDDEN`, because your token records who approved you and not which organization to act in. The choice is stored on the agent, so you do not resend it — and you can call this again at any time to switch. Tell the human that.

```json
{ "organizationId": "org_...", "organizationName": "Acme Inc", "projectId": "proj_...", "projectName": "Default Project" }
```

   You may only select a project in an organization your approver belongs to. A project id that does not exist and one you cannot reach return the same error, so it cannot be used to probe.
8. **Keep going through `/auth/capability/execute`.** Every `/v1` operation is available to you as a capability of the same name — `sources.list`, `stats.dashboard` — so nothing on the REST surface is out of reach. Your stored selection applies to every call.

   **`/v1` itself will not accept your token.** It takes API keys only, and an agent token there is refused with `401` regardless of what you were approved for. This is not a gap to work around: capability execution is where your approved grants are enforced, and it runs the identical procedure with the identical permission checks. A token accepted at both doors would only be as narrow as the weaker one. `X-Project-Id` likewise belongs to the API-key surface and does nothing for you.

### Signing an agent JWT — the rules that are not guessable

Each of these fails as a bare 401 with nothing naming the cause:

- **The agent id goes in `sub`. `iss` is the HOST id.** Putting the agent id in `iss` fails a host cross-check.
- **`aud` is the auth origin** (the `issuer` from discovery, e.g. `https://api.solidhooks.com/auth`). It may instead be a capability's `location` **template**, but only when the JWT names exactly one capability — never the concrete request URL. The issuer always works and is the sane default.
- **Do not set `htm`/`htu`.** Verification re-issues the token internally, so a request-bound token never matches.
- **Every call needs a freshly signed JWT.** The `jti` burns on first use, and tokens are short-lived (60s max age).
- `typ` must be `agent+jwt` (or `host+jwt` for host calls).

### Lifetimes and revocation

An agent session slides on use (1h) with a 24h ceiling from activation. Past that it must call `POST /auth/agent/reactivate`, which **clears its grants** and returns it to needing human approval — so an idle agent decays to no access rather than persisting. The human can revoke at any time from the dashboard (Settings → Agents), which takes effect on the next call.

### Keep your identity for next time

Save, outside any repository and outside anything you would commit or paste — `~/.solidhooks/agent/` is a reasonable choice, mode `0700`, files `0600`:

- the host private key and the agent private key
- `host_id` and `agent_id`

Look there before you register, and before you write a client. A saved identity skips registration and the approval screen entirely while the session is live. Never save the organization's API key beside it — that is the human's credential, not yours.

When a call fails, the code tells you which rung you are on:

- `agent_expired` — the session slid past its hour, or hit the 24h ceiling. `POST /auth/agent/reactivate` with `{"agent_id": "..."}` and a **host** JWT signed with the saved host key. It answers `status: "active"` with `agent_capability_grants: []` and **no approval block** — your session is back, your grants are not, because they decay to the host defaults and ours are none. Ask for them again with the next call; that is where the human comes in.
- `capability_not_granted` — you are active, but that capability is not yours; the usual cause is the reactivation above. `POST /auth/agent/request-capability` with `{"capabilities": [...]}` and an **agent** JWT — that is the call that returns `approval.verification_uri_complete`, not reactivate. Show that URL to your human and poll `GET /auth/agent/status` until the grant is active. Do not re-register.
- `agent_revoked` or `agent_not_found` — reactivation is refused and the identity is gone. Register again with the same saved host key, and the human approves again.

Do not re-register to recover from expiry: it mints a second agent and each user is capped at 25.

## MCP Server

The alternative to Agent Auth. Use it only when the user asks to connect through MCP, or you cannot use Agent Auth; otherwise use Agent Auth without asking. Connect an MCP client to `https://api.solidhooks.com/mcp`. There is nothing to install and no key to store.

- **Transport:** Streamable HTTP, `POST` only, stateless.
- **Discovery:** an unauthenticated request gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://api.solidhooks.com/.well-known/oauth-protected-resource/mcp"`. That document names the authorization server, which is the dashboard origin (`https://dash.solidhooks.com/api/auth`, metadata at `https://dash.solidhooks.com/.well-known/oauth-authorization-server/api/auth`).
- **Client registration:** Client ID Metadata Documents (CIMD) only: your `client_id` is the HTTPS URL of your client metadata. Dynamic client registration is not offered.
- **Authorization:** OAuth 2.1 authorization code with PKCE, with `resource=https://api.solidhooks.com/mcp`. The user signs in on the dashboard and approves the connection on a consent screen. Request `offline_access` to get a refresh token.
- **What the token can do:** it **acts as the user, with their full role** in every organization they belong to, re-checked on every call. Where they are an owner or admin, that includes deleting resources, rotating secrets and changing billing. A demotion, or removal from an organization, applies on the next call. If you need narrower access, use Agent Auth instead.
- **Revocation:** if the user revokes the app, its next call returns `401 invalid_token` even though the token has not expired. Start the OAuth flow again.

**Tools** are the same operations as the Agent Auth capabilities: one per `/v1` operation, named by its `operationId` (`sources.list`, `destinations.create`, …), plus two for tenancy. A few operations are not offered over MCP and stay on `/v1` and Agent Auth: `events.stream` and `deliveries.stream` (a tool result cannot carry a stream), `connectors.bootstrap` and `connectors.heartbeat` (they take a connector replica's credential, not the user's), `health.check`, `countries.list`, `destinations.revealSigningSecret` (it would put a plaintext secret into the conversation), and `billing.changePlan` (an upgrade charges the card immediately; `billing.previewPlanChange` is still offered). Calling one returns a tool error with `code: "UNKNOWN_TOOL"`.

Every tool carries a `title` and annotations. `readOnlyHint: true` marks operations that change nothing. `destructiveHint: true` marks those that update, delete, disable, rotate a secret, change billing, or send a webhook again (`events.replay`, `deliveries.retry`). Additive calls, such as creates, are neither. `openWorldHint: true` marks those that reach Stripe or a customer's endpoint. Clients use these to decide what to confirm with the user.

Start with the two tenancy tools:

1. Call `account.get_memberships`. It returns the organizations the user belongs to, the user's role in each, and their projects.
2. Ask the user which project to work in, then call `account.select_project` with its id. The choice is remembered for your app and that user, so you do not resend it. Call it again to switch.

Until a project is selected, every operation returns a tool error with `code: "NO_PROJECT_SELECTED"`. Once one is selected, `tools/list` shows only what the user's role in that organization can call. A refusal from the API comes back as a tool error whose text is the same JSON body `/v1` returns (`{ defined, code, status, message, data }`): `FORBIDDEN` for an action the role does not allow, `NOT_FOUND`, and so on, as described under "Errors". A `BAD_REQUEST` from input validation lists each offending field in `data.issues`, with its `path` and `message`. An unexpected server failure returns `code: "INTERNAL_SERVER_ERROR"` as a tool error too; retry once.

## Walkthrough

Replace `$API` with the API origin (e.g. `https://api.solidhooks.com` or `http://localhost:3100`), and `$SDHK_KEY` with the API key you were given.

### 1. Get an API key from the account owner

You do not mint this yourself. The owner creates it in the dashboard under **Organization → API Keys → New Key** (https://dash.solidhooks.com/organization/api-keys), where they give it a name, an optional expiry, and a **Project access** choice: every project in the organization, or exactly one. The plaintext `sdhk_sk_...` value is displayed once at creation, so they must copy it to you then; it cannot be read back later.

If you were handed a key and do not know its scope, `GET /v1/projects` answers it: a project-bound key sees only the project it is bound to.

Sanity-check the key before building anything on it:

```bash
curl -s "$API/v1/billing/plan" \
  -H "authorization: Bearer $SDHK_KEY"
# → 200 with your plan and entitlements. 401 means the key is wrong, revoked or expired.
```

### 2. Create a source (inbound endpoint)

```bash
curl -s "$API/v1/sources" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "GitHub events",
    "provider": "github",
    "verifierConfig": { "secret": "<the-webhook-secret-you-set-in-github>" }
  }'
# → { "id": "src_...", "maskId": "...", "ingestUrl": "https://sdhk.events/<maskId>", ... }
```

Naming a known `provider` is all it takes — SolidHooks knows how GitHub signs, so `verifierType` and the whole signing scheme are filled in server-side and you supply only the secret. See "Provider presets" below; the same shorthand covers `linear`, `stripe`, `shopify`, `nuvion`, `paystack`, `openai`, `anthropic` and `resend`.

`ingestUrl` is the full inbound URL — configure it directly in the webhook provider. It points at the dedicated ingest origin (prod `https://sdhk.events`, dev `http://localhost:3200`), which is separate from this management API.

**`verifierType` is only needed when verification is on.** Omit it, with an empty `verifierConfig`, and the source accepts everything unverified — good for a quick start:

```bash
curl -s "$API/v1/sources" -H "authorization: Bearer $SDHK_KEY" -H 'content-type: application/json' \
  -d '{ "name": "Scratch", "provider": "generic", "verifierConfig": {} }'
# → verifierType: "noop"
```

That works on any provider: `{ "provider": "nuvion", "verifierConfig": {} }` is a Nuvion source with verification off (it still gets Nuvion's `eventTypeField`). Supplying a secret is what asks for verification — so a config carrying one that we cannot match to a verifier is **rejected**, never quietly stored unverified.

#### Replay protection for `hmac` sources

This section is for `provider: "generic"`. Known providers get whatever replay protection they support from their preset: the *scheme* keys (`timestampHeader`, `timestampFormat`, `signedPayloadTemplate`) are the preset's to set, never yours — but `toleranceSeconds` is a policy knob that stays yours wherever a timestamp exists to bound it (`stripe`, every Standard Webhooks preset — `openai`, `anthropic`, `resend` — and hmac presets carrying a timestamp header, currently `nuvion`). See "Provider presets" below and "Standard Webhooks verifier" further down.

By default the `hmac` verifier signs the request body alone, so a captured request stays valid indefinitely. If your provider also sends a timestamp header and folds it into the signature, add these optional keys to `verifierConfig`:

| Key | Meaning |
| --- | --- |
| `timestampHeader` | Header carrying the timestamp (e.g. `x-slack-request-timestamp`). Setting it turns on replay protection. |
| `timestampFormat` | How that header is encoded: `unix_seconds` (the default) or `iso8601`. Requires `timestampHeader`. Only the replay-window check reads it — the signature always covers the header exactly as sent. |
| `toleranceSeconds` | How far the timestamp may drift from server time **in either direction** before the request is rejected. Defaults to `300`. Requires `timestampHeader`. |
| `signedPayloadTemplate` | Layout of the bytes the provider signs, using `{timestamp}` and `{body}`. Omit it with `timestampHeader` set and `{timestamp}.{body}` is stored for you, so a later `GET` reports what actually gets verified. Clearing `timestampHeader` then means clearing this too, since a template using `{timestamp}` without a header to supply it is rejected. |

A Slack-style source, which signs `v0:<timestamp>:<body>`:

```bash
curl -s "$API/v1/sources" \
  -H "Authorization: Bearer $SOLIDHOOKS_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Slack events",
    "provider": "generic",
    "verifierType": "hmac",
    "verifierConfig": {
      "hash": "sha256",
      "encoding": "hex",
      "header": "x-slack-signature",
      "prefix": "v0=",
      "timestampHeader": "x-slack-request-timestamp",
      "signedPayloadTemplate": "v0:{timestamp}:{body}",
      "toleranceSeconds": 300,
      "secret": "<your-slack-signing-secret>"
    }
  }'
```

The timestamp encoding is declared, never guessed. `unix_seconds` (the default) accepts digits only, so a millisecond value is read literally and lands far outside the window; `iso8601` requires an explicit offset (`2026-08-11T12:45:17.503Z`), because a bare local-time value would shift the window by the server's UTC offset. An unparseable timestamp fails the request rather than silently skipping the replay check. The API rejects configurations that would only look protected: a template without `{body}`, more than one `{body}`, a `{timestamp}` with no `timestampHeader` to supply it, a `timestampHeader` your template never uses, or a `toleranceSeconds` with no `timestampHeader`.

`stripe` sources get the same protection automatically (the timestamp is inside `Stripe-Signature`), and also accept `toleranceSeconds`.

#### Provider presets — send only the secret

For a known provider — `github`, `linear`, `stripe`, `shopify`, `nuvion`, `paystack`, `openai`, `anthropic`, `resend` — **the provider decides the whole signing scheme and you send only the secret.** `verifierType` and every scheme detail (HMAC's `hash`, `encoding`, `header`, `prefix`, timestamp fields — or Standard Webhooks' fixed header names) come from the provider's preset.

```bash
curl -s "$API/v1/sources" \
  -H "Authorization: Bearer $SOLIDHOOKS_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Nuvion production",
    "provider": "nuvion",
    "verifierConfig": { "secret": "<your-nuvion-webhook-secret>" }
  }'
```

That is a complete, replay-protected source. It stores `verifierType: hmac`, `header: x-nuvion-event-signature`, `hash: sha256`, `encoding: hex`, `timestampHeader: x-nuvion-event-timestamp`, `timestampFormat: iso8601` (Nuvion sends an ISO-8601 instant, not unix seconds) and `signedPayloadTemplate: {timestamp}.{body}`. Paystack is the same shape — `{ "provider": "paystack", "verifierConfig": { "secret": "…" } }` — resolving to HMAC-SHA512/hex over `x-paystack-signature` with no timestamp, since Paystack does not send one.

The OpenAPI schema models this shorthand directly: each preset provider has its own branch in the create-source union — except `openai`, `anthropic` and `resend`, which share one branch (with one example each) because they are one scheme, not three — where `verifierType` is optional (with the preset's verifier as its `default`), the scheme keys appear as their canonical constants (so an echo validates and an override does not), and `toleranceSeconds` exists only on branches whose provider carries a timestamp to bound it. A client generated from the spec therefore agrees with the server about every form on this page. Sending `verifierType` explicitly is never wrong.

Each branch is named and carries a ready-to-send example, so you can pick one by title rather than by decoding the `anyOf`: "GitHub (HMAC preset)", "Linear (HMAC preset)", "Shopify (HMAC preset)", "Nuvion (HMAC preset)", "Paystack (HMAC preset)", "Custom HMAC (you define the scheme)", "Stripe (preset)", "Stripe verifier on a non-Stripe provider", "Standard Webhooks (preset providers)", "Standard Webhooks on an uncatalogued producer", "HTTP Basic Auth", "API key header", and "Unverified (no signature check)" last. Every one of those examples is validated against its own branch in CI, so a sample copied out of the spec is a payload the server accepts.

**The scheme is not yours to override.** A provider's signature scheme is a fact about that provider, so a `hash`, `encoding`, `header`, `prefix`, `timestampHeader`, `timestampFormat` or `signedPayloadTemplate` that **differs** from the preset is **rejected**, not silently ignored. (Echoing a preset's own value back is fine — see the round-trip note under update.)

```json
{ "code": "BAD_REQUEST", "message": "hash is not configurable for provider \"nuvion\" — it is fixed by that provider's signature scheme. Use provider \"generic\" to set it yourself." }
```

This is deliberate: overriding the scheme cannot produce a working source, only one that rejects every webhook the provider actually sends — and in the timestamp case, one that looks protected while not being. Use `provider: "generic"` when you need full control.

`secret` is always yours. `toleranceSeconds` (how much clock drift you accept — your policy, not the provider's) is yours **wherever there is a timestamp for it to bound**: Stripe, every Standard Webhooks preset (`openai`, `anthropic`, `resend` — the timestamp rides in their mandatory `webhook-timestamp` header), and HMAC presets that carry a timestamp header, which today means `nuvion`. GitHub, Linear, Shopify and Paystack sign without a timestamp, so sending a tolerance there is rejected rather than stored inert. `generic` sets its own `timestampHeader` and so may always set a tolerance.

The preset only applies when the verifier matches the one the provider uses, so an explicit `provider: "nuvion"` with `verifierType: "basic_auth"` injects nothing, and an explicit `verifierType: "noop"` still means no verification at all.

**Update follows the same rules.** Changing `provider` on an existing source re-derives the scheme from the new provider's preset, so a source moved onto `nuvion` picks up its timestamp fields instead of silently keeping the old provider's — provided the effective `verifierType` matches that preset's (see the verifier-match rule below). Every `PATCH` on `/v1` — sources, destinations, connections and projects alike — takes the id in the path and the changed fields inside a `data` object. Within `data`, send only the fields you are changing: `{ "data": { "name": "renamed" } }` is a complete, valid PATCH, while a bare `{ "name": "renamed" }` with no wrapper is rejected with 400. The one envelope rule: **`verifierType` and `verifierConfig` travel together** — touching verification (including rotating a secret) means sending both, and a `verifierConfig` on its own is rejected, because a config with no named verifier could only be guessed at. `{ "data": {} }` is a valid no-op. Within the envelope, `verifierConfig` is itself a **merge patch** while `provider` and `verifierType` stay unchanged: omitted keys keep their stored values (so `{ "data": { "verifierType": "hmac", "verifierConfig": { "secret": "…" } } }` rotates the credential without touching a stored `toleranceSeconds`), and `null` clears an optional setting. A provider or verifier change starts from a fresh config instead — the stored one belongs to the previous configuration. The custom-response pair is partial too: a `customResponseBody` or `customResponseContentType` sent alone is combined with the stored other half, and the resulting pair is validated together (a body that is not valid JSON is rejected against a stored JSON content type). An unchanged `provider` echo is a no-op, never a reset — only an actual provider change re-derives the provider-dependent fields.

```bash
curl -s -X PATCH "$API/v1/sources/src_..." \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{ "data": { "name": "GitHub events (prod)" } }'
```

Two things to know when editing:

- **Echoing the scheme back is fine.** `GET` returns the stored `verifierConfig`, and PATCHing it back unchanged is accepted — only a value that *differs* from the preset is refused. So the natural read-modify-write loop works; you do not have to strip the preset keys yourself.
- **Changing `provider` or `verifierType` requires a new credential.** A credential belongs to one provider under one scheme, so it is not carried across. Omitting it there is rejected rather than silently reusing the old one (which would fail every signature while the source still looked configured). Send the field the NEW verifier reads — `secret` for `hmac` and `stripe`, `password` for `basic_auth`, `header_value` for `api_key` — or set `verifierType: "noop"` to turn verification off. The error names the field for you.

Note that Paystack signs with your **account secret key** (`sk_live_…`), not a scoped per-webhook signing secret — so the value you store here is a full API credential. It is encrypted at rest and never returned by the API, but treat its exposure as you would the key itself.

#### Standard Webhooks verifier

`verifierType: "standard_webhooks"` implements the [Standard Webhooks](https://www.standardwebhooks.com) spec — the scheme OpenAI, Anthropic, Resend, Supabase, Twilio, PagerDuty, Kong, Vanta, Drata, Lob, Lithic, Replicate and other adopters sign with. Unlike `hmac`, nothing about the scheme is yours to set: it fixes three headers and a signature format, so `verifierConfig` accepts exactly two keys.

```
webhook-id:        <producer-generated id>
webhook-timestamp: 1755300000
webhook-signature: v1,<base64> [v1,<base64> ...]
```

**Svix-hosted producers send the same three headers spelled `svix-id`, `svix-timestamp` and `svix-signature`, and both spellings are accepted.** Svix co-authored the spec and white-labels the headers to `webhook-*` only on its higher tiers, so most real traffic from the scheme arrives `svix-*` prefixed — Resend is the catalogued example. They are aliases: same values, same signed payload, same key. The set is resolved from whichever spelling carries the id, so a request uses one or the other, never a mix.

`webhook-signature` is a **space-delimited list** of `<version>,<signature>` entries, and any entry whose version is `v1` may match — this is what lets a producer mid-rotation send both the old and the new secret's signature at once and have either verify; do not treat the first entry as authoritative. `v1a` entries (asymmetric ed25519) are **skipped, not rejected** — verifying them is out of scope. The MAC is `base64(HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw-body>"))`, keyed on the secret with its `whsec_` prefix stripped and the remainder **base64-decoded** — the raw bytes, never the literal string.

```json
{ "verifierConfig": { "secret": "whsec_...", "toleranceSeconds": 300 } }
```

- `secret` (required) — the producer's Standard Webhooks signing secret.
- `toleranceSeconds` (optional, default `300`) — the same two-sided replay window every other timestamped verifier uses.

Any hmac scheme key (`hash`, `encoding`, `header`, `prefix`, `timestampHeader`, `timestampFormat`, `signedPayloadTemplate`) sent in `verifierConfig` is **rejected**, exactly like `stripe` — there is nothing about the scheme left to configure:

```json
{ "code": "BAD_REQUEST", "message": "hash is not part of the standard_webhooks verifier's config — the Standard Webhooks spec fixes its own header names and signature scheme. Use verifierType \"hmac\" with provider \"generic\" to configure a scheme yourself." }
```

**Catalogued adopters** — `openai`, `anthropic`, `resend` — get the same preset shorthand as any other known provider: name the provider, send only the secret.

```bash
curl -s "$API/v1/sources" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "OpenAI webhooks",
    "provider": "openai",
    "verifierConfig": { "secret": "whsec_your_signing_secret" }
  }'
# → verifierType: "standard_webhooks"
```

**Any other adopter** — Supabase, Twilio, PagerDuty, or one we have not catalogued at all — wires up the same verifier explicitly on `provider: "generic"`:

```bash
curl -s "$API/v1/sources" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "Supabase webhooks",
    "provider": "generic",
    "verifierType": "standard_webhooks",
    "verifierConfig": { "secret": "whsec_your_signing_secret" }
  }'
```

**These providers default `idempotencyKeys`** on create when you send none of your own — the spec's SHOULD, and it costs nothing since the id header is mandatory for every producer signing with this scheme. `openai` and `anthropic` default to `["headers.webhook-id"]`; **`resend` defaults to `["headers.svix-id"]`**, because that is the header Svix actually sends. The default names ONE header rather than both on purpose: an idempotency key is built from every path you list and is skipped entirely if any one of them is missing, so listing both spellings would disable deduplication rather than cover both. If your Resend account has white-labelled headers, set `["headers.webhook-id"]` yourself. An `idempotencyKeys` you do send — including an explicit `[]` — always wins; the default never overrides an explicit choice.

### 3. Create a destination (outbound endpoint)

```bash
curl -s "$API/v1/destinations" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "My app",
    "type": "http",
    "url": "https://myapp.example.com/webhooks",
    "method": "POST",
    "headers": [{ "value": "x-internal-token: supersecret", "sensitive": true }]
  }'
# → { "id": "dest_...", ... }
```

Every destination gets a signing secret (`whsec_...`) at creation, and every delivery it signs carries headers your endpoint verifies. **New destinations sign with [Standard Webhooks](https://www.standardwebhooks.com/); destinations created before that became the default sign with the older SolidHooks HMAC scheme.** `signatureScheme` on the destination tells you which — `standard_webhooks` or `solidhooks_v1`. Fetch the secret with `GET /v1/destinations/{id}/signing-secret`; rotate it with `POST /v1/destinations/{id}/rotate-signing-secret`. Both need a key scoped `destination: read & write` (or an admin/owner session). A key without that grant gets 403 `INSUFFICIENT_API_KEY_PERMISSIONS` (see "Authentication") — ask whoever holds the key to mint one scoped for destinations.

**`standard_webhooks`** sends three headers:

```
webhook-id:        dlv_...        the delivery id — use it as your idempotency key
webhook-timestamp: 1755300000     unix seconds
webhook-signature: v1,<base64>    space-delimited LIST, may hold more than one value
```

Verify by recomputing `base64(HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw-body>"))` and timing-safe-comparing it against **each** entry in `webhook-signature`, accepting if any matches. The key is the secret with `whsec_` stripped and the remainder **base64-decoded** — the raw bytes, not the literal string. Any Standard Webhooks library does all of this in one call, which is the point of using the scheme at all.

Two things the list being a list buys you. During a rotation overlap it carries the new and previous secrets' signatures together, so you can adopt the new secret on your own schedule. And because a verifier accepts any matching entry, ordering means nothing — never treat the first entry as authoritative.

**`solidhooks_v1`** (supported indefinitely) sends `x-solidhooks-signature` (hex HMAC-SHA256) and `x-solidhooks-timestamp` (unix seconds). Recompute `HMAC-SHA256(secret, "<x-solidhooks-timestamp>.<raw-body>")` — keyed on the whole `whsec_...` string exactly as written, *not* base64-decoded — and timing-safe-compare it to the signature header. It carries one value, so it cannot express a rotation overlap. Standard Webhooks is the default and the right choice for almost everything, but this scheme is deliberately the one to pick if you deliver **binary payloads to a JavaScript receiver** — see the limitation below.

Under both schemes, reject timestamps outside your tolerance window (5 minutes is typical) to block replays, and verify against the **raw request body bytes** before any JSON parsing — re-serializing changes the bytes and the signature will not match.

**Move a destination between schemes** with `POST /v1/destinations/{id}/signature-scheme` (`{"scheme": "standard_webhooks"}`). This is breaking and has no grace period: it mints a new secret and switches immediately, because an overlap signs with two secrets under *one* scheme and cannot span two. Make your endpoint accept the new scheme first, then switch, then fetch the new secret.

**One limitation worth knowing if you send non-UTF-8 bodies.** SolidHooks signs the exact bytes it delivers. The reference `standardwebhooks` JavaScript library converts a Buffer payload to a string before computing its MAC, which is lossy for bytes that are not valid UTF-8, so it cannot verify those deliveries and will reject them. This is deliberate — signing the lossy form instead would make one signature authenticate many different payloads. If you deliver binary bodies, verify against the raw bytes — the Go, Python and PHP implementations already do, so Standard Webhooks is fine for them. Only a **JavaScript** receiver is affected, and for that case `solidhooks_v1` is the scheme to choose: you hand-write the check, so nothing decodes the body before hashing it.

Source verifier secrets rotate the same way, in the opposite direction. `POST /v1/sources/{id}/rotate-secret` takes the new secret plus an `overlapDays` of `1`, `7` or `30`, and SolidHooks accepts BOTH the old and the new secret until the window closes — so the producer (Shopify, Stripe, GitHub) can switch on their own schedule with nothing rejected in between. Unlike the destination side this works for every verifier type today, because it operates on the stored secret rather than the signature format. `GET /v1/sources/{id}/rotation-status` reports how many requests each secret has verified, with a last-seen timestamp; those counts exist only in this direction, because we do the verifying. When the counter store is unreachable it answers `countersAvailable: false` with null counts — **null is not zero**, and zero is the reading that means the producer has not switched yet. `POST /v1/sources/{id}/cancel-rotation` abandons a rotation and restores the previous secret, refusing with 409 `ROTATION_IN_USE` once the new one has already verified traffic.

`POST /v1/sources/{id}/test-signature` answers "why is this request being rejected?" without waiting for real traffic. Send `{ "headers": { ... }, "body": "<raw body, byte for byte>" }` — the exact header set and body a producer sent, headers matched case-insensitively — and it verifies them against the source's live secret(s) using the same code path ingest uses. It replies `{ verified, slot, reason, rotating }`: `slot` is `current`, `previous` or `null`, so during a rotation window this is what tells you whether the producer has switched yet. Unlike the inbound 401 — which stays deliberately opaque so it cannot act as a signature oracle — this DOES return `reason`, drawn from the same taxonomy as `rejectionReason` on a rejected event (`signature_mismatch`, `missing_signature_header`, `timestamp_out_of_tolerance`, and so on), because the caller here is the authenticated tenant reading their own diagnostics. It needs only `source: read`: diagnosing a failing producer does not require the ability to rotate a secret. Running it never records slot usage, so testing cannot disturb the counters `cancel-rotation` reads. It is rate limited per source (~1/s, burst 10) — a burst of clicks is fine, a loop is not.


The secret is `whsec_` followed by standard base64 of 32 random bytes — the Standard Webhooks secret format.

**Rotating a `standard_webhooks` destination can give your endpoint a grace period.** Pass `overlapDays` of `1`, `7` or `30` to `POST /v1/destinations/{id}/rotate-signing-secret` and every delivery carries both secrets' signatures in `webhook-signature` until the window closes, so you can roll the new value out without dropping anything. `GET .../signing-secret` returns `previousSecret` and `previousSecretExpiresAt` while it is live, and we email 24 hours before it closes. `POST /v1/destinations/{id}/end-rotation-overlap` retires the old secret early. Rotating again while the window is live is refused with 409 `ROTATION_OVERLAP_ACTIVE`, since it would displace a secret receivers may still rely on; pass `force: true` to rotate anyway.

Without `overlapDays` — and always on a `solidhooks_v1` destination, whose single-value header cannot carry two signatures — rotation is a hard cutover: the moment it returns, deliveries are signed with the new secret only. Update your endpoint first, or deliveries will fail verification until you do. Passing `overlapDays` to a `solidhooks_v1` destination is refused rather than ignored, so you are never told you have a window you do not have.

### 4. Connect them

```bash
curl -s "$API/v1/connections" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "GitHub → My app",
    "sourceId": "src_...",
    "destinationId": "dest_...",
    "filterRules": { "eventTypes": ["push", "pull_request"] }
  }'
```

Omit `filterRules.eventTypes` (or pass an empty object) to forward all events. Done — inbound webhooks now flow through to your destination with retries.

## Project scoping (X-Project-Id)

Project-scoped endpoints (`/v1/sources`, `/v1/destinations`, `/v1/connections`, `/v1/events`, `/v1/deliveries`, `/v1/stats/*`) accept an optional `X-Project-Id` header:

- **Omitted** → the request targets your organization's oldest project (the auto-created "Default Project"). Fresh accounts need zero configuration.
- **Set** → the request targets that project. The id must belong to your organization, otherwise the API returns 404.

Manage projects at `/v1/projects` (org-scoped, no header needed): list, create (`{ "name": ..., "slug": ... }`), update (`PATCH /v1/projects/{id}` with `{ "data": { "name": ... } }`), delete. Delete needs a key scoped `project: read & write` (or an admin/owner session); anything less gets 403 `INSUFFICIENT_API_KEY_PERMISSIONS`.

**Project-bound keys** (created with a single project selected under **Project access**) only ever see their bound project:

- Data-plane requests ignore the header rules above: no header targets the bound project, and any other `X-Project-Id` returns 404.
- On `/v1/projects` the key sees exactly its own project: list returns only it, get/update/delete of any other project returns 404, and creating projects returns 403 (project management is an organization-level concern — it needs a key with access to every project).

The binding is fixed for the life of the key: the dashboard creates and revokes keys but does not re-scope them. Widening or narrowing what you can reach means asking the owner for a new key.

## Ingesting webhooks

- Ingest is served from a dedicated origin, separate from this management API: prod `https://sdhk.events/<maskId>` (maskId at the domain root), dev `http://localhost:3200/<maskId>`.
- **That origin is the only way to send a webhook.** This management API has no ingest endpoint — there is no `/v1/ingest/...` path and no `Ingest` tag in the OpenAPI spec. Posting a webhook to the API host returns 404; use the ingest origin.
- Send provider webhooks to the source's `ingestUrl` (format: `<ingest-base>/<maskId>`). The provider's payload is accepted raw, exactly as it sends it — no wrapper object, any content type. No other auth is needed — the maskId is the credential. Rotate it with `POST /v1/sources/{id}/rotate-mask` — needs a key scoped `source: read & write` (or an admin/owner session); anything less gets 403 `INSUFFICIENT_API_KEY_PERMISSIONS`.
- Responses: `200 {"ok":true,"eventId":"evt_..."}` on success; `{"ok":true,"duplicate":true}` when an idempotency key matches a previous event; 4xx `{"ok":false,"code":...,"message":...}` on verification/size failures.
- A signature failure returns a deliberately opaque `401` (see "Error semantics"), but also records the request as an event with `status: "rejected"` — query `GET /v1/events?status=rejected` to find out which check failed.
- Per-source knobs: `maxPayloadSizeKb`, `idempotencyKeys` (dot-paths like `headers.x-webhook-id`, on any provider — but rejected with 400 on an email source, which dedupes on its Message-ID instead), `eventTypeField` (dot-path used by connection filters), custom static responses for generic providers. A source also carries a read-only `rateLimit` (requests/second, `0` = unlimited) — an operational clamp set by SolidHooks support, not writable through the API.
- `eventTypeField` can be set on any provider, not just `generic`. Known providers (`github`, `linear`, `stripe`, `shopify`, `nuvion`, `paystack`, `openai`, `anthropic`, `resend`) default it to their standard header/body path (e.g. `headers.x-github-event` for GitHub, `body.type` for the three Standard Webhooks presets) if omitted, but a source can override it with any `headers.<name>` or `body.<path>` value — e.g. set a GitHub source's `eventTypeField` to `body.action` to filter connections on `review_requested` instead of the raw event header. Omitting it on create, or sending an empty string on update, resets it to the provider's default (or `null` for `generic`).

## Ingesting email

Sources can also receive inbound email. Pass `"provider": "email"` on `POST /v1/sources`, or `PATCH /v1/sources/{id}` to convert an existing one. It is a provider like `github` or `generic` — there is no separate flag.

- When enabled, source views include `emailAddress` (`<maskId>@sdhk.events`) — give that address to anything that sends email. It is `null` while the flag is off.
- **A source ingests email or webhooks, never both.** With the flag on, `POST <ingest-base>/<maskId>` returns `400 BAD_REQUEST` ("Source ingests email and does not accept HTTP webhooks") — the two arrive on different paths and only the email one understands an envelope. Keep a separate source for webhooks. For the same reason `idempotencyKeys` is rejected on an email source: dedupe there is the `Message-ID`, not a configured path.
- `POST /v1/sources/{id}/rotate-mask` rotates the email address too (it is derived from the maskId). Needs a key scoped `source: read & write` (or an admin/owner session).
- Each accepted email is stored as a regular event whose type comes from the source's `eventTypeField`, falling back to `email.received` when that is unset or absent from the message. Either way the type is usable in connection `filterRules.eventTypes` like any other, and replaying the event routes with that persisted type so filters apply identically.
- **On an email source `eventTypeField` accepts only `body.from` or `body.subject`** — the envelope's shape is ours, and these are the two single-valued fields that discriminate between senders. Any other path (including `headers.*`) is rejected with 400. `body.from` is the `From:` header reduced to a bare address, so a filter matches `billing@supplier.example` and not the display name or the per-message bounce address.
- The stored payload is a normalized envelope:

```json
{
  "from": "alice@example.com",
  "to": ["<maskId>@sdhk.events"],
  "cc": [],
  "subject": "Invoice #42",
  "messageId": "<abc123@mail.example.com>",
  "date": "Tue, 14 Jul 2026 09:00:00 +0000",
  "text": "plain-text body",
  "html": "<p>html body</p>",
  "spamStatus": "NotSpam",
  "bounce": false,
  "attachments": [
    { "name": "invoice.pdf", "contentType": "application/pdf", "size": 12345, "data": "<base64>" }
  ]
}
```

- The event's raw body is the full original Postal payload (all documented fields plus anything extra Postal sends) with `attachments[].data` stripped to `null` — attachment bytes live only in the envelope, subject to the payload budget below.
- Deduplication is on the email `Message-ID`: a repeated Message-ID returns `{"ok":true,"duplicate":true}` instead of storing a second event.
- Email bodies also count toward the source's `maxPayloadSizeKb` and are charged before attachments: `text` is charged first and is sliced down to the budget if it alone exceeds it, then `html` is dropped to `null` whole (never partially cut) if `text` + `html` would still exceed it, and the envelope gets `"bodyTruncated": true`.
- Attachment data counts toward the source's `maxPayloadSizeKb`. An attachment that would exceed it keeps its metadata but gets `"data": null` plus `"truncated": true` — its data is omitted entirely, never partially cut.

## Private delivery (SolidHooks Connect)

Destinations don't have to be publicly reachable. A **Connector** is an org-scoped resource (created once per customer network/cluster, reused across projects) that lets SolidHooks deliver to endpoints inside a private network — no inbound firewall holes on your side. One connector can run **multiple agent replicas** for high availability.

1. `POST /v1/connectors` — provision one (`{ "name": "prod-cluster", "description": "..." }`). This mints a credential, so it needs a key scoped `connector: read & write` (or an admin/owner session); anything less gets 403 `INSUFFICIENT_API_KEY_PERMISSIONS`. Response includes `enrollmentKey` — a **reveal-once bootstrap token** (`cbt_…`), shown only here and on `regenerate-enrollment`; store it immediately. Unlike the old Ziti OTT it **does not expire**. Gated by the plan's `maxConnectors` entitlement: exceeding it returns 403 `CONNECTOR_LIMIT_REACHED` with `data: { limit, current }`.
2. Run one or more connector agent replicas in your network with the bootstrap token (dashboard shows the exact `docker run` / `helm install` one-liner). Each replica calls the public `POST /v1/connectors/bootstrap` (no auth; body `{ "enrollmentKey": "cbt_..." }`) to self-enroll — it receives a one-time Ziti enrollment token **and a reveal-once per-replica `heartbeatToken` (`crh_…`)**, and joins the connector. This is rate-limited per connector and gated by the plan's `maxReplicasPerConnector` entitlement.
3. Each replica then calls the public `POST /v1/connectors/replicas/heartbeat` (no auth; body `{ "heartbeatToken": "crh_..." }`) on an interval — the stock agent beats every 60s, tunable via `HEARTBEAT_INTERVAL_SEC`. This is how a replica asserts it is alive; only its hash is stored, so the token cannot be retrieved again and the agent persists it beside its identity. A replica that neither heartbeats for 24 hours **nor** appears connected to the overlay has its identity revoked and its slot freed. Both conditions are required, so a replica that is serving traffic but cannot reach the SolidHooks API is never revoked. A healthy beat returns `{ "ok": true }`. A token matching a **revoked** replica returns **403** (`CONNECTOR_REPLICA_REVOKED`) carrying a machine-readable `reason` (`connector_deleted` | `replica_revoked` | `identity_absent`) and a human-readable message — the stock agent logs the reason and stops the container, and does not re-enroll. An **unknown** token — or one whose revocation record has aged out (tombstones are kept 30 days) — returns an opaque **401** with a deliberately generic message, so the endpoint cannot be used to probe which credentials are live.
4. `GET /v1/connectors/{id}` (or `GET /v1/connectors`) reports `status` aggregated over replicas — `"pending"` (no replica enrolled yet) → `"connected"` (at least one replica is up) / `"disconnected"` (replicas enrolled, all reported, none reachable) / `"unknown"` (nothing connected and at least one replica went unreported — SolidHooks could not reach the overlay controller, which says nothing about your agent; retry rather than treating it as an outage) — plus `connectedReplicas` and `totalReplicas` counts.
5. Create a destination with `"type": "private"` and `"connectorId"` set to the connector's id instead of `"type": "http"`:

```bash
curl -s "$API/v1/destinations" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "In-cluster payments service",
    "type": "private",
    "connectorId": "cntr_...",
    "url": "http://payments.default.svc.cluster.local:8080/hooks",
    "method": "POST"
  }'
```

`url` is still validated as a URL, just not required to be publicly resolvable — it's the in-network address the connector agent resolves. Everything downstream (connections, retries, attempts log, billing per delivery) is identical to `"http"` destinations.

Other connector endpoints — the three mutations among them need a key scoped `connector: read & write` (or an admin/owner session), like `POST /v1/connectors`: `GET /v1/connectors/options` (lightweight `{ id, name }` list for a destination-form picker), `GET /v1/connectors/{id}/replicas` (one row per enrolled replica, each with its own `status` — the same four values as the connector aggregate — plus last-seen; use this to tell "3 of 3 up" from "1 of 3 up" when the aggregate says `connected`), `POST /v1/connectors/{id}/regenerate-enrollment` (rotate the bootstrap token — invalidates the previous one; already-enrolled replicas keep running), `POST /v1/connectors/{id}/toggle-disabled` (a disabled connector rejects new private destinations; already-enrolled replicas keep beating, since disabling is not revocation), `DELETE /v1/connectors/{id}` (blocked with 400 while any destination still references it).

Connector creation provisions overlay infrastructure, so a connector carries a `provisionedAt` timestamp that is `null` until that finishes. While it is `null`, creating a private destination against the connector returns 400 and deleting it returns 409 `CONNECTOR_PROVISIONING` — both retryable, and provisioning takes seconds. This is independent of `isDisabled`, which is a user-flippable switch rather than a platform state.

If `ZITI_CTRL_URL`/`ZITI_ADMIN_USERNAME`/`ZITI_ADMIN_PASSWORD` aren't configured on the deployment, connector mutations return 400 `BAD_REQUEST` — private delivery is an optional capability, not assumed available.

## Plans & billing

Billing is organization-level. The billable unit is the **delivery** (one event routed to one connection): fan-out to N connections counts N deliveries; automatic retries of a delivery are free; replays create new deliveries.

- `GET /v1/billing/plan` — your active plan, its `currency`/`priceCents`, and effective entitlements (`includedDeliveriesPerMonth`, `retentionDays`, `maxSeats`, `maxDeliveriesPerSecond`, ...). `null` on a numeric entitlement means unlimited/custom.
- `GET /v1/countries` — public (no auth): countries available for sign-up, each with the billing currency and default language an organization created there inherits. The owner's country sets the currency at sign-up, and it is fixed once the organization first subscribes.
- `GET /v1/billing/plans` — the plan catalog (Hobby free tier, paid tiers), in display order. Each plan carries `currency` and `priceCents` — the price in **your organization's** billing currency, which is what it would actually be charged — plus a `prices` map of every supported currency for comparison. `priceCents: null` means custom/sales-led (enterprise). Amounts are minor units (pence/cents).
- `GET /v1/billing/subscription` — current Stripe subscription state (status, renewal date, pending cancellation) and whether a Stripe customer exists.
- `GET /v1/billing/usage` — deliveries used vs included in the current usage window, plus `resetAt` and (on overage-enabled plans) the accrued pay-as-you-go units and estimated charge (`currency`, `pricePer100kCents`, `estimatedCents` — all in your organization's billing currency). The window is the subscription's billing period for subscribed organizations (resets at renewal) and the UTC calendar month otherwise.
- `POST /v1/billing/cancel` — schedule the subscription to cancel at the end of the current billing period (owner/admin only). The plan, quota, and usage window stay unchanged until then; afterwards the organization falls back to the free tier.
- `POST /v1/billing/resume` — undo a scheduled cancellation before the period ends (owner/admin only).
- `POST /v1/billing/change-plan` — change an existing subscription between paid tiers (owner/admin only). Body: `{ planKey, scheduleAtPeriodEnd? }`. Upgrades (omit `scheduleAtPeriodEnd`) apply immediately and invoice the prorated difference now; downgrades (`scheduleAtPeriodEnd: true`) take effect at period end. Requires an existing subscription — the free tier is a cancellation (use `cancel`) and enterprise is sales-led.
- `POST /v1/billing/preview-plan-change` — price an immediate change to `{ planKey }` without making it, using Stripe's own invoice preview: `currency`, `dueTodayCents` (what confirming actually charges — never negative, and it clamps to 0 when account credit covers the invoice), `lines[]` of net pre-tax proration line items, and `nextAmountCents`/`nextChargeAt` for the recurring price that follows. `lines` will not always sum to `dueTodayCents` — the total is computed after tax and after any credit — so treat it as a breakdown to reconcile, not an equation. Read-only — unlike `change-plan` it does not release a pending scheduled change — and it only prices the immediate (upgrade) direction; a downgrade scheduled to period end charges nothing today, so there is nothing to preview there. `available: false` is not an error: when Stripe can't price the change (an API failure, or billing not configured) the response still comes back 200 with unusable amounts — check `available` before showing a figure, and never block the upgrade on it. You also get `available: false` while a scheduled change is pending, and that one is a refusal rather than a failure: `change-plan` releases the queued schedule before charging, so a figure priced against the scheduled state is not what you would be billed. Call `POST /v1/billing/revert-scheduled-change` first if you need a quote. Same refusals as `change-plan` otherwise (no subscription, same plan, free target, or a non-self-serve/unknown plan).
- `POST /v1/billing/refresh` — drop the cached plan resolution and return the freshly resolved plan. Call after changing the subscription so entitlements update without waiting for the cache TTL or the Stripe webhook.

Quota behavior depends on the plan. The free tier hard-caps: once its included deliveries are used, ingest rejects further requests with HTTP 429, code `QUOTA_EXCEEDED`, and `data: { limit, current, resetAt }` until the window resets or the plan is upgraded. Paid tiers with overage enabled never hard-cap — deliveries beyond the included quota are metered and billed as overage (see the plan's `entitlements.overage` in `GET /v1/billing/plans`). Duplicate (idempotent) requests are never counted or blocked.

Every billing mutation needs a key scoped `billing: read & write` (or an admin/owner session) and rejects anything less with 403 `INSUFFICIENT_API_KEY_PERMISSIONS`; the reads (`GET /v1/billing/plan`, `usage`, `subscription`, `plans`) and `POST /v1/billing/refresh` work with any key, scoped `billing: read` or unscoped. `POST /v1/billing/preview-plan-change` is gated the same as `change-plan` even though it makes no change — it is a pre-flight for that mutation, so it needs the same permission. New organizations start on the free Hobby plan with no setup. The **first** subscription is a browser step (Stripe Checkout at https://dash.solidhooks.com/settings/plan, org owner/admin only) — ask the human you're working for to complete it; verify afterwards via `GET /v1/billing/plan`. Once subscribed, everything else is fully API-driven: cancellation and resume (`POST /v1/billing/cancel` / `resume`) and plan up/downgrades between paid tiers (`POST /v1/billing/change-plan`, previewable first via `POST /v1/billing/preview-plan-change`).

Other plan-enforced limits: event history is retained for the plan's `retentionDays` (older events and their deliveries are purged nightly and can no longer be replayed), and organization seats are capped at `maxSeats` — inviting a member beyond the cap is refused with 403 `INVITATION_LIMIT_REACHED`. Invitations are sent through the session-authenticated `/auth/*` organization endpoints the dashboard uses, not `/v1`, so an API key never sees this code — ask the account owner to free a seat or upgrade.

Outbound delivery throughput is paced per plan (`maxDeliveriesPerSecond` / `burstDeliveriesPerSecond` in the plan's entitlements; `null` = unlimited) and, layered under it, by each destination's own configurable `rateLimit` + `rateLimitPeriod`. Unlike ingest limits this is **never an error**: a delivery over either rate is queued (its status shows `throttled`) and sent automatically when capacity frees — nothing is dropped, no retry attempt is consumed, and no action is needed. A delivery that was ever held carries a permanent `throttledAt` timestamp even after it succeeds. `GET /v1/deliveries/stats` returns the live queue snapshot — `{ pending, throttled }` counts of deliveries waiting right now (historical/outcome counts live in `GET /v1/stats/dashboard`).

## Live streams (Server-Sent Events)

Two endpoints tail activity as it happens, so an agent can react without polling: `GET /v1/events/stream` and `GET /v1/deliveries/stream`. Both are `text/event-stream`, both are forward-only — they start at the moment of connection and emit nothing historical; use the list endpoints for history — and both close after 30 minutes — reconnect rather than treating the close as an error.

Resumption is plain SSE. Every event carries an `id`; reconnect with a `Last-Event-ID` header (an `EventSource` client does this for you) and the server resumes from exactly that point, tie-breaks included. You do not track a cursor yourself. `after` is a separate start-from filter for a fresh stream, not a resume mechanism: pass it to start earlier than the moment of connection, clamped to the last 24 hours.

The two differ in what "activity" means, and this is the part worth reading twice:

- **`/v1/events/stream`** is anchored on `createdAt`. Events are immutable, so each event is emitted exactly once. Scope with `sourceId`.
- **`/v1/deliveries/stream`** is anchored on the delivery's most recent attempt, falling back to its creation time. A delivery is created `pending` and mutates as attempts land, so **the same delivery id is emitted more than once** — once on creation and again on every attempt. Replace by id rather than appending, and resume with `after` set to the row's `lastAttemptAt` when present and its `createdAt` otherwise. Scope with `eventId` or `status`.

Neither stream is a history export: `after` is clamped to the last 24 hours. Use `GET /v1/events` or `GET /v1/deliveries` for anything older.

## Delivery retries

A failed delivery is retried automatically on an exponential schedule: immediately, then 5s, 5m, 30m, 2h, 5h, 10h, 10h. Two plan entitlements bound it, and whichever runs out first ends the schedule:

- `maxDeliveryAttempts` — total attempts, first attempt included (Hobby 3, paid plans 8).
- `maxRetryHours` — wall-clock ceiling from the first attempt (Hobby 1, paid plans 36).

Both are also clamped by the plan's `retentionDays`, since a retry cannot outlive the delivery record it belongs to. Retries are free: they reuse the same delivery and never count against your delivery quota. Replays do create new billable deliveries.

While a delivery is still being retried, `nextAttemptAt` tells you when the next attempt is due; `null` means no further attempts are scheduled.

### How a destination's response is interpreted

| Response | What happens |
| --- | --- |
| `2xx` | Success. |
| `3xx` | Failure. Redirects are **never followed** — point the destination at its final URL. |
| `410 Gone` | Two consecutive 410s disable the destination and email the org owners/admins. Any other response in between resets that count. |
| `429`, `502`, `504` | Parked, not failed. Honours `Retry-After` (seconds or HTTP-date, capped at 1h), costs no retry attempt, and is capped at 5 parks per delivery. |
| other `4xx`/`5xx` | Failure, retried on the schedule above. |

A parked delivery reads `status: "throttled"` with `throttledReason: "destination_busy"`; one held by a rate limit reads `throttledReason: "rate_limit"`. Both are queued, not lost.

Requests time out after 20 seconds by default. Set `timeoutMs` on a destination (1000–30000) to override it.

## Error semantics

- **401 UNAUTHORIZED** — missing, invalid, expired or revoked API key. Check the `Authorization: Bearer` header; if the key was valid before, ask the account owner whether it was revoked or has passed its expiry.
- **403 FORBIDDEN** — no organization/project could be resolved (e.g. organization has no projects).
- **403 INSUFFICIENT_API_KEY_PERMISSIONS** — the API key's own permissions don't cover this action (see "Authentication"). For a scoped key, this resource wasn't granted `read & write` — or wasn't granted at all. For an unscoped (legacy) key, the action is outside member tier. Either way the remedy is the same: ask whoever holds the key to mint one scoped for this resource. Retrying with the same key will not help. Distinct from a plain 403 `FORBIDDEN`, which is a session/organization refusal rather than a key-scope one.
- **404 NOT_FOUND** — resource does not exist *or* belongs to another organization/project; also returned for a foreign `X-Project-Id`.
- **400 BAD_REQUEST** — validation failure; the body contains field-level details. Also returned by `POST /v1/events/{id}/replay` for an event that cannot be replayed: a `duplicate` (it never had its own deliveries) or a `rejected` one (it was never authenticated, so replaying it would push unverified content to your destinations).
- **401 UNAUTHORIZED** (ingest only) — signature verification failed. The message is always the same `"Signature verification failed"` regardless of which check failed, on purpose: telling an unauthenticated caller *why* would turn the 401 into an oracle. The specific reason is recorded on the event instead — `GET /v1/events?status=rejected` returns a `rejectionReason` of `signature_mismatch`, `missing_signature_header`, `missing_id_header` (Standard Webhooks only — the `webhook-id` header, which the MAC is computed over, was absent), `missing_timestamp_header`, `malformed_signature_header`, `malformed_timestamp`, `timestamp_out_of_tolerance`, `verifier_misconfigured` or `unsupported_verifier`. Note these rows are **rate-limited per source**, so under a flood the log is a representative sample rather than a complete ledger of every 401.
- **429** — rate limited. API keys: 600 requests/minute per key. (The `/auth/*` account endpoints the dashboard uses carry their own per-IP limits, which you will not encounter.) Public connector endpoints (`/v1/connectors/bootstrap`, `/v1/connectors/replicas/heartbeat`): 5 requests/second per client IP with a burst of 20, applied before the token is checked; then per connector (~1 bootstrap / 5s) and per replica (1 heartbeat/s) once it resolves.
- **429 QUOTA_EXCEEDED** (ingest and event replay) — the organization exhausted its delivery quota for the current usage window (hard-capped plans only; overage-enabled paid plans keep accepting and meter the excess). Body includes `data: { limit, current, resetAt }`; check `GET /v1/billing/usage` and upgrade or wait for `resetAt`.
- **429 RATE_LIMITED** (ingest, and `POST /v1/sources/{id}/test-signature`) — for the signature test, the per-source budget of roughly 1 request/second with a burst of 10; body includes `data: { limit, remaining, retryAfterSeconds }`. Otherwise ingest throughput exceeded. Normally this is the source's own clamp (`rateLimit` on the source, a rolling token bucket keyed on that source, `0` = unlimited and the default — so most sources never see it); an org-wide clamp can also be applied to a whole organization, and is checked after the source's. Body includes `data: { limit, remaining, retryAfterSeconds }`; the direct ingest route also sets `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Remaining` headers. Back off and retry.
- **403 CONNECTOR_LIMIT_REACHED** (`POST /v1/connectors`) — the plan's `maxConnectors` entitlement is exhausted. Body includes `data: { limit, current }`; upgrade the plan or remove an unused connector.
- **403 REPLICA_LIMIT_REACHED** (`POST /v1/connectors/bootstrap`) — the plan's `maxReplicasPerConnector` entitlement is exhausted for this connector. Body includes `data: { limit, current }`; upgrade the plan or remove a dead replica.
- **409 CONNECTOR_PROVISIONING** (`DELETE /v1/connectors/{id}`) — the connector's overlay infrastructure is still being built (`provisionedAt` is `null`). Retryable by design; provisioning takes seconds. A create that died mid-flight stops blocking deletion after a short window, which is also the recovery path for a half-provisioned connector.
- **400 DESTINATION_NOT_PUBLIC** (`POST /v1/destinations/{id}/test-delivery`) — the destination's URL resolves inside a private or reserved range (loopback, RFC1918, CGNAT, link-local including cloud metadata), so we refuse to dial it on your behalf. The connection is then pinned to the address that was checked, so a name that resolves differently a moment later cannot redirect it. Point the destination at a publicly routable address, or make it a private destination, which tunnels through a connector instead of being fetched directly.
- **400 DESTINATION_NOT_PUBLIC** (`POST /v1/destinations/{id}/test-delivery`) — the destination's URL resolves inside a private or reserved range (loopback, RFC1918, CGNAT, link-local including cloud metadata), so we refuse to dial it on your behalf. Point the destination at a publicly routable address, or make it a private destination, which tunnels through a connector instead of being fetched directly.
- **503 ROTATION_USAGE_UNAVAILABLE** (`POST /v1/sources/{id}/cancel-rotation`) — the per-secret counters could not be read, so we cannot tell whether the producer has already switched to the new secret. The revert is refused rather than attempted: treating "we could not ask" as "no traffic yet" is what would make an unsafe revert look safe. Retry shortly, or re-send with `force: true`, which skips the check entirely and so stays available during the outage.
- **409 ROTATION_IN_USE** (`POST /v1/sources/{id}/cancel-rotation`) — the new verifier secret has already verified requests, so the producer has switched and reverting would reject traffic they are signing correctly. Body includes `data: { count, lastSeenAt }`; re-send with `force: true` only if you are sure. Note this code has no destination-side equivalent: we perform verification for inbound webhooks, so we can tell which secret was used, and cannot for outbound signing.
- **409 ROTATION_OVERLAP_ACTIVE** (`POST /v1/sources/{id}/rotate-secret`, `POST /v1/destinations/{id}/rotate-signing-secret`) — a previous secret is still inside its overlap window, and rotating again would displace a secret that is still valid. Body includes `data: { expiresAt }`. On a source, the producer may still be signing with it: wait for that instant, or cancel the rotation first (`POST /v1/sources/{id}/cancel-rotation`) — there is no override. On a destination, receivers that have not adopted the current secret would start failing verification: wait, retire the old secret early with `POST /v1/destinations/{id}/end-rotation-overlap`, or re-send with `force: true` to rotate anyway. A destination rotated without `overlapDays` opens no window, so only a rotation that passed `overlapDays` can trigger it.
- **409 CONFLICT** (`POST /v1/deliveries/{id}/retry`) — the delivery is currently `throttled`: it is already queued behind a rate limit and will send automatically. Do not retry it; wait for it to complete (watch its `status`, or the queue via `GET /v1/deliveries/stats`).

Event status (`GET /v1/events`, filterable via `?status=`): `received` (matched at least one connection and was dispatched), `filtered` (connections exist but their event-type filters excluded it), `unmatched` (the source has no active connections), `rejected` (failed signature verification — carries a `rejectionReason`, never delivered), `duplicate` and `pending` (legacy; ingest no longer writes either).

Replay/retry: `POST /v1/events/{id}/replay` re-runs an event through its connections (creates new billable deliveries, so the delivery quota applies — hard-capped organizations at their limit get 429 `QUOTA_EXCEEDED`); a `duplicate` or `rejected` event is refused with 400. `POST /v1/deliveries/{id}/retry` retries a single delivery (free — no new delivery is created; returns 409 while the delivery is queued behind a rate limit, since it will send on its own).

## Current limitations

- API keys can be scoped per resource to read or read & write (see "Authentication"). Key management is not on this API at all — creating, rescoping and revoking a key are dashboard actions for the account owner. An existing key CAN be rescoped without its secret changing, so if your key is missing an action, ask for it to be rescoped rather than reissued.
- An agent cannot bootstrap its own access to the management API: sign-up, email verification and key creation are browser steps a human completes, and an account can do nothing until the owner's email is verified (the anti-abuse gate). Ask for a key rather than trying to issue one.
- The monthly delivery quota is enforced at ingest, alongside the per-source ingest clamp (`rateLimit`). Outbound throughput is paced separately per plan and per destination.
- The MCP server registers clients through CIMD only (no dynamic client registration).
