# Webhooks

Webhooks push changes to you within seconds, removing most of the polling an
integration would otherwise need. Manage them yourself with the
`webhooks:manage` scope — no email to us required.

## Register an endpoint

```bash
curl -X POST https://api.wearenoe.fr/v1/webhooks \
  -H "Authorization: Bearer $NOE_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/noe",
    "events": ["reservation.created", "reservation.cancelled", "calendar.updated"],
    "description": "Production sync"
  }'
```

The response contains the **signing secret, shown once**. Store it before you
close the connection; it cannot be retrieved later, only rotated.

Your URL must be **HTTPS with a public hostname**. Literal IP addresses, private
ranges and loopback are rejected — both when you register and again at delivery
time, so a hostname that starts resolving to a private address later stops
being delivered to.

## Events

| Event | Fires when |
| --- | --- |
| `reservation.created` | A booking arrives, from any channel |
| `reservation.updated` | A booking changes |
| `reservation.cancelled` | A booking is cancelled |
| `reservation.checkout` | A stay reaches its check-out |
| `block.created` / `block.updated` / `block.deleted` | An availability block changes |
| `calendar.updated` | Prices or stay restrictions change |
| `property.updated` | Any property edit |

Deliveries inherit your grant: you will never receive an event for a property
outside your property scope, or for a workspace that has revoked you.

<Callout type="note">
`property.updated` fires on every property write, including bulk edits of
amenities, equipment and internal fields. The one deliberate exception is a CSV
bulk import, which is summarised rather than emitting an event per row —
reconcile after an import with `updated_after` on `GET /v1/properties`.
</Callout>

## Payload

```json
{
  "id": "evt_9f2b…",
  "object": "event",
  "type": "reservation.created",
  "api_version": "2026-08-01",
  "created": "2026-08-18T09:31:02Z",
  "tenant_id": "…",
  "data": { "reservation_id": "…" }
}
```

Payloads carry **ids, not full objects**. Fetch the record if you need its
current state — that way you always act on what is true now, rather than on a
snapshot that may be several retries old.

## Verify the signature

Every delivery is signed:

```
X-Noe-Signature: t=1755500000,v1=5257a869e7ecebeda32affa62cdca3fa…
```

The signed material is `<timestamp>.<raw request body>`, HMAC-SHA256 with your
signing secret.

```javascript
import crypto from 'node:crypto';

function verify(rawBody, header, secrets, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)));
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;

  // Reject anything outside the window: this is what stops a captured
  // delivery being replayed against you later.
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  return secrets.some((secret) => {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
  });
}
```

Two things matter here. Verify against the **raw body bytes**, before any JSON
parsing — re-serialising changes the bytes and the signature will not match. And
compare in constant time, as above.

## Rotating the secret

`POST /v1/webhooks/{id}/rotate-secret` issues a new secret while the previous
one stays valid for **24 hours**. Accept both during that window (the `secrets`
array above), deploy the new value, then let the old one lapse. No dropped
deliveries.

## Retries and idempotency

Respond `2xx` quickly — ideally by enqueuing the event and returning
immediately. Anything else is a failure and is retried after **1 minute, 5
minutes, 30 minutes, 2 hours and 12 hours**. After that the delivery is
exhausted, and you can replay it from
`POST /v1/webhooks/{id}/deliveries/{deliveryId}/retry`.

An endpoint that keeps failing is **disabled automatically**, and both you and
the workspace are notified. Re-enable it with `PATCH /v1/webhooks/{id}` once it
is healthy.

Delivery is **at-least-once**: if you time out after processing an event, we
cannot know that and will retry. Deduplicate on the event `id`, which is stable
across every retry.

## Catching up after downtime

`GET /v1/events` is the durable log:

```bash
curl "https://api.wearenoe.fr/v1/events?since=2026-08-18T09:00:00Z" \
  -H "Authorization: Bearer $NOE_API_KEY"
```

Use it to fill a gap without a full resync. And keep a periodic
[`updated_after` reconciliation](/filtering-and-sync) running regardless:
webhooks are the optimisation, polling remains the guarantee.

## Debugging

`GET /v1/webhooks/{id}/deliveries` shows recent attempts with the response code
and error we recorded. That is usually enough to distinguish "our request never
arrived" from "your endpoint returned 500".
