# Alex Reservations — Bot API

REST API designed for **conversational AI assistants** (Instagram DM, WhatsApp, Telegram, Messenger…). The bot can check real-time availability, create bookings honouring the restaurant's true capacity, look up bookings by phone, modify or cancel them — all in a single round-trip per operation.

**Base URL:** `https://your-domain.com/wp-json/alexr-bot/v1`

This API is **separate** from the platform integration API at `/wp-json/alexr/v1`:

| | Platform API (`alexr/v1`) | Bot API (`alexr-bot/v1`) |
|---|---|---|
| Use case | External platforms (TheFork, OpenTable, Zapier) syncing existing reservations | Conversational bots creating reservations from scratch |
| Real availability check | No | **Yes** |
| Bound to a widget | No | **Yes** (each key has its own `widget_id`) |
| Notifications by default | OFF | **ON** |
| Modify endpoint | No | Yes |
| Module | Included | Requires the API Bot module |
| Key storage | `api_keys` table | `api_keys_bot` table (independent) |
| Log storage | `api_logs` table | `api_logs_bot` table (independent) |

---

## Authentication

All requests must include a valid bot API key. Two methods are supported:

```
X-API-Key: your-api-key-here
```

or

```
Authorization: Bearer your-api-key-here
```

Each bot API key is bound to **one restaurant** and **one widget** (which determines the available services, party-size limits, online-booking rules, etc.). The bot does not need to send `widget_id` on every request — it is resolved from the key.

### Authentication errors

| HTTP | Code | Message |
|------|------|---------|
| 401 | `MISSING_API_KEY` | No API key provided. |
| 401 | `INVALID_API_KEY` | Key not found or inactive. |

---

## Response envelope

All responses follow a consistent JSON envelope.

**Success:**

```json
{ "success": true, "data": { ... } }
```

**Error:**

```json
{ "success": false, "error": { "code": "SLOT_UNAVAILABLE", "message": "...", "details": { ... } } }
```

The `code` field is machine-readable; the `message` is human-readable. `details` is optional and may include validation errors, alternative dates, etc.

### Error codes

| Code | Typical HTTP | Meaning |
|---|---|---|
| `MISSING_API_KEY` | 401 | No API key header. |
| `INVALID_API_KEY` | 401 | Key not found or deactivated. |
| `RESTAURANT_NOT_FOUND` | 404 | Restaurant inactive or missing. |
| `WIDGET_NOT_FOUND` | 400 / 404 | Widget not configured or doesn't belong to the restaurant. |
| `SERVICE_NOT_FOUND` | 404 | Shift/Event not found or not part of the widget. |
| `BOOKING_NOT_FOUND` | 404 | UUID does not match any booking for this restaurant. |
| `VALIDATION_FAILED` | 400 | Missing or invalid fields. `details` lists them. |
| `INVALID_DATE` | 400 | Date not in `YYYY-MM-DD` format or unreal date. |
| `INVALID_TIME` | 400 | Time not in `HH:MM` 24h format. |
| `DATE_CLOSED` | 409 | Restaurant fully closed on that date. |
| `SLOT_CLOSED` | 409 | Service has the slot blocked. |
| `SLOT_UNAVAILABLE` | 409 | No real availability for the requested combination. Returns `alternative_dates`. |
| `BOOKING_NOT_MODIFIABLE` | 409 | Booking is cancelled, finished, paid, denied or no-show. |
| `INVALID_TABLE` | 400 | `table_ids` contains tables from another restaurant. |
| `AREA_NOT_FOUND` | 404 | `area` does not match any area of this restaurant. |
| `AREA_AMBIGUOUS` | 400 | Several areas share that name — use the area id. |
| `AREA_NOT_SUPPORTED` | 400 | The service does not let guests choose an area. |
| `AREA_NOT_BOOKABLE` | 409 | The area is not open to online bookings. |
| `AREA_FULL` | 409 | Not enough free seats (or no free table) in that area. Returns the area's `free_seats`. |

