JoeFlow Developer documentation

JoeFlow REST API v1 — agent/integration reference

The org-key REST surface under /api/v1, with the agent-facing reads and writes called out. The MCP tool surface over the same owner libs is documented in docs/mcp.md (coverage matrix) and docs/mcp-tools.md (generated per-tool schemas); the Projects contract (REST + MCP) has its own deep-dive in docs/projects-api-mcp.md.

Auth (JF-103 / JF-109 model)

  • Credential: org API key — Authorization: Bearer jfk_live.<48 hex>, minted under Settings → API keys.
  • Scopes: one vocabulary scope per module area (crm, invoices, chats, acquisition, assets, appointments, stock, workforce, tax, health, kb, flows, projects, insights, guild, catalog, tickets, members). NULL/empty scope set = full access (absent-enabled); an explicit set must include the scope the route requires. A valid-but-out-of-scope key reads as 401 unauthorized — never disclosed.
  • Rate limit: 600 requests / 5 min per key, fixed epoch-aligned window. Over-limit → 429 + Retry-After (fail-open limiter: counter errors allow).
  • Org binding: the org ALWAYS comes from the key. {orgSlug} in the URL must resolve to the key's ACTIVE org, or the route answers 401 (cross-org reads as unauthorized/not-found — never an error leak).
  • Never exposed over the API: API-key secrets, vault credentials, webhook signing secrets, portal/board/booking tokens (each token is itself a credential), other orgs' data.

Pagination convention for list routes: ?limit= (1–200, default 100) and ?offset=, unless the route documents a cursor.


Org members (JOE-223) — assignee resolution

GET /api/v1/org/{orgSlug}/members
    ?active=true|false        (omit = everyone)
    &limit= &offset=
Scope: members
{
  "members": [
    { "id": "usr-32-char-id", "name": "Ada Admin", "email": "ada@acme.test",
      "role": "admin", "active": true }
  ]
}

id is the USER id — the value every assignee/owner field consumes (tickets.assigneeId, deals.ownerId, projects.leadId). role is the org membership role (admin | supervisor | agent); active: false means the user is suspended (JF-32) and is not a usable assignee. Membership permissions/groups/billable rates are deliberately NOT serialized. Sorted by name; paginated.

Also on MCP: list_members (same wire shape, offset-cursor pagination).

Flows (JF-64 + JOE-223 lifecycle)

POST /api/v1/flows/{orgSlug}                                  → start session
     { flowSlug, requesterEmail?, ticketNumber?, answers?[] }
POST /api/v1/flows/{orgSlug}/sessions/{id}                    → answer
GET  /api/v1/flows/{orgSlug}?sessionId={id}                   → session status
GET  /api/v1/flows/{orgSlug}                                  → LIST (JOE-223)
     ?status=draft|published|archived  &limit= &offset=
PATCH  /api/v1/flows/{orgSlug}/{flowId}                       → update (JOE-303)
     { title?, description?, document? }  — document is a FULL draft replacement
DELETE /api/v1/flows/{orgSlug}/{flowId}?confirm=true          → delete/archive (JOE-303)
     (confirm also accepted as the JSON body { "confirm": true })
Scope: flows

List rows:

{
  "flows": [
    { "id": "…uuid", "title": "Triaging requests", "slug": "triaging-requests",
      "status": "published", "updatedAt": "…",
      "publishedVersion": { "id": "…uuid", "version": 2 } }
  ]
}

slug is what POST's flowSlug consumes; publishedVersion is the immutable version (JF-1) an external runner pins — null for a flow with no published version (start would 409).

Lifecycle notes (shared with the MCP flow tools):

  • Creation is draft-first: a new flow lands as draft with a safe default graph (start → end/resolved) that lints clean and runs — replace it node-by-node via the canvas (or pass a full document to MCP create_flow).
  • Publishing validates the draft with the same graph lint the canvas publish action blocks on; only then is the draft frozen as the next immutable flow_versions row. API-key publishes stamp created_by: null (keys have no user id).
  • Update (JOE-303): PATCH / MCP update_flow replaces title, description, and/or the DRAFT document (validated against the canvas flowDocumentSchema). Published versions are immutable (JF-1): correcting a published flow = update the draft + publish, which mints the next version under the SAME flow + slug — no create-new-and-republish. Retitling never changes the slug. Archived flows refuse updates (409).
  • Delete (JOE-303) — safe policy: a draft with no published versions is permanently deleted (nothing can reference a flow that never published). A flow WITH published versions is never destroyed — a hard delete would cascade through flow_versions into flow_sessions — so confirm: true ARCHIVES it instead (status: archived: hidden from the default lists, start 404s, versions + session history kept). An unconfirmed DELETE 409s with a preview of which path applies; the MCP tool returns the same preview as its dry-run.
  • Slug reuse policy: slugs are unique per org and permanent for the life of the flow row. A hard-deleted draft's slug becomes reusable (create_flow may mint it again); an archived flow keeps its row, so its slug stays reserved forever.
  • Session start/answer stay REST-only (MCP is deliberately discovery + authoring): the engine's side effects fire in exactly one place.

