# Signed webhook contract

Machine-readable event contract: `https://cleanedweb.com/asyncapi.json`.

Signed webhooks deliver finalized saved-search events to a customer HTTPS endpoint.
Configuration is currently managed by a signed-in workspace owner or administrator
in the CleanedWeb portal. Webhook management endpoints are not part of the public
property OpenAPI contract.

## Event mapping

| Saved-search selection | Public event type |
| --- | --- |
| `new_match` | `property.match.created` |
| `price_changed` | `property.price.changed` |
| `status_changed` | `property.status.changed` |

Live events use `schema_version: property-event-v1`. Validate against:

- `https://cleanedweb.com/schemas/property-event-v1.json`
- `https://cleanedweb.com/examples/webhook-event.json`

The stable event `id` is the deduplication key. Delivery is at least once and order
is not guaranteed across properties or retry attempts. Commit the event ID and
business change atomically before acknowledging the request.

A connection test uses `type: webhook.test`, contains 0 records and 0 Units, and is
not a `property-event-v1` live event.

## Signature verification

CleanedWeb uses the Standard Webhooks HMAC-SHA256 wire format. Preserve the exact raw
request bytes; parsing and re-serializing JSON changes the signed content.

Read these headers:

- `webhook-id`
- `webhook-timestamp`
- `webhook-signature`

For each space-separated `v1,BASE64_SIGNATURE` value, compute HMAC-SHA256 over:

```text
webhook-id + "." + webhook-timestamp + "." + raw_request_body
```

Decode the base64 material after the `whsec_` prefix to obtain the signing key.
Compare signatures in constant time and accept the event when any current signature
matches. During secret rotation, old and new signatures can both be present for 24
hours. Reject timestamps outside the receiver's explicit clock-skew policy.

Return a `2xx` response only after durable acceptance. Redirects and other status
codes are failures.

## Retries and endpoint state

Transport failures and non-`2xx` responses retry on this schedule:

1. 5 seconds
2. 5 minutes
3. 30 minutes
4. 2 hours
5. 5 hours
6. 10 hours
7. 14 hours
8. 20 hours
9. 24 hours

There are at most 10 attempts in a delivery cycle. A valid `Retry-After` is honored
between 1 second and 24 hours. HTTP `410` is terminal. Exhausted delivery or a
terminal endpoint moves the connection to `needs_attention`.

After fixing and testing the endpoint, a workspace administrator can replay failed
webhook deliveries from the previous 7 days. Replays preserve the event ID, so the
same deduplication rule applies.

## Freshness and system-of-record boundary

Webhook delivery begins after an observer event is finalized. It is not evidence of
source-real-time acquisition. Fetch the canonical property endpoint when current
state matters; a failed notification does not imply a failed property update.

The event envelope can contain `data.record_count` and `data.units`. Do not treat
those values or an HTTP status as the billing ledger. Runtime `unit_costs`, workspace
receipts, and the Unit ledger are authoritative when available.

## Receiver checklist

1. Accept only HTTPS requests on the intended route.
2. Limit request size before buffering the raw body.
3. Verify timestamp and signature before processing data.
4. Validate `property-event-v1` and reject unknown versions.
5. Deduplicate by event `id` in durable storage.
6. Commit before returning `2xx`.
7. Monitor repeated failures and portal `needs_attention` state.