---

## Endpoints

### 1. GET `/restaurant` — bot context

Returns metadata useful for priming the bot's prompt: restaurant name, timezone, language, services offered through the widget, party-size limits, reservation policy and upcoming closed dates.

```bash
curl https://your-domain.com/wp-json/alexr-bot/v1/restaurant \
  -H "X-API-Key: ...."
```

**Response (200):**

```json
{
  "success": true,
  "data": {
    "restaurant": {
      "id": 1,
      "name": "Raffaella Cucina Napoletana",
      "timezone": "America/Santiago",
      "language": "es",
      "phone": "+56...",
      "address": "...",
      "reservation_policy": "Free cancellation up to 2h before..."
    },
    "widget": {
      "id": 42,
      "name": "Instagram Bot Widget",
      "guests_min": 1,
      "guests_max": 12
    },
    "services": [
      {
        "id": 101,
        "name": "Lunch",
        "type": "shift",
        "public_notes": null,
        "min_guests": 1,
        "max_guests": 8,
        "availability_type": "volume_total"
      }
    ],
    "closed_dates": ["2026-06-15", "2026-06-22"]
  }
}
```

---

### 2. GET `/availability` — slots for a date

Real-time availability for a single date, taking into account current bookings, table assignments and the service rules.

| Param | Required | Description |
|-------|----------|-------------|
| `date` | yes | `YYYY-MM-DD` |
| `party_size` | yes | integer ≥ 1 |
| `service_id` | no | numeric ID, or `all` (default) |
| `widget_id` | no | override the API key's widget (rarely needed) |
| `area` | no | area id **or** name. Only returns slots where the party fits in that area. |

```bash
curl "https://your-domain.com/wp-json/alexr-bot/v1/availability?date=2026-06-15&party_size=4" \
  -H "X-API-Key: ...."
```

**Success (200) — slots found:**

```json
{
  "success": true,
  "data": {
    "date": "2026-06-15",
    "party_size": 4,
    "available": true,
    "slots": [
      { "time": "13:00", "time_seconds": 46800, "service_id": 101, "service_name": "Lunch", "service_type": "shift", "duration_minutes": 90 },
      { "time": "13:30", "time_seconds": 48600, "service_id": 101, "service_name": "Lunch", "service_type": "shift", "duration_minutes": 90 },
      { "time": "20:00", "time_seconds": 72000, "service_id": 102, "service_name": "Dinner", "service_type": "shift", "duration_minutes": 90 }
    ]
  }
}
```

#### Areas

When the service shares its capacity between areas (availability type *volume total* with **"The user can select Area"**), every slot also carries the free seats per area — the same numbers the web widget shows:

```json
{
  "time": "20:00", "time_seconds": 72000,
  "service_id": 102, "service_name": "Dinner", "service_type": "shift",
  "duration_minutes": 90,
  "areas": [
    { "area_id": 6,  "area_name": "Terrace", "max": 36, "occupied": 30, "free": 6 },
    { "area_id": 13, "area_name": "Cellar",  "max": 40, "occupied": 40, "free": 0 }
  ]
}
```

This lets the bot answer *"at 20:00 we still have room on the Terrace, but the Cellar is full"* without a second request.

Passing `area` narrows the result instead:

```bash
curl "https://your-domain.com/wp-json/alexr-bot/v1/availability?date=2026-06-15&party_size=4&area=Terrace" \
  -H "X-API-Key: ...."
```

* Only slots where `party_size <= free` in that area are returned, and each slot's `areas` holds just that area.
* The resolved area is echoed back at the top level as `"area": { "id": 6, "name": "Terrace" }`.
* On a **table based** service the area does not hold seats of its own, so no `areas` breakdown is returned; `area` then filters the slots down to those with a free table in that area.
* A service that does not allow choosing an area contributes no slots when `area` is used.
* An unknown area responds `404 AREA_NOT_FOUND`.

**Success (200) — no slots, with alternatives:**

