# Delivering an outbound webhook

Connect publishes a full outbound webhook contract — one channel, one signed envelope, at-least-once delivery — but the delivery side is not yet implemented, so no workspace is sending events today. Read this as the shape a receiver has to be built to honour, not as something to subscribe to. Until it runs, take events from the application itself or by polling.

- **Status:** Not yet
- **Audience:** developer
- **Last verified:** 2026-09-10
- **Canonical:** https://connectbyjbrh.com/docs/workflows/webhook-delivery/

## What exists today

What exists is the description: an AsyncAPI 3.1.0 document at `/developers/asyncapi.yaml` covering one channel, one message envelope and the signing scheme. What does not exist is a delivery engine sending against it. Publishing the contract first is deliberate — a receiver can be written and tested against a fixed shape — but a contract is not a running service and this page does not pretend otherwise.

> **Careful** Do not build a subscription flow into a product on the assumption that events arrive. Nothing registers a subscriber URL today, and no signature is being sent from production.

Inbound provider callbacks are a separate thing entirely and are not this interface. A carrier posting a call event, a mail push, a messaging provider's webhook — those are the application's own inbound surface, authenticated by the provider's signature, and their shapes belong to the provider rather than to Connect.

## The envelope, as published

| Header | Meaning |
|---|---|
| `X-Connect-Signature` | `sha256=` then the hex HMAC-SHA256 of `<timestamp>.<raw body>`, keyed with the subscription secret |
| `X-Connect-Timestamp` | Unix seconds at signing. A receiver rejects anything far from now, which is what bounds replay |
| `X-Connect-Event` | The event type, mirrored out of the body so a receiver can route without parsing |
| `X-Connect-Delivery` | The attempt identifier. It changes on every retry — so it is exactly the wrong field to de-duplicate on |

```json
{
  "id": "ev_01J8Z6QK9M",
  "type": "followup.due",
  "occurredAt": "2026-09-10T09:15:00Z",
  "workspaceId": "ws_example",
  "data": {
    "followUpId": "fu_01J8Z6QK9M",
    "channel": "phone",
    "reason": "Call back about the quotation",
    "dueAt": "2026-09-10T09:15:00Z"
  }
}
```

The six described types are `conversation.received`, `conversation.replied`, `followup.due`, `followup.completed`, `needsyou.created` and `call.ended`. The `data` object is type-specific and open: a receiver ignores fields it does not recognise rather than failing on them, because adding a field is not a breaking change and treating it as one makes every improvement an outage.

## The chain the contract describes

> **Note** The stages below document the published design. None of them is running in production today; they are here so a receiver can be built correctly rather than adjusted later.

1. Trigger — something a business cares about happens: a conversation is answered, a follow-up falls due, an item is queued for a person, a call ends.
2. Internal event — the canonical record is already written. The webhook describes it; it does not carry the record's authority.
3. Authentication and workspace resolution — the event belongs to exactly one workspace, and `workspaceId` in the envelope is always the subscriber's own.
4. Request — one event per HTTP POST to the URL the workspace registered. No batching, so a receiver never has to unpack an array.
5. Canonical record — the event id is stable across retries; the delivery id is not. De-duplication belongs on the event id.
6. Reasoning — none. A webhook is a notification, not a decision, and it carries no model output.
7. Knowledge, memory and rules — untouched. Delivery reads the event, not the workspace's knowledge.
8. Autonomy and approval — not applicable to a notification; nothing is sent to a customer and nothing is spent.
9. Action through a provider — the HTTPS POST itself, signed over the raw body with the subscription secret.
10. Result — at-least-once. A 2xx ends the attempt; anything else is retried with backoff, and a persistently failing receiver is paused, with the pause visible in the workspace.
11. Relationship, timeline and memory — none. A subscriber is not a contact.
12. Audit, usage and Needs You — the design puts a paused subscription in front of a person, because a silently paused feed is indistinguishable from a quiet business.

## What a receiver has to get right

1. Verify the signature over the exact raw bytes, before parsing the JSON.
   - Result: Parsing first and re-serialising to check changes the bytes, and the check then fails for reasons that look like a signing bug.
2. Compare in constant time, and reject a timestamp outside a few minutes.
   - Result: The first stops a timing oracle; the second bounds how long a captured delivery stays useful to somebody who replays it.
3. De-duplicate on the event id and treat the handler as idempotent.
   - Result: At-least-once means a duplicate is normal traffic, not an incident.
4. Answer quickly and do the work afterwards.
   - Result: A slow receiver is retried, which multiplies the load that made it slow.
5. Ignore unknown fields and unknown event types.
   - Result: Your receiver keeps working through additions instead of needing a release to match every one.

## What to do while this is not running

- Read the state you need on a schedule through the API, which is available today, and compare against what you last saw.
- Use the application's own queues for anything a person acts on — Needs You already collects the decisions and problems that matter.
- Build the receiver against the published description if you want to be ready; the envelope and the signing scheme are fixed, and testing against them costs nothing now.
- Track the changelog rather than this page for the moment the interface starts delivering.

The comparison between the two approaches, and when polling is the better answer even for a running webhook interface, is on [Webhooks or polling](/developers/webhooks-vs-polling/).

## Questions

### Can I subscribe to Connect webhooks today?

No. The interface is described and published; there is no subscription to register and no delivery running. Anyone telling you otherwise has read the AsyncAPI document and assumed a description implies a service.

### Why publish a description of something that does not run yet?

Because the contract is the expensive part to change. Publishing the envelope, the headers and the signing scheme early lets a receiver be written once and lets the shape be reviewed by the people who would have to consume it. The status word on this page keeps that honest.

### Is the delivery identifier good enough for de-duplication?

No — it changes on every retry, which makes each retry look like a new event. The event id is the stable one and is the only correct key. This is the single most common mistake in webhook receivers, which is why both the description and this page say it twice.

## Related

- [Outbound webhooks](https://connectbyjbrh.com/developers/webhooks-outbound/)
- [Verifying a Connect webhook signature](https://connectbyjbrh.com/developers/webhook-signatures/)
- [The AsyncAPI description](https://connectbyjbrh.com/developers/asyncapi-events/)
- [Webhooks or polling](https://connectbyjbrh.com/developers/webhooks-vs-polling/)
- [A developer calling the public API](https://connectbyjbrh.com/docs/workflows/public-api-call/)
- [Protocol reference](https://connectbyjbrh.com/docs/protocols/)

## What this page is based on

- `webapp/developers/asyncapi.yaml` — the published channel, envelope, headers and event types
- Connect capability registry (`docs-source/facts.py`) — `asyncapi` is published; no delivery capability is recorded
- Connect protocol record (`docs-source/facts.py`) — AsyncAPI 3.1.0
