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 as401 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 answers401(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
draftwith a safe default graph (start → end/resolved) that lints clean and runs — replace it node-by-node via the canvas (or pass a fulldocumentto MCPcreate_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_versionsrow. API-key publishes stampcreated_by: null(keys have no user id). - Update (JOE-303):
PATCH/ MCPupdate_flowreplaces title, description, and/or the DRAFT document (validated against the canvasflowDocumentSchema). 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_versionsintoflow_sessions— soconfirm: trueARCHIVES it instead (status: archived: hidden from the default lists, start 404s, versions + session history kept). An unconfirmedDELETE409s 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 is404 { "error": "not found" }, never an existence oracle), ORcontactEmail(+ optionalcontactName) as a find-or-create fallback keyed by (org, lowercase email) — an unknown email creates the contact as a lead (leadStatus: "new") and fires thecontact.createdwebhook. Neither →400. - Pipeline (JOE-244 + JOE-316):
pipelineSlugmatches 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 carriespipelineFallback: { 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:
externalIdis the dedupe key on (org, externalId) — a replay returns the stored deal with200 { deal, existing: true }and creates nothing (JF-102 invoice-push convention;201 … existing: falseon create). - Other fields:
ownerIdmust be an org member (400otherwise — ids are the USER ids from/api/v1/org/{orgSlug}/members),categoryIdmust exist in the org,amountCentsinteger cents,expectedCloseDateYYYY-MM-DD,description≤5000. Structured intake data lands oncrm_deals.intake_answersjsonb: template-backed callers passintakeTemplateId+intakeAnswers(validated against the template's questions); raw callers passintake(orintakeAnswers) 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,400readable,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 }] }] }— theslugis the stable integration handle to send aspipelineSlug;isDefaultflags 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 is409, 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" }ifslugis in the body — renames never break integrations). →200 { "pipeline": {…} }. - DELETE is guarded:
409for the DEFAULT pipeline (it anchors every integration that omitspipelineSlug— it can never be deleted) and409while 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),400readable,409guard.
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;selectrequiresoptions(≤20); ≤30 questions per form;keyis what answers are stored under at submit (same validation vocabulary the UI actions enforce — lib/intake/input.ts is the shared schema). - Create (
POST):namerequired;headline/description/successMessage/subjectPrefixoptional;assigneeIdmust be an org member USER id (400otherwise —?active=filter on/api/v1/org/{orgSlug}/membersfinds usable ones);createContactdefaults true;questionsdefaults []. - Update (
PATCH) is a TRUE partial patch: absent keeps the stored value,nullclears an optional field,questions(when present) replaces the whole list, andstatusmoves thedraft → published → closedlifecycle (publish to make the public link accept submissions;closedstops 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 — preferstatus: "closed"to stop submissions while keeping history. MCP callers get the same semantics behind theconfirm: truegate (delete_intake_form). publicTokenis 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 resolvedsections+ parsedsectionsData(environments / build-deploy / repos). - CRM contacts read + upsert (JF-102):
/api/v1/crm/{orgSlug}/contacts, with?updatedSinceincremental 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 optionalprojectId(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-alllines[]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/externalIdare 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 > 0must be≥ priceCents, andpriceMaxCentswhen 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/priceMaxCentsride along next topriceCents).
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 …/invoicesand MCPcreate_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 amodifiers[]definition-snapshot array ({name, kind: "percentage"|"fixed", percentBp|amountCents}, max 20; percentage rates are basis points and may be negative). The MCPcreate_invoicedescription documents the same semantics. - Reads carry a formatted
unitPriceDisplayadditively — catalog item get/list, invoice detail lines (REST detail + MCPget_invoice), and the public catalog payload. 1500 millicents with unitwordreads back as"$0.015 / word"; a 0/absent rate readsnull; 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) gainspricingModel/unitPriceMillicents/unit/unitPriceDisplayas 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,cadenceweekly|biweekly|monthly|quarterly|annually with the matching anchor (dayOfWeek0-6 for weekly/biweekly,dayOfMonth1-31 otherwise), and the price asamountCentsXORproductId(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
POSTto the registered URL withcontent-type: application/jsonand 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).