```json
{
  "success": true,
  "data": {
    "date": "2026-06-15",
    "party_size": 4,
    "available": false,
    "slots": [],
    "alternative_dates": [
      { "date": "2026-06-14", "slots_count": 8 },
      { "date": "2026-06-13", "slots_count": 12 },
      { "date": "2026-06-16", "slots_count": 6 },
      { "date": "2026-06-17", "slots_count": 9 }
    ]
  }
}
```

**Success (200) — restaurant closed that day:**

```json
{
  "success": true,
  "data": {
    "date": "2026-06-15",
    "party_size": 4,
    "available": false,
    "reason": "DATE_CLOSED",
    "slots": [],
    "alternative_dates": [ ... ]
  }
}
```

---

### 3. GET `/availability/month` — days with availability in a range

Useful when the user asks "what days do you have open this week/month?".

| Param | Required | Description |
|-------|----------|-------------|
| `start_date` | yes | `YYYY-MM-DD` |
| `end_date` | yes | `YYYY-MM-DD` (must be ≥ start_date) |
| `service_id` | no | numeric ID, or `all` (default) |
| `widget_id` | no | override |

```bash
curl "https://your-domain.com/wp-json/alexr-bot/v1/availability/month?start_date=2026-06-01&end_date=2026-06-30" \
  -H "X-API-Key: ...."
```

**Success (200):**

```json
{
  "success": true,
  "data": {
    "start_date": "2026-06-01",
    "end_date": "2026-06-30",
    "days_available": ["2026-06-01", "2026-06-02", "2026-06-05", "..."],
    "days_with_services": {
      "2026-06-01": [101, 102],
      "2026-06-02": [101]
    }
  }
}
```

`days_with_services` maps each available date to the IDs of the services that offer slots that day. `days_available` is a flat sorted list of dates.

---

### 4. POST `/bookings` — create a reservation (with real availability check)

Performs an atomic create-with-availability-check. If the slot is no longer free (or the date is closed, or the service is blocked online), responds with `409 SLOT_UNAVAILABLE` and a `details.alternative_dates` array.

**Body (JSON):**

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `date` | string | yes | — | `YYYY-MM-DD` |
| `time` | string | yes | — | `HH:MM` (24h) |
| `party_size` | int | yes | — | ≥ 1 |
| `customer_name` | string | yes | — | First name (display name) |
| `customer_phone` | string | yes | — | National or international phone |
| `customer_last_name` | string | no | "" | Surname |
| `customer_email` | string | no | synthetic | If omitted, generated as `{platform}+{phone}@fake` |
| `customer_dial_code` | string | no | "" | Optional dial-code prefix (e.g. `+56`) |
| `service_id` | int | no | auto-detect | Shift or event ID. If omitted, the API matches the slot to one of the widget's services. |
| `notes` | string | no | null | Free text — allergies, occasion, etc. |
| `status` | string | no | service-resolved | Force the initial status. One of `pending`, `booked`, `confirmed`. If omitted, the service's own rules decide (usually `booked`, or `pending` if the service is configured to require review). Explicit value **bypasses** the "force pending for specific tables" rule — respect what the caller asks. Any other value → `400 VALIDATION_FAILED`. |
| `table_ids` | int[] | no | auto-assign | Specific table id(s) to seat the booking on (from `GET /tables`). For POS walk-ins already seated. See note below. |
| `area` | int\|string | no | none | Area the guest chooses — id or name. Validated against the area's capacity. See note below. |
| `send_notifications` | bool | no | **true** | If false, no email/SMS/WhatsApp is sent. Notifications sent match the final `status`: `pending`/`booked`/`confirmed` each have their own email + WhatsApp templates. |
| `widget_id` | int | no | API key default | Rarely needed. |

> **`table_ids` (walk-ins).** When you pass `table_ids`, Alex Reservations does **not** auto-assign a table and the **availability/cover check is skipped** — the POS asserts the guests are physically seated, so the booking is created on exactly those tables even if the shift looks "full". The floor plan then matches the room. Ids must belong to the restaurant (otherwise `400 INVALID_TABLE`); table conflicts are not blocked (the floor plan shows the overlap). Accepts a JSON array (`[12,13]`) or a comma-separated string.

