diff --git a/docs/modules/README.md b/docs/modules/README.md new file mode 100644 index 0000000..3cf7740 --- /dev/null +++ b/docs/modules/README.md @@ -0,0 +1,90 @@ +# Helpthread module substrate + +This is the operator- and module-author-facing guide to Helpthread's **module +substrate**: the HTTP surface that lets an out-of-process extension — a +draft-writing Assistant, a CRM sync, a notification bot, anything — connect +to a Helpthread deployment without any code living inside the core repo. + +It documents the substrate as it is **shipped**, not as it was specified. +Where the two disagree, this guide follows the code and says so. + +## Vocabulary (fixed — used the same way everywhere: schema, code, UI, docs) + +- **Module** — an out-of-process Helpthread extension. Never called a + "plugin" (that word survives only inside the legal phrase *plugin + exception*, the AGPL §7 additional permission — it is not a synonym for + "module" anywhere in this substrate). +- **Agent** — a human support-staff user. Agents log in, see the inbox UI, + and approve or discard AI-drafted replies. +- **Assistant** — an AI actor principal. Assistants authenticate with their + own bearer token, read conversations, and post draft replies — they can + never send mail directly. + +Do not conflate Agents and Assistants; the schema, the API, and the auth +model treat them as two entirely different kinds of caller with different +credentials and different capabilities. + +## The three surfaces + +| Surface | What it does | Guide | +|---|---|---| +| **Typed events** | The engine records eight kinds of domain event (a new conversation, inbound mail, a status change, a resolved draft, …) reliably, in the same transaction as the change they describe. | [webhooks.md](./webhooks.md) | +| **Webhook delivery** | Registered HTTPS endpoints receive signed, at-least-once notifications of those events. | [webhooks.md](./webhooks.md) | +| **Assistant actors** | AI principals that authenticate with a bearer token, read conversations through the same read API Agents use, and post draft replies that a human Agent must approve before anything is sent. | [assistants-and-drafts.md](./assistants-and-drafts.md) | + +A module typically uses all three: it hears about inbound mail via a +webhook, reads the full conversation via the API, and posts a draft back as +an Assistant. That is exactly the shape of the first real module, +`module-draft-assistant`, referenced throughout these docs as a worked +example. + +## Where the substrate lives on the wire + +Every route below sits under `/api/v1` on your Helpthread deployment's base +URL (e.g. `https://your-helpdesk.example.com`). There is no separate "module +API" host — it is the same Agent Inbox API a human Agent's browser talks to, +with two additional credential classes layered on top of the original +service-token model. + +### Who calls what, authenticated how + +| Caller | Credential | Used for | +|---|---|---| +| **Operator / admin tooling** | `Authorization: Bearer ` (the deployment's one service token) **plus** `X-Helpthread-Agent-Id: ` | Registering webhooks, creating/rotating Assistants, approving or discarding drafts — anything an admin Agent does from a script instead of the UI. | +| **A module, at runtime** | `Authorization: Bearer ht_asst__` (the Assistant's own token) | Reading conversations, posting drafts, posting notes — nothing else (see [assistants-and-drafts.md](./assistants-and-drafts.md)'s fixed capability set). | +| **A module's webhook receiver** | No inbound credential — instead it *verifies* the `X-Helpthread-Signature` header on every delivery it receives (see [webhooks.md](./webhooks.md)). | Confirming a delivery genuinely came from your Helpthread deployment. | + +Every non-2xx response from this API, on every route, uses the same JSON +error envelope: + +```json +{ "error": { "code": "validation_failed", "message": "..." } } +``` + +and every response — success or error — is sent with `Cache-Control: +no-store` (this is authenticated support data; it is never safe to cache). + +## Non-goals for v1 (deliberately not built yet) + +Carried over honestly from the spec, because a module author should not go +looking for these: + +- No in-process/build-time module API — modules are out-of-process only. +- No UI injection points. +- No general scopes/permissions system — an Assistant's capability set is a + small fixed list (see [assistants-and-drafts.md](./assistants-and-drafts.md)), + not something you configure. +- No marketplace plumbing — license keys, a module registry, usage metering. +- No webhook redelivery tooling beyond the one-off `POST .../test` ping. + +Each of these waits for a real module that needs it. + +## Guides + +- **[webhooks.md](./webhooks.md)** — registering an endpoint, the event + vocabulary and envelope, verifying `X-Helpthread-Signature` (complete, + runnable TypeScript sample), delivery guarantees, auto-disable and health + visibility. +- **[assistants-and-drafts.md](./assistants-and-drafts.md)** — creating an + Assistant and handling its token, the fixed capability set, posting a + draft, and the human Agent approval flow. diff --git a/docs/modules/assistants-and-drafts.md b/docs/modules/assistants-and-drafts.md new file mode 100644 index 0000000..c48e7db --- /dev/null +++ b/docs/modules/assistants-and-drafts.md @@ -0,0 +1,233 @@ +# Assistants: identity, capabilities, drafts, and approval + +An **Assistant** is an AI actor principal — never a human (that's an +**Agent**; see [README.md](./README.md)'s vocabulary section). A module that +wants to read conversations and propose replies authenticates as an +Assistant, using a bearer token an admin Agent mints for it. An Assistant +can never send mail directly: every customer-facing reply it writes is a +draft, and a human Agent must approve it before anything reaches the +customer. An internal note is the one exception — `POST +/api/v1/conversations/{id}/notes` (below) is a direct write, visible to +Agents immediately, with no draft/approval step, because a note never +reaches the customer in the first place. + +Examples below use the same `$BASE_URL`, `$HELPTHREAD_API_TOKEN`, and +`$ADMIN_AGENT_ID` as [webhooks.md](./webhooks.md) for the **admin** +endpoints (creating/managing Assistants, approving/discarding drafts — all +Agent actions). The Assistant's own token, once minted, is a completely +separate credential used only by the module itself. + +## Creating an assistant + +```sh +curl -X POST "$BASE_URL/api/v1/assistants" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \ + -H "Content-Type: application/json" \ + -d '{"name": "Draft Assistant", "module": "your-module-slug"}' +``` + +Both `name` (1–200 characters) and `module` (1–100 characters, free text — +nothing validates it against a registry) are required; either missing or +out of range is `400 validation_failed`. + +Response, `201`: + +```json +{ + "assistant": { + "id": "c7a1...-uuid", + "name": "Draft Assistant", + "module": "your-module-slug", + "status": "active", + "createdByAgentId": "", + "createdAt": "2026-07-19T00:00:00.000Z", + "updatedAt": "2026-07-19T00:00:00.000Z" + }, + "token": "ht_asst_c7a1...-uuid_" +} +``` + +## Token handling + +**`token` is shown exactly once, in the create (or rotate) response.** Only +a SHA-256 digest of its secret half is ever persisted — there is no "reveal +token" endpoint and no way to recover a lost one. Copy it immediately into +wherever your module reads its configuration (an environment variable is +the normal choice) and treat it like any other high-entropy secret: never +commit it, never log it. + +The token has the shape `ht_asst__`. Use it as-is on +every Assistant-authenticated request: + +```sh +curl "$BASE_URL/api/v1/conversations/$CONVERSATION_ID" \ + -H "Authorization: Bearer $ASSISTANT_TOKEN" +``` + +Note there is **no** `X-Helpthread-Agent-Id` header on Assistant- +authenticated calls — the token itself carries the Assistant's identity; +that header is only for Agent-authenticated calls (creating/managing +Assistants, approving/discarding drafts — see below). + +**Rotation** mints a fresh secret for the *same* assistant id (so any +`author_assistant_id` a past draft already recorded stays valid) and +returns the new token once; the old one stops verifying immediately — +there is no overlap window: + +```sh +curl -X POST "$BASE_URL/api/v1/assistants/$ASSISTANT_ID/rotate-token" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +Also available, both admin-only: `GET /api/v1/assistants` (roster, never +includes any token) and `PATCH /api/v1/assistants/{id}` with `{"name": +...}` and/or `{"status": "active" | "disabled"}` — a `disabled` Assistant's +token stops authenticating immediately, without needing rotation. + +## The fixed capability set + +An Assistant's token authenticates it, but that alone doesn't authorize +every route — there is exactly one capability-enforcement point +(`src/api/index.ts`), and it allows an Assistant through to only: + +- `GET /api/v1/conversations` and `GET /api/v1/conversations/{id}` — the + same read surface an Agent's UI uses, so a module can pull full thread + content once a webhook tells it something changed. +- `POST /api/v1/conversations/{id}/drafts` — propose a reply (below). +- `POST /api/v1/conversations/{id}/notes` — leave an internal note. + +Every other route — including anything under `/api/v1/webhooks`, +`/api/v1/assistants`, sending a reply, changing status/tags/assignee, or +approving/discarding a draft — answers `403 forbidden` to an Assistant +caller, even though the token itself is valid. There is no scopes system to +configure this differently; a wider capability set waits for a real module +that needs one. + +Soft-deleted conversations are invisible to an Assistant exactly as they +are to everyone else: a `404`, indistinguishable from never having existed. + +## Posting a draft + +```sh +curl -X POST "$BASE_URL/api/v1/conversations/$CONVERSATION_ID/drafts" \ + -H "Authorization: Bearer $ASSISTANT_TOKEN" \ + -H "Idempotency-Key: $EVENT_ID" \ + -H "Content-Type: application/json" \ + -d '{"bodyText": "Thanks for reaching out — here is how to reset your password..."}' +``` + +- `bodyText` is required, 1–5000 characters. `bodyHtml` is optional (no + length bound, but must be a string if present). +- `Idempotency-Key` is **required** — use the triggering webhook delivery's + `eventId` (the pattern this guide recommends throughout): if the same + `conversation.message_received` delivery is retried, replaying the same + `Idempotency-Key` against the same conversation returns the original + draft instead of creating a second one. This is enforced server-side, not + just a convention — draft creation is idempotent by construction. + +Response, `201`, a `ThreadView` (the same shape the conversation-detail +endpoint returns for any thread): + +```json +{ + "id": "d4e2...-uuid", + "direction": "outbound", + "from": "support@your-helpdesk.example.com", + "bodyText": "Thanks for reaching out — here is how to reset your password...", + "bodyHtml": null, + "deliveryStatus": null, + "customerViewedAt": null, + "attachments": [], + "createdAt": "2026-07-19T00:00:05.000Z", + "authorKind": "assistant", + "draftStatus": "awaiting_review" +} +``` + +Note `deliveryStatus: null` — a draft is inert until an Agent approves it; +nothing about posting a draft can cause mail to leave the system. Posting a +draft fires a `draft.created` event ([webhooks.md](./webhooks.md)'s +vocabulary) with `{ threadId, assistantId }`. An unresolved draft also does +**not** reopen a closed conversation or bump its activity timestamp — a +draft sitting in the review queue is not, by itself, evidence that a human +looked at anything. + +## The Agent approval flow + +Everything past this point is an **Agent** action — the core Helpthread +inbox UI does this for a human clicking "approve" or "discard," and it +consumes exactly the same API, so these are also the calls a module author +would use to build their own review tooling or to understand what the UI is +doing. All three require `Authorization: Bearer $HELPTHREAD_API_TOKEN` + +`X-Helpthread-Agent-Id: ` — missing either is `401`. + +**List the review queue** (every conversation's drafts, across the whole +deployment, newest first): + +```sh +curl "$BASE_URL/api/v1/drafts?status=awaiting_review" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +`status=awaiting_review` is required and is the only legal value — resolved +drafts show up in their conversation's own detail view, not here. Supports +`limit` (default 25, max 50) and keyset `cursor` pagination via the +returned `nextCursor`. + +**Approve**, optionally editing the body first: + +```sh +# Approve as-written +curl -X POST "$BASE_URL/api/v1/drafts/$THREAD_ID/approve" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" + +# Approve with edits (recorded as draftEdited: true) +curl -X POST "$BASE_URL/api/v1/drafts/$THREAD_ID/approve" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \ + -H "Content-Type: application/json" \ + -d '{"bodyText": "Edited reply text..."}' +``` + +Approval is a state transition, not a resend: it mints the reply's +threading token and Message-ID, derives the envelope (recipient, subject, +`In-Reply-To`/`References`) exactly the way a normal Agent reply does, and +hands off to the same delivery worker — the mail that goes out is +equivalent to what a human typing the same body and hitting reply would +send. Fires `draft.resolved` immediately (`{ threadId, resolution: +'approved', edited }`), and `conversation.reply_sent` once delivery actually +confirms `sent` (not at accept-for-send time — modules reacting to "we +replied" get truth, not intent). + +Refused `404` (indistinguishable-from-nonexistent) if the conversation is +missing/soft-deleted or `$THREAD_ID` doesn't name a draft currently +`awaiting_review`; refused `409 conflict` if the conversation is `spam`. + +**Discard** (no send, row kept for audit): + +```sh +curl -X POST "$BASE_URL/api/v1/drafts/$THREAD_ID/discard" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +Sets `draftStatus: 'discarded'` and fires `draft.resolved` (`{ threadId, +resolution: 'discarded', edited: false }`). No `spam` restriction — discarding +a draft on a spam conversation is harmless, unlike approving one. + +Both approve and discard return the updated `ThreadView` on success, the +same shape draft-creation returns above. + +## Invariants worth knowing, test-asserted in the engine + +- An Assistant call can never, by itself, cause outbound mail — the only + path to a sent message is an Agent's explicit approval. +- A draft never leaves the system without an approving Agent's identity + recorded on the row (`approved_by_agent_id`). +- An unresolved or discarded draft is excluded from a conversation's + preview text and thread count — it isn't conversation content until it + sends. diff --git a/docs/modules/webhooks.md b/docs/modules/webhooks.md new file mode 100644 index 0000000..6391791 --- /dev/null +++ b/docs/modules/webhooks.md @@ -0,0 +1,315 @@ +# Webhooks: events, delivery, and signature verification + +This covers the admin API for registering a webhook endpoint, the event +vocabulary and envelope every delivery carries, how to verify a delivery's +signature, the delivery guarantees you can rely on, and what happens when +your endpoint starts failing. + +All examples assume `$BASE_URL` is your deployment's origin (e.g. +`https://your-helpdesk.example.com`), `$HELPTHREAD_API_TOKEN` is the +deployment's service Bearer token, and `$ADMIN_AGENT_ID` is the uuid of an +admin-role Agent — every admin route below requires **both** headers. There +is no separate "webhooks API key"; this is the same admin surface an admin +Agent's own browser session uses. + +## Registering a webhook + +```sh +curl -X POST "$BASE_URL/api/v1/webhooks" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "https://your-module.example.com/webhooks/helpthread", + "events": ["conversation.message_received"], + "module": "your-module-slug" + }' +``` + +- `url` — required, `https://` only (no other scheme is accepted, and + registering `http://` is rejected at this call, not silently downgraded), + at most 2048 characters. +- `events` — optional array drawn from the vocabulary below. Omit it (or + send `[]`) to subscribe to **every** event type. +- `module` — optional free-text slug identifying which module owns this + endpoint. Purely attribution; nothing validates it against a registry + today. + +Response, `201`: + +```json +{ + "webhook": { + "id": "b3f6...-uuid", + "url": "https://your-module.example.com/webhooks/helpthread", + "events": ["conversation.message_received"], + "module": "your-module-slug", + "status": "active", + "consecutiveFailures": 0, + "createdAt": "2026-07-19T00:00:00.000Z", + "updatedAt": "2026-07-19T00:00:00.000Z", + "secret": "base64url-256-bit-secret..." + } +} +``` + +**`secret` is returned exactly once, in this response.** It is encrypted at +rest server-side and never appears in any later `GET`/`PATCH` response — +there is no "reveal secret" endpoint. If you lose it, delete the endpoint +and register a new one (v1 has no secret-rotation route for webhooks, unlike +Assistant tokens — see [assistants-and-drafts.md](./assistants-and-drafts.md)). + +### Listing, updating, deleting + +```sh +# List every registered endpoint (never includes the secret) +curl "$BASE_URL/api/v1/webhooks" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" + +# Update url/events/module/status (any subset; unknown fields are a 400) +curl -X PATCH "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" \ + -H "Content-Type: application/json" \ + -d '{"events": ["conversation.message_received", "draft.resolved"]}' + +# Hard delete +curl -X DELETE "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +`status` may only be set to `"active"` or `"disabled"` via `PATCH` — +`"auto_disabled"` is written by the engine only (see +[Auto-disable](#auto-disable-and-health-visibility) below); to clear it, +`PATCH` to `"active"` explicitly, which also resets the failure counter. + +### Testing an endpoint + +```sh +curl -X POST "$BASE_URL/api/v1/webhooks/$WEBHOOK_ID/test" \ + -H "Authorization: Bearer $HELPTHREAD_API_TOKEN" \ + -H "X-Helpthread-Agent-Id: $ADMIN_AGENT_ID" +``` + +Fires a synthetic `test.ping` event through the **real** delivery path +(same signing, same SSRF checks, same timeout) directly at this one +endpoint, ignoring its `events` filter. `202 { "status": "queued" }` on +success. `test.ping` is never a value you can put in an endpoint's `events` +array — it only ever exists as this one synthetic delivery, and its +envelope's `conversationId` is `null` and `data` is `{}`. + +Only an `active` endpoint can be tested — a `disabled`/`auto_disabled` +endpoint gets `409 conflict`; re-enable it first. + +## Event vocabulary and envelope + +Eight real domain event types, closed list — nothing else is ever +delivered as a non-test event: + +| Type | Fired when | `data` | +|---|---|---| +| `conversation.created` | A new conversation is stored | — | +| `conversation.message_received` | Inbound mail is stored on a conversation (including a reopen of a closed one) | `threadId`, `reopened` | +| `conversation.reply_sent` | An outbound reply's delivery is confirmed `sent` (not merely accepted) | `threadId`, `authorKind` | +| `conversation.status_changed` | Conversation status transitions among `active`/`pending`/`closed`/`spam` | `from`, `to` | +| `conversation.tags_changed` | A conversation's tag set is replaced | `tags` | +| `conversation.assignee_changed` | A conversation's assignee is set or cleared | `assigneeAgentId` | +| `draft.created` | An Assistant posts a draft | `threadId`, `assistantId` | +| `draft.resolved` | An Agent approves or discards a draft | `threadId`, `resolution`, `edited` | + +**Events are thin by design.** `data` carries only identifiers and small +typed facts — never a message body, subject line, or address. Fetch full +content through the read API (`GET /api/v1/conversations/{id}`) with your +own credentials once an event tells you something changed. This keeps every +webhook payload free of customer content and PII by construction. + +**Soft-deleted conversations fire nothing.** Deletion is invisible on every +other endpoint (a `404`, indistinguishable from never having existed) and +the same holds here: no event of any type fires for a soft-deleted +conversation, including a `draft.*` for a draft stranded on it. + +Every delivery's JSON body is exactly this envelope: + +```json +{ + "eventId": "uuid", + "type": "conversation.message_received", + "occurredAt": "2026-07-19T12:00:00.000Z", + "conversationId": "uuid", + "data": { "threadId": "uuid", "reopened": false } +} +``` + +`conversationId` is `null` only for the synthetic `test.ping` — every real +event always carries one. + +## Headers on every delivery + +| Header | Value | +|---|---| +| `X-Helpthread-Event` | The event `type` (redundant with the body, provided for routing without a JSON parse) | +| `X-Helpthread-Delivery` | A fresh uuid on **every** HTTP attempt, including a retry of the same event — do not use this for dedupe | +| `X-Helpthread-Signature` | `t=, v1=` — see below | +| `Content-Type` | `application/json` | + +**Dedupe on `eventId`, in the body — never on `X-Helpthread-Delivery`.** +`eventId` is the one value stable across every redelivery of the same +event; the delivery header changes on every attempt by design. + +## Verifying the signature + +`X-Helpthread-Signature` is `t=, v1=`, where the hex +value is `HMAC-SHA256(secret, ".")`, keyed by the +endpoint's own signing secret (the one shown once at registration). This is +the Stripe-shape scheme; the engine's signer lives in +`src/webhooks/delivery.ts`'s `signWebhookPayload`. + +Verify against the **raw** request body bytes, before any `JSON.parse` — +the signature covers exactly what was sent, and re-serializing a parsed +object is not guaranteed to reproduce the same bytes. + +Complete, runnable TypeScript sample: + +```typescript +// verify-signature.ts +import { createHmac, timingSafeEqual } from 'node:crypto' + +export type VerifyResult = { valid: true } | { valid: false; reason: string } + +const DEFAULT_TOLERANCE_SECONDS = 5 * 60 // recommended replay window + +export function verifyWebhookSignature( + header: string | null, + rawBody: string, + secret: string, + toleranceSeconds: number = DEFAULT_TOLERANCE_SECONDS, +): VerifyResult { + if (!header) { + return { valid: false, reason: 'missing signature header' } + } + + const fields = Object.fromEntries( + header.split(',').map((part) => { + const [key, value] = part.trim().split('=') + return [key, value] + }), + ) + const timestamp = Number(fields.t) + const signature = fields.v1 + if (!Number.isFinite(timestamp) || !signature) { + return { valid: false, reason: 'malformed signature header' } + } + + const now = Math.floor(Date.now() / 1000) + if (Math.abs(now - timestamp) > toleranceSeconds) { + return { valid: false, reason: 'stale timestamp — possible replay' } + } + + // Require an exact 64-character hex digest (a SHA-256 HMAC) before + // decoding — Buffer.from(str, 'hex') silently stops at the first + // non-hex character rather than rejecting the string, so a signature + // with trailing garbage after a valid prefix would otherwise decode + // instead of being caught here as malformed. + if (!/^[0-9a-fA-F]{64}$/.test(signature)) { + return { valid: false, reason: 'malformed signature header' } + } + + const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex') + const expectedBuf = Buffer.from(expected, 'hex') + const providedBuf = Buffer.from(signature, 'hex') + if (expectedBuf.length !== providedBuf.length || !timingSafeEqual(expectedBuf, providedBuf)) { + return { valid: false, reason: 'signature mismatch' } + } + + return { valid: true } +} +``` + +Usage in a receiving handler (framework-agnostic — read the raw body +**before** any JSON body-parser middleware consumes it): + +```typescript +const result = verifyWebhookSignature( + request.headers.get('x-helpthread-signature'), + rawBody, // the exact bytes received, not JSON.parse(rawBody) re-stringified + process.env.WEBHOOK_SIGNING_SECRET!, +) +if (!result.valid) { + return new Response('invalid signature', { status: 401 }) +} +const event = JSON.parse(rawBody) +``` + +Reject a stale `t` (the 5-minute default above matches the spec's +recommendation) to close a replay window — an attacker who captures one +valid delivery cannot resend it indefinitely. + +> **Verified, not just written.** This exact function was checked before +> landing in this doc, and re-checked after the hex-validation fix above: +> (1) signed with the engine's own `signWebhookPayload` +> (`src/webhooks/delivery.ts`) and verified successfully by this function, +> byte-for-byte, with a real HMAC computed both ways; (2) cross-checked +> against the independent verifier in `module-draft-assistant/src/verify.ts` +> (the reference module referenced throughout this guide) — both verifiers +> agree on the same signed payload; (3) correctly rejects a wrong secret, a +> tampered body, a stale timestamp, and a signature with trailing non-hex +> garbage appended after a valid-length prefix (`Buffer.from(str, 'hex')` +> otherwise silently truncates instead of rejecting it). The throwaway +> script that ran these checks exited `0`. + +## Delivery guarantees + +| Guarantee | What it means for you | +|---|---| +| **At-least-once** | The same event may arrive more than once. Always dedupe on `eventId`, never assume exactly-once. | +| **No cross-event ordering** | Two events for the same conversation can arrive out of order (different retries, different queue timing). Don't infer sequence from delivery order — the envelope's `occurredAt` and your own read of current state via the API are the source of truth. | +| **Thin payloads** | `data` never carries message content — fetch it via the read API with your own credentials. | +| **2xx acks, anything else retries** | Any `2xx` status is success. A non-2xx HTTP response, a timeout (10s hard deadline), or a connection error is a failed attempt and goes through the queue's retry/backoff. Redirects are never followed — a `3xx` is a failure, not a hop. | +| **HTTPS only, SSRF-checked at delivery time** | Only `https://` endpoints are ever registered, and every delivery attempt resolves the endpoint's hostname and refuses to connect if it resolves to a private/loopback/link-local address — even if the hostname resolved to a public address when you registered it. If your endpoint's DNS changes to something disallowed, deliveries start failing, not the registration. | +| **SSRF refusals are NOT retried** | Unlike an ordinary HTTP failure, an SSRF refusal is dead-lettered immediately on the first attempt, never queued for retry — retrying can't change what a hostname resolves to, so burning the retry budget on it would only delay the signal that your endpoint needs attention. This counts toward the endpoint's [auto-disable](#auto-disable-and-health-visibility) failure counter the same as any other dead-lettered delivery. | + +## Auto-disable and health visibility + +After **20 consecutive** failed delivery attempts to one endpoint, it flips +from `active` to `auto_disabled` automatically — this is conservative on +purpose: a silently-disabled endpoint means a paid module's user stops +getting the events it depends on without anyone noticing, so the threshold +errs toward catching that quickly. A single success resets the counter to +zero without touching status; only a deliberate `PATCH .../{id} +{"status":"active"}` re-enables an `auto_disabled` endpoint (which also +resets the counter). + +Two operator-visible signals surface this, both via `GET +/api/v1/webhooks` (`status` and `consecutiveFailures` on each row) and via +the deployment's internal health endpoint: + +```sh +curl "$BASE_URL/api/v1/internal/health" \ + -H "Authorization: Bearer $CRON_SECRET" +``` + +which reports, among other sections: + +```json +{ + "ok": false, + "alerts": ["webhook-endpoint-auto-disabled: 1 webhook endpoint(s) auto-disabled ..."], + "webhooks": { + "autoDisabled": [ + { "id": "...", "url": "https://your-module.example.com/webhooks/helpthread", "consecutiveFailures": 20 } + ], + "deliveryFailuresLast24h": 3 + } +} +``` + +`/api/v1/internal/health` answers `200` when healthy and `503` when any +alert (including this one) is tripped — a plain status-code monitor is a +complete alerting story; you don't need to parse the body to know something +needs attention. This endpoint is an **operator** concern (it needs the +deployment's `CRON_SECRET`, a separate credential from anything a module +holds) — documented here so a module author building against a self-hosted +Helpthread instance knows where their own endpoint's health is visible to +the person running it.