Docs
Get an API key

Handle webhooks reliably

Verify, deduplicate, process, and reconcile Watcher events reliably.

In short

2 calls, in the order this page makes themFor people and agents
  1. 1GET/v1/watcher/eventsEvent log
  2. 2POST/v1/watcher/webhooksRegister the webhook endpoint

The 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/json
  • oex-signature: t=<unix_seconds>,v1=<lowercase_hex_hmac>
  • A JSON body with a unique id, type, created_at, and type-specific data

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:

  1. Start a database transaction.
  2. Insert the event ID into a table with a unique constraint.
  3. Enqueue or store the event payload in the same transaction.
  4. If the ID already exists, treat the delivery as accepted and return 2xx.
  5. 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. 1 minute
  2. 5 minutes
  3. 30 minutes
  4. 2 hours
  5. 12 hours
  6. 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 typeImportant dataRecommended action
travel.hotel.watch.triggereddata.watch and advisory data.current price detailsNotify the user and link to a fresh booking search
travel.hotel.trip_watch.triggereddata.watch and up to 3 entries in data.matches, cheapest firstShow the qualifying hotels and fetch fresh offers when the user chooses one
travel.hotel.watch.purchaseddata.watch, data.booking, and data.mandate_idConfirm the booking to the user using the binding data.booking.total_amount
travel.hotel.watch.purchase_faileddata.watch, data.reason, and optional advisory data.currentExplain 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:

Request
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:

Response
{
  "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:

  1. Omit after on the first request.
  2. Process each payload through the same idempotent path as a webhook.
  3. Save the last processed event ID.
  4. Pass that ID as after on the next request.
  5. 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:

Request
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:

  1. Keep the old secret active in your verifier.
  2. Re-register the endpoint and capture the new secret.
  3. Add the new secret to the verifier as quickly as possible while retaining the old one.
  4. Try verification with both secrets during the transition.
  5. 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.

On this page