# 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. |

---

## 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) |

```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 }
    ]
  }
}
```

**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. |
| `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. |
| `send_notifications` | bool | no | **true** | If false, no email/SMS/WhatsApp is sent. |
| `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.

```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"
  }'
```

**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" }
    ]
  }
}
```

> `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).

**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. |
| `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.

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 (seated / finished / no-show)

Operational status change driven by your POS / host app, so the floor plan and statistics stay in sync — e.g. when guests are seated, when the table is closed/paid out, or when nobody showed up.

| Param | Required | Description |
|-------|----------|-------------|
| `status` | yes | One of `seated`, `finished`, `no-show` |

```bash
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**

- No customer notifications are sent — this is an operational change. An audit-log entry is recorded ("Automation Bot").
- **`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.