Insights (JOE-81 + JOE-223 catalog)

GET /api/v1/insights/{orgSlug}/metrics
    ?rollup=audience|revenue_cents|views|engagement
      | ?platform={id}&metric={key} [&dimension=]
    &days=30 &format=value|series|platforms|sources
Scope: insights

Valid values are machine-readable:

GET /api/v1/insights/{orgSlug}/catalog
Scope: insights
{
  "rollups": [
    { "key": "audience", "label": "Audience", "metricKeys": ["subscribers", "…"] }
  ],
  "platforms": [
    { "id": "youtube", "name": "YouTube", "category": "video", "mode": "api",
      "metrics": [ { "key": "subscribers", "label": "Subscribers",
                     "unit": "count", "kind": "level", "rollup": "audience" } ] }
  ]
}

A malformed query 400s with rollups (the valid keys) and a hint pointing at the catalog. A query with neither rollup nor platform + metric 400s the same way. value format (the default) also carries previous for percent-change rendering. The module gate applies: Insights toggled off → 404 on both routes.


CRM deals (JOE-243) — deal push for intake forms

POST /api/v1/crm/{orgSlug}/deals
Scope: crm

Body (JSON; the 32bitsoft.com website intake is the reference caller):

{
  "title": "Intake IN-GOXYLQJ7 — Piper Wade (Pilot episode)",
  "contactId": "…uuid", 
  "pipelineSlug": "Sales",
  "ownerId": "usr-32-char-id",
  "amountCents": 375000,
  "externalId": "IN-GOXYLQJ7",
  "description": "…",
  "intake": { "reference": "IN-GOXYLQJ7", "shape": "pilot", "…": "…" }
}
  • Customer required: contactId / companyId (real rows of THIS org — a cross-org or unknown id is 404 { "error": "not found" }, never an existence oracle), OR contactEmail (+ optional contactName) as a find-or-create fallback keyed by (org, lowercase email) — an unknown email creates the contact as a lead (leadStatus: "new") and fires the contact.created webhook. Neither → 400.
  • Pipeline (JOE-244 + JOE-316): pipelineSlug matches the workspace's pipeline SLUGS first (crm_pipelines.slug — the stable handle every pipeline gets at create, org-unique, NEVER rewritten by a rename), then the pipeline NAMES (case-insensitive; slug-style input normalizes onto the name, "client-accounts" → "Client accounts"). No match is not an error — the deal falls back to the default pipeline and the response carries pipelineFallback: { requested, resolvedToSlug }, so an intake with a stale/missing pipeline reference still lands the deal. Absent → the default pipeline, no flag. The deal lands on the pipeline's FIRST stage (always open, so no won/proposal epilogues run on create).
  • Idempotency: externalId is the dedupe key on (org, externalId) — a replay returns the stored deal with 200 { deal, existing: true } and creates nothing (JF-102 invoice-push convention; 201 … existing: false on create).
  • Other fields: ownerId must be an org member (400 otherwise — ids are the USER ids from /api/v1/org/{orgSlug}/members), categoryId must exist in the org, amountCents integer cents, expectedCloseDate YYYY-MM-DD, description ≤5000. Structured intake data lands on crm_deals.intake_answers jsonb: template-backed callers pass intakeTemplateId + intakeAnswers (validated against the template's questions); raw callers pass intake (or intakeAnswers) free-form (20 KB cap).
  • Responses: 201 { "deal": {…}, "existing": false }, 200 { "deal": {…}, "existing": true }. Errors: the house shapes — 401 unauthorized (bad token, out-of-scope key, unknown org slug, org mismatch), 404 not found, 400 readable, 429 rate limited + Retry-After.

MCP parity: create_deal shares the same owner lib (it also takes pipelineSlug — same slug/name resolution + default fallback — and reports pipelineFallback) but has no externalId/intake fields. list_deals / get_deal MCP reads have no REST list route yet.


Deal pipelines (JOE-316) — user-managed pipelines with stable slugs

GET    /api/v1/crm/{orgSlug}/deal-pipelines
POST   /api/v1/crm/{orgSlug}/deal-pipelines
PATCH  /api/v1/crm/{orgSlug}/deal-pipelines/{pipelineId}   { "name": "…" }
DELETE /api/v1/crm/{orgSlug}/deal-pipelines/{pipelineId}
Scope: crm
  • GET → { "pipelines": [{ id, name, slug, isDefault, dealCount, stages: [{ id, name, position, probability }] }] } — the slug is the stable integration handle to send as pipelineSlug; isDefault flags the pipeline every slugless deal create files into.
  • POST { name (required, ≤120, org-unique), slug? (lowercase letters/numbers/dashes ≤100, org-unique — an explicitly TAKEN slug is 409, never silently aliased; absent = derived from the name), stages? [{ name, probability 0-100?, position? }] (omitted = the house default set; explicit positions are honored with the rest densified around them) } → 201 { "pipeline": {…} }.
  • PATCH renames (400 { "error": "slug is immutable" } if slug is in the body — renames never break integrations). → 200 { "pipeline": {…} }.
  • DELETE is guarded: 409 for the DEFAULT pipeline (it anchors every integration that omits pipelineSlug — it can never be deleted) and 409 while deals reference the pipeline (move or delete them first); an empty non-default pipeline deletes (stages cascade). → 200 { "ok": true }.
  • Errors: the house shapes — 401 unauthorized, 404 not found (unknown or cross-org id — never an existence oracle), 400 readable, 409 guard.

MCP parity: list_deal_pipelines (the same list payload, slugs included), create_deal_pipeline, update_deal_pipeline, and delete_deal_pipeline (destructive — dry-run impact report without confirm: true).


Intake forms (JOE-302) — Tickets > Intake over the API

GET    /api/v1/tickets/{orgSlug}/intake-forms[?status=draft|published|closed &limit= &offset=]
POST   /api/v1/tickets/{orgSlug}/intake-forms             { name, … }
GET    /api/v1/tickets/{orgSlug}/intake-forms/{id}
PATCH  /api/v1/tickets/{orgSlug}/intake-forms/{id}        { …partial patch }
DELETE /api/v1/tickets/{orgSlug}/intake-forms/{id}
Scope: tickets

The JOE-75 intake forms (the /{org}/tickets/intake admin UI) over the org key — create, inspect, version, and delete the shareable questionnaires whose submissions create a contact + ticket. New forms land as draft with a fresh publicToken; only published forms accept submissions.

Form shape (serialized in every response; list rows additionally carry submissionCount, newest first):

{
  "forms": [
    {
      "id": "…uuid", "name": "Support intake", "status": "published",
      "publicToken": "…uuid",
      "headline": "Tell us about the project", "description": null,
      "successMessage": "Thanks!", "subjectPrefix": "[Web]",
      "assigneeId": "usr-32-char-id", "createContact": true,
      "questions": [
        { "key": "service", "label": "Service", "type": "select",
          "required": true, "options": ["Build", "Consult"],
          "helpText": "Pick one" }
      ],
      "submissionCount": 7,
      "createdAt": "…", "updatedAt": "…"
    }
  ],
  "total": 1
}
  • Question types: text | textarea | select | checkbox; select requires options (≤20); ≤30 questions per form; key is what answers are stored under at submit (same validation vocabulary the UI actions enforce — lib/intake/input.ts is the shared schema).
  • Create (POST): name required; headline/description/ successMessage/subjectPrefix optional; assigneeId must be an org member USER id (400 otherwise — ?active= filter on /api/v1/org/{orgSlug}/members finds usable ones); createContact defaults true; questions defaults [].
  • Update (PATCH) is a TRUE partial patch: absent keeps the stored value, null clears an optional field, questions (when present) replaces the whole list, and status moves the draft → published → closed lifecycle (publish to make the public link accept submissions; closed stops submissions while keeping history). (The UI edit dialog remains full-replace — unaffected.)
  • Delete is a HARD delete: the form row goes and its submissions cascade (FK onDelete). There is no restore window — prefer status: "closed" to stop submissions while keeping history. MCP callers get the same semantics behind the confirm: true gate (delete_intake_form).
  • publicToken is the credential for the public surfaces: /forms/{publicToken} (hosted page) and the submit endpoint below.

Public field schema for dynamic rendering

GET /api/public/intake/{publicToken}          (no auth — the token IS the credential)
{
  "form": { "name": "…", "headline": "…", "description": "…",
            "successMessage": "…", "questions": [ …same schema… ] },
  "honeypot": "company",
  "submitPath": "/api/public/intake/{publicToken}/submit"
}

Lets external sites (the 32bitsoft.com website pattern) render the form dynamically per service and POST { name, email, answers, company? } to submitPath (answers keyed by question key). Same gate as the submit route: unknown tokens, draft/closed forms, and inactive orgs all read as a bare 404 — no existence oracle. honeypot names the field renderers must include (humans never fill it; bots do). Routing config (subjectPrefix/assigneeId/createContact) never appears here.

MCP parity: list_intake_forms, get_intake_form, create_intake_form, update_intake_form, delete_intake_form (destructive, dry-run + confirm) over the same owner seam, scope tickets, offset-cursor pagination on the list.


Other REST surfaces (existing, unchanged here)

  • Projects (JOE-154): GET /api/v1/projects/{orgSlug}/projects[/{id}], tasks, milestones, time, deliverables — full contract in docs/projects-api-mcp.md. The project detail response already carries the resolved sections + parsed sectionsData (environments / build-deploy / repos).
  • CRM contacts read + upsert (JF-102): /api/v1/crm/{orgSlug}/contacts, with ?updatedSince incremental feeds; deal create (JOE-243): /api/v1/crm/{orgSlug}/deals (section above).
  • Invoices/payments read + idempotent push (JF-102): /api/v1/invoices/{orgSlug}/invoices|payments. The invoice list accepts ?projectId= (uuid) to scope to one project's documents (JOE-467); POST accepts an optional projectId (org-validated — a foreign/missing id is a readable 400) that feeds job-costing revenue rollups. Invoice lines accept sub-cent unit rates (unitPriceMillicents + modifiers[] — see Sub-cent unit pricing below). JOE-467 adds the draft update door: PATCH /api/v1/invoices/{orgSlug}/invoices/{invoiceId} edits a DRAFT document (true partial patch — absent keys keep stored values, explicit null clears; notes/termsText/currency/issueDate/dueDate/ termsDays/contactId/companyId/projectId/documentDiscountPercent plus a replace-all lines[] in the same shape as POST). Totals recompute, the bill-to snapshot refreshes when the customer changed, and an 'updated' audit event is written. Sent/paid/voided/deleted documents refuse with a readable 400 ("Only drafts can be edited"); source/externalId are the create-time identity and are rejected in a patch; identical replay payloads converge on the same row state. MCP parity: create_invoice (projectId), list_invoices (projectId filter), update_invoice (invoiceId or invoiceNumber addressing).
  • Read-only module lists (JF-75/83/84/85/86/96/99): acquisition, assets, appointments, stock, workforce, tax, health, flows sessions — each under /api/v1/{module}/{orgSlug}/…, bearer + scope per the model above.

Catalog items — list price + price range (JOE-467)

Catalog item master data lives under /api/v1/catalog/{orgSlug}/items (POST create / GET list / GET+PUT+PATCH /{itemId}, scope catalog) with MCP parity (create_catalog_item, update_catalog_item, get_catalog_item, list_catalog_items — the MCP schemas ARE the shared REST schemas, so the fields below ride both doors).

  • listPriceCents (integer ≥ 0, default 0) — the regular/compare-at price shown beside the launch price ("$X launch (regular $Y)"). 0 = no compare-at — every pre-existing item defaults to 0 and reads unchanged.
  • priceMaxCents (integer ≥ 0, nullable, default null) — optional range top for low–high pricing ("from $X to $Y"); null = not a range. Ranges are allowed on recurring items ("from $X/mo") — no special-casing.
  • Validation (readable 400s, enforced in the shared save path so action/ REST/MCP all inherit): listPriceCents > 0 must be ≥ priceCents, and priceMaxCents when set must be ≥ priceCents.
  • Both fields are returned on get/list item payloads (and serialized additively on the public catalog read /api/v1/catalog/{orgSlug}/products — listPriceCents/priceMaxCents ride along next to priceCents).

Invoice lines carry a definition SNAPSHOT of the linked item's list price: POST /api/v1/invoices/{orgSlug}/invoices and the draft PATCH …/invoices/{invoiceId} stamp listPriceCents onto every line that carries a productId (null on custom lines and on items whose list price is 0 — no compare-at). Snapshots are taken at line-create only; catalog edits never rewrite signed documents, and the snapshots are pure metadata — invoice totals are byte-identical with or without them (pinned test).

Catalog bundles + add-on relations (JOE-467)

Items can declare two kinds of relations, exposed through the same doors as every other catalog field (REST items routes + MCP create_catalog_item/update_catalog_item/get_catalog_item/ list_catalog_items — the schemas are shared):

  • components — a REPLACE set of {itemId, quantity} pairs declaring a BUNDLE's constituents (quantity ≥ 1, default 1, max 50). Position follows array order.
  • addOnItemIds — a REPLACE set of item ids declaring directional suggested add-ons (item → suggested add-on, NOT symmetric; max 50).

Replace-set semantics mirror modifiers exactly: on a create/full save the payload list (absent included) is the item's full sheet after the save (absent = []); on a patch an omitted list keeps the stored rows, and a provided list — including [] — replaces the whole sheet. Validation runs before any write with readable 400s: every referenced id must be an EXISTING item in the same workspace (foreign/unknown → "Component item not found" / "Add-on item not found"), and an item can never reference itself ("An item cannot be a component of itself" / "An item cannot be its own add-on"). Duplicate ids collapse to the first occurrence (the relation tables enforce unique (bundle, component) and unique (product, related, kind); the kind column is text with add_on as v1's only value).

