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

# Legacy webhook format

> The format of webhooks created before V1: body, headers and signature, and how to move to V1.

<Warning>
  This format is frozen. It only applies to webhooks created before V1 (their
  card shows **Legacy format** in Settings → Integrations → Webhooks). Every new
  webhook, and every endpoint created through the API, uses
  [V1](/api-reference/webhooks). Legacy webhooks receive reservation events
  only: customer events have no legacy body.
</Warning>

Delivery, retries, the delivery journal and the list of publishing flows are
the same for both formats: see the [webhook reference](/api-reference/webhooks).

## Move to V1

1. Update the receiver to verify `EatNow-Signature` (see
   [Verify the signature](/api-reference/webhooks#verify-the-signature)) and to
   read the V1 body: `type` instead of `event`, `data.object` (the partner API
   reservation: `party_size`, `start_at`…) instead of `data.reservation`,
   `data.previous_attributes` (changed fields only) instead of
   `previous_data`. The secret does not change.
2. In **Settings → Integrations → Webhooks**, click **Upgrade to V1** on the
   webhook. This cannot be undone.
3. Deliveries already in the journal keep their legacy body and signature,
   resends included; everything published afterwards is V1.

## Headers

| Header | Value |
| - | - |
| `Content-Type` | `application/json` |
| `User-Agent` | `EatNow-Webhooks/1.0` |
| `X-EatNow-Event` | Same as body `event`. |
| `X-EatNow-Delivery` | Same as body `id`. **The deduplication key**: identical on every retry and manual resend. |
| `X-EatNow-Timestamp` | Same as body `timestamp`, an ISO 8601 UTC instant (the event time, not the attempt time). |
| `X-EatNow-Signature` | `sha256=` followed by the HMAC-SHA256 hexadecimal digest. |
| `X-EatNow-Delivery-Log-Id` | Our delivery journal entry for this destination, as shown in the dashboard. For support; do not dedupe on it. |
| `X-EatNow-Attempt` | Attempt number of that journal entry, starting at `1`. |
| `X-EatNow-Test` | `true` only on the "Test" button of the settings page. The payload is a fake reservation: acknowledge it, do not act on it. |

Custom headers from the configuration are applied first; the headers above are
applied after them and always win (names compared case-insensitively), so a
custom header can never replace them. Any custom header whose name starts with
`X-EatNow-` is dropped. The signature covers the **raw body
only**, not a concatenation of the timestamp header and body.

## Payload

Illustrative creation event; IDs and timestamps are examples:

```json theme={null}
{
  "id": "d68fef44-5121-4a97-9d61-a3b4631d465f",
  "event": "RESERVATION_CREATED",
  "timestamp": "2030-05-20T15:00:00.000Z",
  "restaurant_id": "restaurant_example",
  "data": {
    "reservation": {
      "id": "reservation_example",
      "restaurantId": "restaurant_example",
      "status": "CONFIRMED",
      "group_size": 2,
      "reservation_date": "2030-05-20T00:00:00.000Z",
      "reservation_time": "19:30",
      "created_at": "2030-05-20T15:00:00.000Z",
      "updated_at": "2030-05-20T15:00:00.000Z",
      "source": "WEBSITE",
      "tags": [],
      "metadata": null,
      "customer": {
        "id": "customer_example",
        "external_id": null,
        "name": "Example Guest",
        "email": "guest@example.com",
        "lang": "EN"
      },
      "tables": [
        {
          "id": "table_example",
          "name": "T1"
        }
      ],
      "room": {
        "id": "room_example",
        "name": {
          "EN": "Main room",
          "FR": "Salle principale",
          "DE": "",
          "IT": "",
          "ES": "",
          "PT": ""
        }
      }
    }
  }
}
```

| Field | Contract |
| - | - |
| `id` | Event delivery identifier; reused across retries and destinations for that publication. |
| `event` | One of the three event names above. |
| `timestamp` | Event construction time, not a new timestamp for each attempt. |
| `restaurant_id` | Restaurant publishing the event. |
| `data.reservation` | Reservation snapshot for the event. |
| `previous_data.reservation` | Optional earlier snapshot; not present on every update. |

The reservation always includes `id`, `restaurantId`, `status`, `group_size`,
`reservation_date`, `reservation_time`, `created_at`, `updated_at`, `source`,
`tags` and `metadata` (which can be `null`). This is **not** the Partner API
reservation format: it uses `group_size` and separate date/time fields, not
`party_size` and `start_at`.

`reservation_date` carries the restaurant's service-day label, normally
serialized as an ISO midnight string. Preserve its `YYYY-MM-DD` part; do not
convert that UTC midnight to another timezone to determine the day.
`reservation_time` is a local `HH:mm` value. Do not treat the pair as a UTC
instant. `created_at`, `updated_at` and the event `timestamp` are UTC instants.

Optional content depends on the reservation:

* `customer`: ID, nullable `external_id`, name and available email, phone and
  language.
* `tables`: objects with `id` and `name`; no nested room.
* `room`: `id` and multilingual `name` object, not a string.
* `waiter`: `id` and `name`.
* `payments`: `id`, `amount`, `currency`, `status`, `provider`, optional
  `provider_id` and `created_at`.
* `custom_message`, `allergies` and `total_amount_paid`: omitted when empty or
  zero by the current transformer. Omission does not mean “unchanged”.

**Amounts are in minor units**: `total_amount_paid` and `payments[].amount`
are integers in cents (`5000` = 50.00 in the payment's currency). Divide by
100 to display them.

`previous_data` accompanies most updates. When the change comes from a staff
action, a partner call or an automation, it is the full reservation before the
change. When it comes from a register (NowOS), it carries the reservation's own
fields before the change (status, date, time, covers…) with the current
relations (customer, tables, room).

* `shift`: may appear in the test sample; ordinary reservation events currently
  omit it. Do not require it.

Webhooks may contain personal and payment-related data regardless of API key
scopes. Restrict access to bodies and secrets; avoid logging complete payloads.

## Verify the signature

Compute HMAC-SHA256 over the **exact bytes received**, using the webhook secret.
Reject missing or malformed signatures, then compare digests in constant time.
Only parse the JSON after verification. Parsing and reserializing JSON changes
whitespace or field order and can invalidate a legitimate signature.

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

    export function verifySignature(rawBody, signature, secret) {
      if (
        typeof signature !== "string" ||
        signature.length !== 71 ||
        !/^sha256=[a-f0-9]{64}$/.test(signature)
      ) {
        return false;
      }
      const expected = createHmac("sha256", secret).update(rawBody).digest();
      const received = Buffer.from(signature.slice(7), "hex");
      return timingSafeEqual(expected, received);
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import hashlib
    import hmac
    import re


    def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
        if not isinstance(signature, str) or not re.fullmatch(r"sha256=[a-f0-9]{64}", signature):
            return False
        expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, signature[7:])
    ```
  </Tab>

  <Tab title="PHP">
    ```php theme={null}
    <?php
    function verifySignature(string $rawBody, ?string $signature, string $secret): bool {
        if ($signature === null || preg_match('/^sha256=[a-f0-9]{64}$/D', $signature) !== 1) {
            return false;
        }
        return hash_equals(hash_hmac('sha256', $rawBody, $secret), substr($signature, 7));
    }
    ```
  </Tab>
</Tabs>

### Express receiver example

Save the Node.js verifier as `verify-signature.js`. Register the raw-body
webhook route **before** any `express.json()` middleware. This example only
validates and logs event identifiers; replace that demonstration handling with
your processing before acknowledging a production event. Load the real secret
from secure server-side storage rather than committing it.

```javascript theme={null}
import express from "express";
import { verifySignature } from "./verify-signature.js";

const app = express();
const secret = "REPLACE_WITH_YOUR_WEBHOOK_SECRET";

app.post(
  "/eatnow/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    if (!Buffer.isBuffer(req.body)) return res.sendStatus(400);
    if (!verifySignature(req.body, req.get("X-EatNow-Signature"), secret)) {
      return res.sendStatus(401);
    }
    let payload;
    try {
      payload = JSON.parse(req.body.toString("utf8"));
    } catch (error) {
      console.error("Invalid webhook JSON", error.message);
      return res.sendStatus(400);
    }
    console.info({ event: payload.event, id: payload.id });
    return res.sendStatus(204);
  },
);

app.use(express.json());
app.listen(3000);
```