> **`area`.** The same choice the guest makes in the web widget when the service has **"The user can select Area"** enabled — stored in the booking and returned in every payload. Accepts the area id or its name (case-insensitive; if two areas share a name the request fails with `400 AREA_AMBIGUOUS`, use the id).
>
> What gets validated depends on the service:
> * **Volume total with area selection** — the area has its own seat limit (*Terrace 36, Cellar 40*). If the party does not fit, the request fails with `409 AREA_FULL` and the response details carry `max_covers`, `occupied` and `free_seats` so the bot can offer a different area.
> * **Table based services** — the area has no seats of its own; it restricts where the table is taken from. If no table is free in that area, `409 AREA_FULL`.
> * **Service without area selection** → `400 AREA_NOT_SUPPORTED`. Unknown area → `404 AREA_NOT_FOUND`. Area not open to online bookings → `409 AREA_NOT_BOOKABLE`.
>
> An area with **no capacity configured counts as zero seats**, not as unlimited — exactly as the widget treats it. Configure a limit for every area you let guests choose.
>
> `area` is ignored when `table_ids` is used: the tables already decide where the guests sit.

```bash
curl -X POST https://your-domain.com/wp-json/alexr-bot/v1/bookings \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ...." \
  -d '{
    "date": "2026-06-15",
    "time": "20:00",
    "party_size": 4,
    "customer_name": "Juan",
    "customer_last_name": "Pérez",
    "customer_phone": "+56912345678",
    "notes": "Allergic to nuts"
  }'
```

Force the booking to be created in `pending` state (e.g. bot flow that requires restaurant review before confirming):

```bash
curl -X POST https://your-domain.com/wp-json/alexr-bot/v1/bookings \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ...." \
  -d '{
    "date": "2026-06-15",
    "time": "20:00",
    "party_size": 4,
    "customer_name": "Juan",
    "customer_phone": "+56912345678",
    "status": "pending"
  }'
```

**Success (201):**

```json
{
  "success": true,
  "data": {
    "reservation_id": "bo_abc...",
    "booking_id": 456,
    "uuid": "bo_abc...",
    "status": "booked",
    "restaurant_id": 1,
    "widget_id": 42,
    "service_id": 102,
    "service_name": "Dinner",
    "date": "2026-06-15",
    "time": "20:00",
    "time_seconds": 72000,
    "party_size": 4,
    "duration_minutes": 90,
    "customer_name": "Juan Pérez",
    "customer_first_name": "Juan",
    "customer_last_name": "Pérez",
    "customer_email": "instagram+56912345678@fake",
    "customer_phone": "+56912345678",
    "customer_dial_code": "",
    "notes": "Allergic to nuts",
    "source": "instagram",
    "language": "es",
    "created_at": "2026-05-26 15:42:01",
    "tables": [
      { "id": 12, "name": "7", "area_id": 2, "area_name": "Interior" }
    ],
    "area": { "id": 6, "name": "Terrace" }
  }
}
```

> `tables` lists the table(s) assigned to the booking (with their area), or `[]` if none is assigned yet. Present in every booking payload (create, read, search and the by-date list).

> `area` is the area the guest chose (`null` when none was requested). Do not confuse it with the `area_id` inside each entry of `tables`: that one is where the assigned table physically is, while `area` is the guest's choice — which on a *volume total* service may carry no table at all.

**Error — area full (409):**

```json
{
  "success": false,
  "error": {
    "code": "AREA_FULL",
    "message": "Not enough free seats in this area for the requested party size.",
    "details": {
      "area_id": 6,
      "area_name": "Terrace",
      "max_covers": 36,
      "occupied": 34,
      "free_seats": 2
    }
  }
}
```

**Idempotency:** if the same `restaurant + email + date + time + party_size` was already booked successfully, the original booking is returned with `"duplicate": true` in `data`. No second booking is created.