Every get/list item payload returns the hydrated sets as READ-TIME convenience labels — ids remain the stored truth:

  • components: [{id, name, priceCents, unit, quantity}] — so a bundle's à-la-carte value is computable client-side (Σ quantity × priceCents). NO pricing math is applied server-side; invoice/quote behavior is unchanged (expose-only v1 — any future bundle pricing must follow the definition-snapshot rule above).
  • addOns: [{id, name, priceCents, unit}].

Convention: an ARCHIVED referenced item still hydrates with its last name/price/unit labels (the relation never silently hides a stored reference — fetch the item to see archivedAt). Deleting a referenced item cascades its relation rows away.

Sub-cent unit pricing (JOE-467) — millicents, modifiers, unitPriceDisplay

Unit-priced catalog items carry their rate at 1/1000-cent precision: unitPriceMillicents, where 1 millicent = $0.00001 — a $0.015/word rate stores as 1500. pricingModel is fixed (the legacy priceCents path, unchanged) or unit (priceCents stays 0 and the rate prices against the item's unit label). Rounding happens only at the invoice line: line gross = round(quantity × unitPriceMillicents / 1000), then each modifier delta rounds once (half away from zero, applied in array order) — the item-level rate is never rounded, so 10,000 words × 1500 mc = exactly $150.00.

  • Invoice lines accept sub-cent rates on create AND draft patch (REST POST/PATCH …/invoices and MCP create_invoice/update_invoice — the REST and MCP line schemas are the same object, so a payload valid on one door is valid on both): unitPriceMillicents (integer, 0..2000000000, nullable) plus a modifiers[] definition-snapshot array ({name, kind: "percentage"|"fixed", percentBp|amountCents}, max 20; percentage rates are basis points and may be negative). The MCP create_invoice description documents the same semantics.
  • Reads carry a formatted unitPriceDisplay additively — catalog item get/list, invoice detail lines (REST detail + MCP get_invoice), and the public catalog payload. 1500 millicents with unit word reads back as "$0.015 / word"; a 0/absent rate reads null; a line whose product (and therefore unit label) can't be resolved degrades to the bare rate ("$0.015").
  • The public catalog payload (GET /api/v1/catalog/{orgSlug}/products) gains pricingModel / unitPriceMillicents / unit / unitPriceDisplay as additive keys — previously the payload omitted the rate entirely, so a per-word item read as price 0 publicly.
  • PDF/UI rendering of unit-priced lines is an app-side concern tracked separately (JOE-468) — the API payload is complete.

Service plans (JOE-467) — recurring billing via API

Recurring service plans (JOE-45 auto-invoice / auto-book) are exposed at /api/v1/crm/{orgSlug}/service-plans (scope crm — plans are CRM-db, CRM-owned, not their own module):

  • GET …/service-plans — paginated list, newest first, ?limit= (1-200, default 100) &offset=, filtered by ?contactId=, ?companyId=, ?active=true|false → { total, plans }.
  • POST …/service-plans — create: contactId (required — plans attach to contacts), name, cadence weekly|biweekly|monthly|quarterly|annually with the matching anchor (dayOfWeek 0-6 for weekly/biweekly, dayOfMonth 1-31 otherwise), and the price as amountCents XOR productId (exactly one — a catalog item supplies its per-cycle price, so a recurring catalog item can drive plan creation without pinning a number). Optional: companyId (rollup), eventTypeId (auto-book target), invoiceDescription, autoBook/autoBill (default true), nextInvoiceAt/nextBookAt (YYYY-MM-DD cursor overrides).
  • Eligibility mirrors the UI action (JOE-255): the contact must exist in the org (404 otherwise) and its live company must carry "Eligible for service plans" — a company-less customer, a flagged-off company, and an explicitly picked unflagged company all return readable 400s.
  • Idempotency: a replay (same org + contact + name) returns the stored plan with existing: true, 200 not 201 — supply a deterministic name (the dealServicePlanName convention, "<line> (deal: <title>)") to make retries safe.
  • MCP parity: create_service_plan (write — flat tool-use meter, not an event-billed tool) + list_service_plans (read).

Both doors ride the same owner seam as the UI (lib/crm/service-plans.ts through service-plans-api.ts); the sweep still turns due plans into invoices and bookings — the API starts and reads plans, it does not fork the billing engine.


Outbound webhooks (JF-108) — consumer contract

Orgs register HTTPS endpoints under Settings → Webhooks (admin UI + server actions — endpoint management is deliberately not a REST surface). Delivery itself is part of the integration contract, so it is documented here: when the events below fire, JoeFlow POSTs a signed JSON body to every ACTIVE endpoint of the org whose event filter matches (an empty filter subscribes to ALL events).

Events

contact.created, contact.updated, invoice.created, payment.recorded, release.published, ticket.replied_public, ticket.resolved, project.created, project.updated, project.completed, project.deleted, task.created, task.updated, task.moved, task.claimed, task.completed, time.logged

The authoritative list is WEBHOOK_EVENTS in src/lib/webhooks/shared.ts. Emission points: the REST write chokepoints (projects/tasks/time), the invoicing/contact write seams, the release publish flow, and the ticket support loop (public replies + resolution transitions). UI server actions do not echo into outbound webhooks — the REST seam is the single fan-out for project-surface events.

Delivery shape

  • POST to the registered URL with content-type: application/json and a 5-second timeout.
  • Headers:
    • X-JoeFlow-Event: <event> — the event type from the list above.
    • X-JoeFlow-Signature: sha256=<hex> — HMAC-SHA256 over the EXACT raw request body bytes, keyed with the endpoint's signing secret (a 32-byte hex shown ONCE at webhook creation; stored vault-encrypted and never returned afterwards).
  • Body: the event's data as JSON (the same shape the emitting REST write returns). Treat unknown fields as additive — new payload keys are not breaking changes.

Verification recipe (consumer side): read the raw body as bytes BEFORE any JSON parsing, compute HMAC_SHA256(secret, rawBody), hex-encode it, and compare against the sha256=<hex> value in X-JoeFlow-Signature with a timing-safe compare (e.g. crypto.timingSafeEqual). Do NOT re-serialize a parsed object to verify — key order or whitespace differences would break the signature. A mismatch means the body was tampered with or is not from JoeFlow; reject with any non-2xx status to trigger the retry path below.

Retries

A failed attempt (non-2xx response, timeout, or network error) schedules a retry at +1 min, then +5 min, then +30 min — 3 attempts max. After the third failure the delivery is terminal failed. Retries re-post the STORED original payload through the same signing path; the retry sweep (/api/v1/sweep/webhooks, advisory lock joeflow-webhook-sweep) re-posts due rows. Deactivating a webhook quiesces its queued retries (pending rows age out without delivery).

Manual redelivery

Settings → Webhooks lists the recent deliveries per endpoint with a manual Redeliver action for unsuccessful (failed or pending) rows. Manual redelivery re-posts the stored payload through the same signing path and never re-arms the retry schedule — a failed manual attempt stays terminal (nextAttemptAt stays null); click Redeliver again to try once more.

Testing conventions

Route tests mock the db seam (@/lib/db) and drive the real route handlers through real verifyApiKeyAuth, so the 401/403/429 paths are the genuine auth flow (see src/lib/members/__tests__/members-api.test.ts and src/app/api/v1/insights/[orgSlug]/__tests__/insights-api.test.ts for the idiom).