# Webhook Security

Each webhook has two secret families: one for publishing into the public ingest endpoint and one for verifying outbound deliveries. Hooksbase also keeps a secret-version ledger so you can rotate safely without breaking producers or consumers instantly.

## Secret types

- **Ingest secret** `whsec_...`: Used only for calls into `/v1/ingest/{publicId}`.
- **Signing secret** `sws_...`: Used only to verify the signature Hooksbase attaches to outbound delivery requests.
- **Secret versions**: Hooksbase stores lifecycle metadata for both secret families and exposes the non-secret version history through `/secret-versions`.

## Auth model

Project API keys manage webhook security surfaces. The dashboard mirrors the same controls with extra product affordances such as reveal and audit-log views.

Related Public API routes:

- `POST /v1/webhooks/{id}/rotate-ingest-secret`
- `POST /v1/webhooks/{id}/rotate-signing-secret`
- `GET /v1/webhooks/{id}/secret-versions`
- `POST /v1/webhooks/{id}/test-delivery`

## Rotation behavior

Rotation is overlap-aware:

- the new version becomes `current` immediately
- the previous current version becomes `overlapping`
- older versions become `retired`

During overlap:

- both ingest secrets are accepted for public ingest
- outbound signatures can include both the current and overlapping signing secret

Only create and rotate responses return raw secret material. List and get routes never do.

## Verifying outbound signatures

Hooksbase signs the exact raw body bytes it sends to your destination. The signing input is:

```text
{deliveryId}.{unixTimestampSeconds}.<raw body bytes>
```

The HMAC is SHA-256, base64-encoded, and emitted as `v1,<base64>`. Headers on the outbound request:

- `webhook-id` — the delivery ID (also used as the signing input)
- `webhook-timestamp` — Unix timestamp in **seconds**
- `webhook-signature` — one or more `v1,...` values joined by spaces during a signing-secret overlap window

Use the SDK helper when possible:

**Verify an outbound delivery**

```ts
import { verifyHooksbaseWebhook } from '@hooksbase/sdk'

verifyHooksbaseWebhook({
  headers: request.headers,
  rawBody: await request.arrayBuffer(),
  signingSecret: [
    process.env.HOOKSBASE_CURRENT_SIGNING_SECRET!,
    process.env.HOOKSBASE_PREVIOUS_SIGNING_SECRET!,
  ],
})
```

The helper enforces a 5-minute timestamp tolerance by default (override with `toleranceSeconds`) and checks every `v1,...` candidate signature against every provided secret, so rotation overlap is handled automatically. It throws if no signature matches or the timestamp is too far out of tolerance.

## Dashboard-only views

Some security views exist only in the [dashboard](/docs/dashboard.md) and are not part of the Public API:

- reveal the current signing secret for a webhook
- inspect the secret audit log
- rotate secrets from the UI
- run a synchronous test delivery without creating delivery history

Test delivery is also exposed on the Public API at `POST /v1/webhooks/{id}/test-delivery`. It is intentionally narrower than public ingest:

- it evaluates saved route rules against the probe payload, empty request headers, and derived provider metadata, falling back to the default destination when no rule matches
- it applies the saved transform, selected destination config, timeout, and usable signing secrets
- it does not create deliveries, emit analytics events, or raise operator incidents
- archived webhooks return `409`, but paused webhooks are still testable
- repeated probes are rate-limited so the endpoint is not a general-purpose synchronous traffic path

## Common mistakes

- Verifying against parsed JSON instead of raw bytes.
- Forgetting that both signatures can be valid during an overlap window.
- Expecting list or get routes to ever return the raw secret again after creation.
- Treating test delivery as a full public-ingest routing test. Use public ingest when you need producer auth, provider verification, real request headers, and route evaluation end to end.