**Error — no availability (409):**

```json
{
  "success": false,
  "error": {
    "code": "SLOT_UNAVAILABLE",
    "message": "No availability for the requested date, time and party size.",
    "details": {
      "alternative_dates": [
        { "date": "2026-06-14", "slots_count": 8 },
        { "date": "2026-06-16", "slots_count": 6 }
      ]
    }
  }
}
```

---

### 5. GET `/bookings/{uuid}` — read a booking

```bash
curl https://your-domain.com/wp-json/alexr-bot/v1/bookings/bo_abc... \
  -H "X-API-Key: ...."
```

Returns the same payload shape as the create response. `404 BOOKING_NOT_FOUND` if the UUID is unknown or belongs to a different restaurant.

---

### 6. GET `/bookings` — search by phone **or** list a whole day

The `/bookings` endpoint serves two read modes depending on the query string:

| Param | Required | Default | Description |
|-------|----------|---------|-------------|
| `phone` | one of `phone`/`date` | — | Phone number, matched exactly against `booking.phone` |
| `date` | one of `phone`/`date` | — | `YYYY-MM-DD`. Lists **all** bookings of that day |
| `limit` | no | 5 | (phone mode only) 1–20 results |
| `include_past` | no | false | (phone mode only) If true, also returns past bookings |

If `date` is present it takes precedence and `phone` is ignored. If neither is sent → `400 VALIDATION_FAILED`.

#### 6a. Search by phone

Useful when the user identifies themselves by phone instead of a reservation code.

```bash
curl "https://your-domain.com/wp-json/alexr-bot/v1/bookings?phone=%2B56912345678&limit=3" \
  -H "X-API-Key: ...."
```

**Response (200):**

```json
{
  "success": true,
  "data": {
    "count": 2,
    "bookings": [
      { "reservation_id": "bo_...", "date": "2026-06-15", "time": "20:00", "status": "booked", ... },
      { "reservation_id": "bo_...", "date": "2026-06-22", "time": "13:00", "status": "pending", ... }
    ]
  }
}
```

Sorted by date desc.

#### 6b. List a whole day — `?date=YYYY-MM-DD`

Returns **every** booking of the day, in **all** statuses (except the internal `selected` and `deleted`), each with its assigned `tables`. Use this to see which tables are already taken/reserved before seating a walk-in, so you don't hand out a table that belongs to a pending or confirmed reservation.

```bash
curl "https://your-domain.com/wp-json/alexr-bot/v1/bookings?date=2026-06-15" \
  -H "X-API-Key: ...."
```

**Response (200):**

```json
{
  "success": true,
  "data": {
    "date": "2026-06-15",
    "count": 2,
    "bookings": [
      {
        "reservation_id": "bo_...", "date": "2026-06-15", "time": "13:00",
        "status": "pending", "party_size": 2, "customer_name": "Ana López",
        "tables": [ { "id": 12, "name": "7", "area_id": 2, "area_name": "Interior" } ]
      },
      {
        "reservation_id": "bo_...", "date": "2026-06-15", "time": "20:00",
        "status": "booked", "party_size": 4, "customer_name": "Juan Pérez",
        "tables": []
      }
    ]
  }
}
```

Each booking has the same structure as `GET /bookings/{uuid}`. Sorted by time asc. Invalid date → `400 VALIDATION_FAILED`.

---

### 7. PATCH / PUT `/bookings/{uuid}` — modify

Partial update. Send only the fields the user wants to change. Both `PATCH` and `PUT` are accepted.

| Field | Type | Description |
|---|---|---|
| `date` | string | New `YYYY-MM-DD` |
| `time` | string | New `HH:MM` |
| `party_size` | int | New party size |
| `notes` | string | Replace notes |
| `customer_name` | string | Update first name |
| `customer_last_name` | string | |
| `customer_phone` | string | |
| `customer_email` | string | |
| `table_ids` | int[] | Reassign the booking to specific table(s) — e.g. a waiter moving the reservation to another table in the POS. Overrides auto-assignment; applied even when date/time/party don't change. Empty array `[]` clears the tables. |
| `area` | int\|string | Move the booking to another area (id or name), validated the same way as on create. `null` or `""` clears it. On a table based service the table is reassigned inside the new area. |
| `send_notifications` | bool | Default **true** — sends a modification email |

