# Design note — webhooks

**Dated 2026-08-05. Written before the code, per the provenance practice in
[ROADMAP.md](../../ROADMAP.md#proving-independence).**

The API let something reach in. Webhooks let the forum reach out: a new topic
posts to Slack, a report opens a ticket, a registration lands in a CRM.

---

## The problem this feature actually has

**Convoro has no queue and no cron.** So a webhook fires *inside the request
of the person who caused it* — the member who posted the topic waits for
somebody else's server to answer.

That is not a detail, it is the whole design constraint. An endpoint that
hangs for thirty seconds turns every new post into a thirty-second wait, and
one that goes down turns the forum into something that feels broken. This
exact shape has caused a real outage before on another product, and it is worth
being paranoid about rather than optimistic.

So every decision below is about containing that.

## Decision 1 — a hard ceiling, and it is small

**Two seconds total, one second to connect**, per delivery. Not configurable,
because the setting somebody would reach for is "longer", and longer is the
failure mode.

A webhook that cannot answer in two seconds is a webhook that should be
answering asynchronously on its own side — accept, queue, return 200. That is
what every service receiving webhooks does, and it is fair to expect it.

## Decision 2 — failure disables, rather than retrying forever

No retries. A retry inside the visitor's request is just the timeout again.

Instead: consecutive failures are counted, and at **ten in a row the webhook
disables itself** and says so in the admin panel. A dead endpoint therefore
costs a site at most ten slow requests, not every request forever.

A single success resets the counter, so an endpoint having a bad minute is not
punished for it.

## Decision 3 — the whole thing is skippable and it fails silently

If sending throws, the request carries on. A webhook is bookkeeping; a member
posting is the product. Nothing about a failed delivery is ever shown to the
person who triggered it, because it is not their problem and not their
business.

Failures go to the delivery log and the admin panel, where somebody can act on
them.

## Decision 4 — payloads are signed, and the signature covers the body

`X-Convoro-Signature: sha256=<hmac>` over the exact request body, with a secret
generated per webhook. Plus `X-Convoro-Event` and a delivery id.

Signing matters more here than it looks: a receiving endpoint is usually a
public URL, so without a signature anybody who learns it can post fake events —
"user X was banned", "payment received" — into whatever it feeds.

The secret is shown when the webhook is created and stored in clear, because
unlike a token it has to be *used* to sign, not just compared. That is a real
difference from the API tokens and worth stating rather than glossing.

## Decision 5 — events are the ones core already announces

The eleven typed domain events from phase 5, chosen per webhook. No new event
plumbing: if core announces it, a webhook can subscribe to it, and anything
core does not announce is a gap in the events rather than in webhooks.

The payload is the event's own properties plus a small, stable envelope. It is
deliberately thin — an id and enough to identify the thing, not a full
serialisation. A receiver that wants more has the API, and a thin payload does
not silently leak a field somebody added to a row later.

## Decision 6 — deliveries are logged, and the log is trimmed

Every attempt records the event, the status, how long it took and what came
back. That is the only way to answer "why did Slack not get it", which is the
question people actually ask.

Capped per webhook, oldest pruned. A log nobody trims is a table that only
grows — the same rule as theme revisions.

## What this does not do

- **No retries, no queue, no background sending.** All three want a worker,
  and adding one that has to be run is a bigger commitment than this feature.
  When Convoro grows a queue, this is the first thing that should move onto it.
- **No outbound IP restrictions.** An administrator can point a webhook at an
  internal address, which on a shared host is a way to probe the network. That
  is a real concern and it wants a proper allow/deny policy rather than a
  half-measure; noted here rather than pretended away.
- **No templating of the payload.** JSON, one shape. Anybody needing another
  shape can put a tiny receiver in front, which is what those services are.
