# Webhook setup and operations

Webhooks are optional. Pull sync is the canonical, complete, and recoverable
source of truth. Start pull-only unless lower latency materially improves the
integration.

## Register an endpoint

From the integration detail page, choose the environment, payload mode, one or
more event types, and a public HTTPS URL. Up to three endpoints are supported
per integration. Production endpoint configuration requires an approved or
active integration and a fresh passkey confirmation.

The endpoint must not redirect and must resolve only to public addresses. BOT
pins and rechecks DNS and rejects loopback, link-local, private, reserved,
documentation, multicast, and other non-public destinations.

## Ownership challenge

Before an endpoint becomes active, BOT resolves every A/AAAA answer, rejects the
whole hostname if any answer is non-public, selects one validated address, pins
that address for the request, and still verifies TLS for the registered
hostname. Registration accepts only a public HTTPS hostname on port 443, with
no URL credentials or fragment, and does not follow redirects.

BOT then sends this unsigned setup request:

```http
POST /your/registered/path HTTP/1.1
Content-Type: application/json
Accept: application/json
User-Agent: BoringOrderTracker-Webhook-Verification/1.0

{"type":"bot.webhook.verification","challenge":"<random-value>"}
```

Return a 2xx JSON response containing the exact challenge string. The minimal
valid response is:

```json
{"challenge":"<the-exact-random-value>"}
```

Do not log or transform the challenge. The current verification request has a
five-second connection timeout, a ten-second total timeout, a 16 KiB response
header limit and an 8 KiB response body limit. These setup limits are not a
latency budget for normal delivery. The ownership challenge is not a regular
event and does not carry the HTTP Message Signature headers described below.
Only a successful challenge activates the endpoint.

## Event types

- `connection.created`
- `resource.granted`
- `resource.updated`
- `resource.revoked`
- `connection.revoked`
- `statistics.updated`
- `integration.suspended`

Revocation messages are minimal and never contain a rich snapshot/history.
Pending rich-data deliveries are discarded after revocation.

## Event envelope and payload modes

Every signed event uses a CloudEvents-shaped JSON envelope:

```json
{
  "specversion": "1.0",
  "id": "evt_opaque",
  "type": "resource.updated",
  "source": "https://api.integration.boringordertracker.com",
  "time": "2026-09-01T12:34:56.123456Z",
  "environment": "sandbox",
  "data": {
    "connection_id": "con_integration_specific",
    "resource": {"id": "ord_integration_specific", "type": "order"}
  }
}
```

`notification` mode sends only references needed to trigger a pull:

- `connection.created`: `connection_id`;
- `resource.granted` / `resource.updated`: `connection_id` and `resource`;
- `statistics.updated`: `generation_id`;
- `integration.suspended`: an empty `data` object.

In `full_payload` mode, only `resource.granted` and `resource.updated` add the
current `snapshot` plus `history_complete`. History pages are never embedded;
retrieve them from the API. The payload is generated immediately before each
attempt after rechecking the active endpoint, integration, connection, grant,
resource alias, environment gate and published statistics generation. A retry
can therefore carry a newer current snapshot under the same event ID; consumers
must still treat the event ID as idempotent and use pull/revisions as truth.

`resource.revoked` contains `connection_id`, `resource`, `revocation_id` and
`deletion_deadline_at`. `connection.revoked` contains `connection_id`,
`revocation_id` and `deletion_deadline_at`. Neither contains `snapshot` or
history. Delete every retained individual/derived copy, then acknowledge the
revocation through `POST /v1/revocations/{id}/ack`; do not acknowledge receipt
as if it proved deletion.

## Verify before parsing

BOT signs each delivery using Ed25519 HTTP Message Signatures and sends a
`Content-Digest`, event ID, and event timestamp. Public verification keys are at
`https://integration.boringordertracker.com/.well-known/bot-integration-webhook-jwks.json`.

The current signed request has these headers (HTTP field names are
case-insensitive):