If `date`, `time` or `party_size` changes, availability is **re-validated** against the same service (excluding the current booking from the count). Conflicts respond with `409 SLOT_UNAVAILABLE` and `alternative_dates`.

When `table_ids` is sent, those tables are assigned directly (no availability re-check for the table move) and ids must belong to the restaurant (`400 INVALID_TABLE`). A `tables changed` entry is recorded in the audit log.

The area is **only** touched when you send `area`. Changing `party_size` or `time` does not re-check the area on its own, so a booking can end up over its area's limit — send `area` together with the change when you want it re-validated.

Bookings in status `cancelled`, `denied`, `no-show`, `finished` or `paid` can't be modified (`409 BOOKING_NOT_MODIFIABLE`).

```bash
curl -X PATCH https://your-domain.com/wp-json/alexr-bot/v1/bookings/bo_abc... \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ...." \
  -d '{ "time": "20:30", "party_size": 5 }'
```

**Response (200):**

```json
{
  "success": true,
  "data": {
    "reservation_id": "bo_abc...",
    "status": "booked",
    "date": "2026-06-15",
    "time": "20:30",
    "party_size": 5,
    ...,
    "old_date": "2026-06-15",
    "old_time": 72000,
    "old_party": 4
  }
}
```

---

### 8. POST `/bookings/{uuid}/cancel` — cancel

| Field | Type | Description |
|---|---|---|
| `reason` | string (optional) | Stored in `booking.cancelReason` |
| `send_notifications` | bool (optional, default true) | If false, no cancellation emails |

```bash
curl -X POST https://your-domain.com/wp-json/alexr-bot/v1/bookings/bo_abc.../cancel \
  -H "Content-Type: application/json" \
  -H "X-API-Key: ...." \
  -d '{ "reason": "Customer requested cancellation via Instagram" }'
```

**Response (200):**

```json
{
  "success": true,
  "data": {
    "reservation_id": "bo_abc...",
    "status": "cancelled",
    "date": "2026-06-15",
    "time": "20:00",
    ...
  }
}
```

If the booking was already cancelled, the response still has `success: true` and includes `"message": "Booking is already cancelled."`.

---

### 9. PATCH `/bookings/{uuid}/status` — change status

Change a booking's status. Supports both **reservation-lifecycle** transitions (pending → booked → confirmed) and **table-lifecycle** transitions (seated → finished, or no-show), so the same endpoint is used by the bot when the restaurant confirms a pending reservation and by the POS / host app when guests are seated or the table is closed.

| Param | Required | Default | Description |
|-------|----------|---------|-------------|
| `status` | yes | — | One of the values below |
| `send_notifications` | no | `false` | If `true` and the new status is `pending` or `booked`, sends the same customer email/SMS/WhatsApp that a fresh booking would send. Ignored for `seated`, `finished`, `no-show`, `confirmed`. |

**Allowed values**

| Status | Meaning |
|---|---|
| `pending`   | Awaiting restaurant confirmation. |
| `booked`    | Restaurant has confirmed the booking. |
| `confirmed` | Customer has re-confirmed the booking. |
| `seated`    | Guests have been seated. |
| `finished`  | Table closed / paid out. |
| `no-show`   | Guests did not show up (immediately frees the slot). |

Cancellations should go through **`POST /bookings/{uuid}/cancel`** (with `reason`), not through this endpoint.

