Handle webhooks reliably
Verify, deduplicate, process, and reconcile Watcher events reliably.
In short
2 calls, in the order this page makes themFor people and agentsThe same list heads this page's Markdown:/watcher/webhooks-and-delivery.md
Watcher delivers every event as a signed JSON POST. Delivery is at-least-once, so a production receiver must verify the sender, accept duplicates safely, acknowledge quickly, and reconcile from the event log.
Receiver contract
Each request contains:
content-type: application/jsonoex-signature: t=<unix_seconds>,v1=<lowercase_hex_hmac>- A JSON body with a unique
id,type,created_at, and type-specificdata
Use HTTPS for your production endpoint. Keep the whsec_ signing secret in a secret manager and make it available only to the receiver.
Do not perform expensive business work in the request handler. The handler's job is to verify the request and persist it durably for asynchronous processing.
1. Read the raw request body
Read and preserve the exact request bytes before parsing JSON. Frameworks that automatically parse the body may require a raw-body or request-buffer API.
Verify the bytes Watcher sent. Re-serializing parsed JSON can change whitespace or key order and produce a different signature.
Also read the oex-signature header. Reject the request if the body or header is missing.
2. Verify the signature
The signed message is the timestamp, a period, and the raw body:
<unix_seconds>.<raw_request_body>Verify in this order:
parts = parse_header(oex_signature)
timestamp = integer(parts["t"])
provided = parts["v1"]
reject if abs(current_unix_seconds - timestamp) > 300
message = utf8(timestamp + ".") + raw_body
expected = lowercase_hex(hmac_sha256(webhook_secret, message))
reject unless constant_time_equal(expected, provided)The 300-second tolerance limits replay attacks. Keep your server clock synchronized and compare the expected and provided signatures with your language's constant-time function.
Return a 4xx response for a malformed header, stale timestamp, or invalid signature. Parse the JSON only after verification succeeds.
3. Deduplicate the event
Watcher may deliver the same event more than once. This can happen when your endpoint times out after completing work or when delivery succeeds but Watcher cannot save the acknowledgement.
Use the top-level id as an idempotency key:
- Start a database transaction.
- Insert the event ID into a table with a unique constraint.
- Enqueue or store the event payload in the same transaction.
- If the ID already exists, treat the delivery as accepted and return 2xx.
- If you cannot persist the event, return a non-2xx response so Watcher retries.
Do not deduplicate by watch ID. One watch can produce different events over its lifetime.
4. Acknowledge before doing slow work
Commit the event to durable storage, then return any 2xx status within 10 seconds. Send notifications, call other services, and update application state in an asynchronous worker.
Watcher retries after a timeout or non-2xx response using this schedule:
- 1 minute
- 5 minutes
- 30 minutes
- 2 hours
- 12 hours
- 24 hours
After the retry attempts are exhausted, the event is marked failed in the event log. A 2xx response means you accepted responsibility for the event, so return it only after durable persistence.
5. Process by event type
Every event includes data.user_id. Use it to route the outcome to the end user who owns the watch.
| Event type | Important data | Recommended action |
|---|---|---|
travel.hotel.watch.triggered | data.watch and advisory data.current price details | Notify the user and link to a fresh booking search |
travel.hotel.trip_watch.triggered | data.watch and up to 3 entries in data.matches, cheapest first | Show the qualifying hotels and fetch fresh offers when the user chooses one |
travel.hotel.watch.purchased | data.watch, data.booking, and data.mandate_id | Confirm the booking to the user using the binding data.booking.total_amount |
travel.hotel.watch.purchase_failed | data.watch, data.reason, and optional advisory data.current | Explain the failure and offer the manual booking flow |
A trip match contains hotel_id, name, guest_rating, star_rating, total, currency, room_type, board, and refundable.
An armed auto-purchase watch sends a purchase outcome instead of travel.hotel.watch.triggered. If a fresh price moves back above the cap before purchase, the watch returns to active and sends no outcome.
Recover missed events
The event log is the recovery path when your endpoint is unavailable beyond the retry window:
curl "https://api.oex.sh/v1/watcher/events?after=evt_01hq2example&limit=50" \
-H "x-api-key: $OEX_API_KEY"The response contains the organisation's events from oldest to newest. This example shows the fields used during reconciliation:
{
"data": [
{
"id": "evt_01hq3example",
"type": "travel.hotel.watch.triggered",
"status": "delivered",
"created_at": "2026-08-28T10:20:00+00:00",
"payload": {
"id": "evt_01hq3example",
"type": "travel.hotel.watch.triggered",
"created_at": "2026-08-28T10:20:00+00:00",
"data": {
"watch": {
"id": "wtc_01hq3example",
"status": "triggered"
},
"user_id": "usr_maya",
"current": {
"total": 438.7,
"currency": "USD",
"room_type": "Deluxe King",
"board": "Room only",
"refundable": true
}
}
}
}
]
}Each entry has an event status of pending, delivered, or failed. The full webhook body is in payload; the example above abbreviates data.watch for readability.
For reliable reconciliation:
- Omit
afteron the first request. - Process each
payloadthrough the same idempotent path as a webhook. - Save the last processed event ID.
- Pass that ID as
afteron the next request. - Continue until the response contains fewer entries than your requested limit.
limit accepts 1–200 and defaults to 50. A supplied after ID must exist in your organisation's event log or the API returns 404.
Rotate the signing secret
Registering the webhook again replaces the organisation's endpoint and rotates the secret immediately:
curl -X POST "https://api.oex.sh/v1/watcher/webhooks" \
-H "content-type: application/json" \
-H "x-api-key: $OEX_API_KEY" \
-d '{
"url": "https://your.service/hooks/oex"
}'The response contains the new secret once. Store it immediately.
For a controlled rotation:
- Keep the old secret active in your verifier.
- Re-register the endpoint and capture the new secret.
- Add the new secret to the verifier as quickly as possible while retaining the old one.
- Try verification with both secrets during the transition.
- Remove the old secret after the new configuration is live and any in-flight request has finished.
Because rotation takes effect before you can deploy the returned secret, a delivery during the short transition may fail and retry. Automate the secret update when possible. If you lose the new secret, register the endpoint again to rotate it.
Production checklist
Before accepting live events, verify that your receiver:
- Uses HTTPS.
- Reads the raw request body.
- Rejects malformed, stale, and incorrectly signed requests.
- Uses a constant-time signature comparison.
- Stores event IDs under a unique constraint.
- Persists or enqueues events before returning 2xx.
- Responds within 10 seconds.
- Processes every subscribed event type.
- Reconciles the event log from a saved cursor.
- Can accept both secrets during a planned rotation.
Start with the complete tutorial in Create your first price watch, or review the additional consent controls in Set up auto-purchase.