```http
Content-Type: application/json
Content-Digest: sha-256=:<base64-SHA-256-of-the-exact-body-bytes>:
X-BOT-Event-ID: <event-id>
X-BOT-Event-Timestamp: <RFC3339-UTC-event-time>
Signature-Input: bot=("@method" "@target-uri" "content-digest" "x-bot-event-id" "x-bot-event-timestamp");created=<unix-seconds>;keyid="<kid>";alg="ed25519"
Signature: bot=:<base64-Ed25519-signature>:
User-Agent: BoringOrderTracker-Webhook/1.0
```

Use an RFC 9421 implementation where available. For implementations that expose
the signature base, the `bot` label covers exactly these components in this
order:

```text
"@method": POST
"@target-uri": https://the-exact-registered.example/path?query
"content-digest": sha-256=:<base64-digest>:
"x-bot-event-id": <event-id>
"x-bot-event-timestamp": <event-time>
"@signature-params": ("@method" "@target-uri" "content-digest" "x-bot-event-id" "x-bot-event-timestamp");created=<unix-seconds>;keyid="<kid>";alg="ed25519"
```

`@target-uri` is the exact public URL registered in the portal, not an internal
reverse-proxy URL. Preserve or reconstruct that external URL exactly. Do not
normalize its path, query encoding, default port or trailing slash before
verification. Select the JWK whose `kid` matches `keyid`; production keys have
`kty=OKP`, `crv=Ed25519`, `use=sig`, and `alg=EdDSA`.

For every request:

1. accept only `POST` over HTTPS on the exact registered route and read the raw
   body bytes with a strict local size limit;
2. parse `Signature-Input` structurally—do not concatenate untrusted parameters
   into a verifier—and select its `keyid` from a locally cached current JWKS;
3. calculate SHA-256 over the exact raw body bytes and verify
   `Content-Digest` with a constant-time comparison;
4. verify the Ed25519 signature over the exact component values and order above
   before parsing or using the JSON body;
5. apply a bounded, documented receiver clock-skew/freshness policy to the
   signed `created` value, then use the signed event ID as the replay key;
6. parse JSON and require envelope `id`/`time` to equal the signed event-ID and
   event-timestamp headers, and require the expected source/environment/type;
7. durably enqueue or transactionally commit the event ID before returning a
   2xx. If that ID was already committed, return a 2xx without applying it
   again.

BOT does not currently publish a numeric receiver clock-skew tolerance. Do not
invent one or treat an undocumented number as a BOT guarantee. The signature's
`created` value is generated for each delivery attempt and is the appropriate
freshness input. The event timestamp represents when the event occurred and can
legitimately be older after retries; use it for domain ordering, not as the only
delivery-freshness test.

An unknown key ID should trigger a bounded JWKS refresh, not signature bypass.
Never trust an event merely because it arrived over HTTPS.

## Delivery and recovery

Any 2xx response within the response-size policy completes the attempt. BOT
retries transport failures, oversized responses, HTTP `408`, `409`, `425`,
`429`, and `5xx`; other HTTP statuses are terminal for that delivery. Retries
use exponential backoff from 30 seconds with deterministic jitter, cap an
individual delay at six hours, and stop at the earlier of the event's stored
`expires_at` boundary and seven days after it occurred. The current delivery
transport defaults to a five-second connection timeout, 15-second total timeout
and 64 KiB maximum response; these worker settings can be tightened, so handlers
should respond quickly with a small/empty body.

After the retry window, a failed delivery moves to the dead-letter queue. The
portal exposes dead letters and a manual replay action. Return `429` only for
temporary load shedding, not for signature failures or permanently invalid
payloads.

Your handler must tolerate duplicate delivery and reordering. Use the API's
monotonic revisions and run pull sync after downtime or ambiguity. A webhook is
a prompt to synchronize, not proof that no other change occurred.

For `notification`, enqueue a pull and return. For `full_payload`, it is safe to
use the verified current snapshot for latency, but still reconcile through pull.
Never advance the API cursor merely because a webhook arrived.

## Current rollout limitation

The contract, portal registration/verification flow, signing implementation,
outbox, retry, and dead-letter model are implemented. Scheduled public webhook,
statistics, and retention workers are not yet enabled for production during the
current sandbox rollout. Do not make webhook delivery a launch dependency until
BOT explicitly enables it for the integration/environment.