```bash
# Restaurant confirms a pending booking, sending the confirmation email
curl -X PATCH https://your-domain.com/wp-json/alexr-bot/v1/bookings/bo_abc.../status \
  -H "X-API-Key: ...." \
  -H "Content-Type: application/json" \
  -d '{ "status": "booked", "send_notifications": true }'

# POS marks guests as seated (no notifications, floor plan updated)
curl -X PATCH https://your-domain.com/wp-json/alexr-bot/v1/bookings/bo_abc.../status \
  -H "X-API-Key: ...." \
  -H "Content-Type: application/json" \
  -d '{ "status": "seated" }'
```

**Response (200):** the updated booking (same shape as `GET /bookings/{uuid}`), now with the new `status`.

**Notes**

- Customer notifications are **off by default** to avoid noise for frequent POS updates. Opt in with `send_notifications: true` (only meaningful for `pending` / `booked`).
- An audit-log entry is always recorded ("Automation Bot") via the standard status-changed event.
- **`no-show`** (like `cancelled`) immediately frees the table on the floor plan and in availability.
- **`seated`** keeps the table occupied (the guests are there).
- **`finished`** marks the reservation as completed for statistics; the table stays reserved for the booking's time window (Alex Reservations counts `finished` as occupying its slot).
- A `cancelled` / `denied` booking cannot change status → `409 BOOKING_NOT_MODIFIABLE`.
- Any other `status` value → `400 VALIDATION_FAILED` (allowed values are returned in `error.details.allowed`).
- Idempotent: setting the status it already has returns `200` with `"message": "Booking already has this status."`.

---

### 10. GET `/tables` — list tables

