> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gridbeds.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Register an endpoint, verify a notification's Standard Webhooks signature, deduplicate and apply it, and what happens when your endpoint fails.

<Warning>
  **Preview: nothing is sent yet.** This describes the Distribution API contract (version `1.0.0`). No notification is delivered until the sandbox opens.
</Warning>

A webhook is a thin notification: it says what changed and names it, and you read the resource it names. Notifications follow [Standard Webhooks](https://www.standardwebhooks.com/), so a library for it in your language can verify them.

## Register an endpoint

`POST /webhooks` (scope `manage:webhooks`) registers an endpoint, and your channel may have up to five:

```json theme={null}
{
  "url": "https://listings.example.com/hooks/distribution",
  "events": ["property.*", "availability.updated"],
  "description": "Production listener"
}
```

* `url` is a public HTTPS address on port 443. A private, reserved, loopback or link-local target is refused (`url_not_allowed`), and redirects are never followed.
* `events` lists up to 20 event types or patterns (`*`, `property.*`, `availability.updated`), each covered by your key's scopes.
* The answer contains the endpoint's signing `secret` (`whsec_…`), **shown once**: store it now.

`POST /webhooks/{webhook_id}/test` sends a signed ping and reports the outcome. `GET /webhooks/{webhook_id}/deliveries` lists the deliveries made to an endpoint, newest first, and `GET /webhooks/{webhook_id}/deliveries/{delivery_id}` every attempt of one; deliveries are kept for 35 days.

## Verify the signature

Each notification is a `POST` with three headers:

| Header | |
| - | - |
| `webhook-id` | The event's id. The same on every retry of the same event. |
| `webhook-timestamp` | When it was signed, in Unix seconds. |
| `webhook-signature` | One or more space-separated `v1,<signature>`, where the signature is the base64 HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<body>`, keyed by the secret. |

To verify it:

1. Reject a timestamp more than **5 minutes** away from your clock, so a captured notification cannot be replayed later.
2. Compute the HMAC-SHA256 of `id.timestamp.body` over the **raw body** exactly as received, before any JSON parsing. The key is the secret's base64 part after `whsec_`, decoded.
3. Accept the notification if any `v1,` value matches, compared in constant time. While a secret is being rotated, two signatures are sent.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const TOLERANCE_SECONDS = 5 * 60;

  // headers: lower-case names, as Node gives them; rawBody: the body as received.
  export function verifyWebhook(secret, headers, rawBody) {
    const id = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    const signatures = headers["webhook-signature"];
    if (!id || !/^\d+$/.test(timestamp ?? "") || !signatures) return false;
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;

    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = createHmac("sha256", key)
      .update(`${id}.${timestamp}.${rawBody}`)
      .digest();

    return signatures.split(" ").some((entry) => {
      const [version, signature] = entry.split(",");
      if (version !== "v1" || !signature) return false;
      const given = Buffer.from(signature, "base64");
      return given.length === expected.length && timingSafeEqual(given, expected);
    });
  }
  ```

  ```php PHP theme={null}
  <?php

  const TOLERANCE_SECONDS = 5 * 60;

  // $headers: lower-case names; $rawBody: file_get_contents('php://input').
  function verifyWebhook(string $secret, array $headers, string $rawBody): bool
  {
      $id = $headers['webhook-id'] ?? '';
      $timestamp = $headers['webhook-timestamp'] ?? '';
      $signatures = $headers['webhook-signature'] ?? '';
      if ($id === '' || ! ctype_digit($timestamp) || $signatures === '') {
          return false;
      }
      if (abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
          return false;
      }

      $key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
      $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));

      foreach (explode(' ', $signatures) as $entry) {
          [$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
          if ($version === 'v1' && hash_equals($expected, $signature)) {
              return true;
          }
      }

      return false;
  }
  ```
</CodeGroup>

## Apply a notification

The body is the event:

```json theme={null}
{
  "id": "evt_00000000000ab",
  "type": "property.updated",
  "occurred_at": "2026-10-16T09:30:00Z",
  "channel": "your-channel",
  "data": { "object": "property", "id": "prp_1qnykkykp69yv8d59hxk5410m9", "version": 42 }
}
```

* Delivery is **at least once**, in no particular order. Deduplicate on `webhook-id`.
* Read the resource the event names, and apply it only when its `version` is higher than the one you hold.
* An `availability.updated` event names a property and a range of nights (`from`, `to` exclusive) and carries no version: always re-read that range.
* Answer with any `2xx` within **10 seconds**, and do the work afterwards.

## Retries and suspension

A non-`2xx` answer or a timeout is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then every 12 hours, each with ±20 % jitter, for **3 days**. After that the endpoint becomes `inactive` with `suspended_reason: failing`, and your technical contact is e-mailed. A `410 Gone` answer makes it `inactive` at once (`gone`). A redirect is never followed, and counts as not delivered.

Nothing is delivered to an inactive endpoint. `POST /webhooks/{webhook_id}/enable` enables it again once a signed ping to it succeeds. Nothing is replayed: recover what it missed from `GET /events` ([Synchronisation](/en/distribution-api/synchronisation)).

## Rotate a secret

`POST /webhooks/{webhook_id}/rotate-secret` issues a new secret, shown once. The previous one keeps signing beside it for 24 hours (`previous_secret_expires_at`), so both signatures arrive and a verifier holding either secret accepts the notification; send `"expire_immediately": true` when the old secret has leaked. Deleting an endpoint cancels its pending deliveries and destroys its secrets.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.