Returns every table of the restaurant with its id, name, area/zone and capacity. Use it to keep a mapping between your POS table names and the Alex Reservations table IDs (the same IDs returned in each booking's `tables` field).

```bash
curl "https://your-domain.com/wp-json/alexr-bot/v1/tables" \
  -H "X-API-Key: ...."
```

**Response (200):**

```json
{
  "success": true,
  "data": {
    "count": 3,
    "tables": [
      { "id": 12, "name": "7",     "area_id": 2, "area_name": "Interior", "min_seats": 2, "max_seats": 4 },
      { "id": 13, "name": "EXT-1", "area_id": 5, "area_name": "Terrace",  "min_seats": 2, "max_seats": 4 },
      { "id": 14, "name": "16",    "area_id": 2, "area_name": "Interior", "min_seats": 3, "max_seats": 5 }
    ]
  }
}
```

| Field | Description |
|-------|-------------|
| `id` | Internal table ID (matches the IDs in a booking's `tables`) |
| `name` | Table name/label as shown on the floor plan |
| `area_id` / `area_name` | Area/zone the table belongs to (`null` if none) |
| `min_seats` / `max_seats` | Capacity range of the table |

Returns all physical tables of the restaurant (table combinations are not included).

---

## How it works under the hood

### Real availability
`POST /bookings` calls the same `Service::isAvailable()` method used by the public reservation widget. This means:

- Closed days are honoured.
- Closed slots within an open day are honoured.
- The service's `availability_type` is honoured (`volume_total`, `tables`, `specific_tables`, `tables_schedule`, `tables_layouts`).
- Tables are assigned automatically when the availability type requires it.
- The service's online-bookings configuration is honoured.

### Service auto-detection
If you don't send `service_id` in `POST /bookings`, the API walks the widget's services and picks the one whose bookable slots include the requested time. Sending `service_id` explicitly is faster and unambiguous.

### Status of new bookings
The status (`pending` or `booked`) is decided by the service's `getBookingStatusForNewReservation()` method — the same rule the widget applies. Some services force `pending` for specific tables, which is honoured.

### Customer matching
- If a customer with the same email exists for the restaurant, the booking is linked to it.
- If no email is supplied, a synthetic one is built as `{platform}+{phone}@fake` where `platform` is the API key's `platform` field (`instagram`, `whatsapp_bot`, …). This mirrors the convention already used by WhatsApp-created customers.
- If still no customer exists, one is created automatically.

### Notifications
By default, all create / modify / cancel operations send the configured emails, SMS, WhatsApp and dashboard notifications. Pass `send_notifications: false` to suppress them — useful when the bot confirms in its own chat thread.

### Idempotency
A SHA-256 hash of `restaurant | email | date | time | party_size` is stored in `api_logs_bot.idempotency_key`. A second identical request returns the original booking with `"duplicate": true` in `data` (HTTP 200).

### Tenant isolation
Every read / modify / cancel verifies that the booking's `restaurant_id` matches the API key's `restaurant_id`. Mismatches respond `404 BOOKING_NOT_FOUND` to avoid leaking existence of bookings from other restaurants.

### Source tracking
Bookings created by this API get `booking.source = <api_key.platform>` (e.g. `instagram`, `whatsapp_bot`) and `booking.widget_id = <api_key.widget_id>`. This lets the dashboard segment statistics by origin.

---

## API key setup

Bot API keys live in the `api_keys_bot` table. They are managed from the dashboard under **Settings → Integrations → API Keys (Bot)** when the API Bot module is enabled.

Required to create a key:

- Restaurant
- Widget — determines which services, party limits and rules apply to the bot
- Platform name — `instagram`, `whatsapp_bot`, `telegram_bot`… Used both as the booking `source` and as the prefix for synthetic emails.
- A human-readable name (e.g. `"RaffaellIA Instagram"`)

The key itself is a 64-char hex string generated on creation. Store it as an environment variable in the bot's runtime (e.g. Railway, Cloudflare Workers). Never commit it.

### Manual SQL example

```sql
INSERT INTO {prefix}_api_keys_bot
  (uuid, restaurant_id, widget_id, api_key, platform, name, is_active, date_created, date_modified)
VALUES
  ('akb_unique_id', 1, 42, 'your-64-char-hex', 'instagram', 'RaffaellIA Instagram', 1, NOW(), NOW());
```

| Column | Notes |
|--------|-------|
| `restaurant_id` | Restaurant the key authorizes |
| `widget_id` | The widget whose services/rules the bot uses |
| `api_key` | 64-char hex. Generate with `bin2hex(random_bytes(32))` in PHP |
| `platform` | Free-form, alphanumeric. Used in `source` and synthetic emails |
| `is_active` | Set to 0 to revoke |

---

## Rate limits and timeouts

- The plugin does not impose hard rate limits at the moment. A conversational bot is expected to make at most ~1 call per user message.
- Recommended client-side timeout: **10 seconds**. Operations that touch large floorplans (`POST /bookings` with `tables_schedule` services) can take a couple of seconds.

---

## Logging

Every request lands in `api_logs_bot`:

| Column | Content |
|---|---|
| `restaurant_id`, `api_key_id`, `widget_id` | Resolved from the auth step |
| `action` | `bot_get_restaurant`, `bot_get_tables`, `bot_availability`, `bot_availability_month`, `bot_create_booking`, `bot_get_booking`, `bot_search_bookings`, `bot_modify_booking`, `bot_update_status`, `bot_cancel_booking` |
| `status_code` | HTTP status returned |
| `request_body`, `response_body` | Full JSON (API key stripped from request) |
| `ip_address` | First non-empty of `X-Forwarded-For`, `X-Real-IP`, `REMOTE_ADDR` |
| `idempotency_key` | SHA-256 hash (only on successful creates) |

---

## Conversational flow example

```
User:    "Quiero reservar para el sábado para 4 personas"
Bot:     [GET /availability?date=2026-05-30&party_size=4]
Bot:     "Tenemos 13:00, 14:30 y 20:00. ¿Cuál prefieres?"
User:    "20:00"
Bot:     "¿A nombre de quién y con qué teléfono?"
User:    "Juan Pérez, +56912345678"
Bot:     [POST /bookings { date, time, party_size, customer_name, customer_phone }]
Bot:     "¡Reserva confirmada! Código bo_abc..."
```

If the POST returns `409 SLOT_UNAVAILABLE` (someone took the slot meanwhile), the bot reads `details.alternative_dates` and offers them to the user.
