Skip to content

BookNDish API

Build on BookNDish

A documented REST API, signed webhooks and a free sandbox. Connect a website, a kitchen display, a CRM or an accounting tool to any restaurant on BookNDish, with the owner's permission.

Quick start

  1. 01

    Get a token

    The restaurant's owner or a manager opens Settings, then Developers, and creates a token with the scopes your integration needs, or a sandbox token to start on sample data. A token belongs to one restaurant and is shown once.

  2. 02

    Call the API

    Send the token as a bearer token. Every answer is JSON with data, meta and links.

  3. 03

    Write safely

    Give every POST an Idempotency-Key. A retry with the same key gets the first answer back, and nothing runs twice.

Base URL: https://restaurant-saas-19qm.onrender.com/api/v1

curl https://restaurant-saas-19qm.onrender.com/api/v1/menus \
  -H "Authorization: Bearer $BOOKNDISH_TOKEN" \
  -H "Accept: application/json"
curl -X POST https://restaurant-saas-19qm.onrender.com/api/v1/orders \
  -H "Authorization: Bearer $BOOKNDISH_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 5b0e1f4e-6a8c-4e0e-9a55-0c1f2d3e4f5a" \
  -d '{"type":"pickup","customer_name":"Ada","customer_phone":"+15550100","lines":[{"item_id":42,"quantity":2}]}'

Scopes

A token carries only the scopes it was given, and each endpoint asks for one.

  • menu:read
  • menu:write
  • orders:read
  • orders:write
  • reservations:read
  • reservations:write
  • guests:read
  • reports:read

API reference

Menu

8 endpoints

Menus, categories and dishes.

GET/menus

Every menu with its categories

Scope: menu:read

Example answer 200
{
    "data": [
        {
            "id": 3,
            "name": {
                "en": "Dinner"
            },
            "is_active": true,
            "sort_order": 0,
            "categories": [
                {
                    "id": 7,
                    "menu_id": 3,
                    "name": {
                        "en": "Mains"
                    },
                    "is_active": true,
                    "sort_order": 0
                }
            ]
        }
    ],
    "meta": {
        "count": 1,
        "currency": "EUR"
    },
    "links": {}
}
GET/menus/{menu}

One menu, its categories and their dishes

meta.now says what menus by time of day and happy hours make of the menu right now, on the default branch's clock: whether it and each category are open, and each dish or size a happy hour has cut, at the price charged now.

Scope: menu:read

Parameters
NameInTypeRequired
menupathintegerRequired
Example answer 200
{
    "data": {
        "id": 3,
        "name": {
            "en": "Dinner"
        },
        "is_active": true,
        "sort_order": 0,
        "categories": [
            {
                "id": 7,
                "menu_id": 3,
                "name": {
                    "en": "Mains"
                },
                "is_active": true,
                "sort_order": 0,
                "items": [
                    {
                        "id": 42,
                        "category_id": 7,
                        "name": {
                            "en": "Burger"
                        },
                        "description": {
                            "en": "Beef, brioche"
                        },
                        "price_minor": 1100,
                        "is_available": true,
                        "show_online": true,
                        "dietary": "none",
                        "allergens": [
                            "gluten",
                            "milk"
                        ],
                        "sort_order": 1,
                        "variants": [],
                        "modifier_groups": [],
                        "updated_at": "2026-09-29T10:12:00+00:00"
                    }
                ]
            }
        ]
    },
    "meta": {
        "currency": "EUR",
        "now": {
            "available": true,
            "closed_category_ids": [],
            "prices": []
        }
    },
    "links": {}
}
GET/categories

Every category, or one menu's

Scope: menu:read

Parameters
NameInTypeRequired
menu_idqueryintegerOptional
Example answer 200
{
    "data": [
        {
            "id": 7,
            "menu_id": 3,
            "name": {
                "en": "Mains"
            },
            "is_active": true,
            "sort_order": 0
        }
    ],
    "meta": {
        "count": 1
    },
    "links": {}
}
GET/items

Dishes, a page at a time

Scope: menu:read

Parameters
NameInTypeRequired
category_idqueryintegerOptional
availablequerystring: "true", "false", "1", "0"Optional
per_pagequeryintegerOptional
cursorquerystringOptional
Example answer 200
{
    "data": [
        {
            "id": 42,
            "category_id": 7,
            "name": {
                "en": "Burger"
            },
            "description": null,
            "price_minor": 1100,
            "is_available": true,
            "show_online": true,
            "dietary": "none",
            "allergens": null,
            "sort_order": 1,
            "variants": [],
            "modifier_groups": [],
            "updated_at": "2026-09-29T10:12:00+00:00"
        }
    ],
    "meta": {
        "currency": "EUR",
        "per_page": 25,
        "next_cursor": null,
        "prev_cursor": null
    },
    "links": {
        "next": null,
        "prev": null
    }
}
POST/items

Add a dish

name and description are a string in the restaurant's default language, or a translation map. A dish is priced by price_minor, or per size by variants.

Scope: menu:write

Parameters
NameInTypeRequired
Idempotency-KeyheaderstringOptional
Request body
{
    "category_id": 7,
    "name": "Burger",
    "price_minor": 1100,
    "allergens": [
        "gluten"
    ]
}
Example answer 201
{
    "data": {
        "id": 42,
        "category_id": 7,
        "name": {
            "en": "Burger"
        },
        "description": {
            "en": "Beef, brioche"
        },
        "price_minor": 1100,
        "is_available": true,
        "show_online": true,
        "dietary": "none",
        "allergens": [
            "gluten",
            "milk"
        ],
        "sort_order": 1,
        "variants": [],
        "modifier_groups": [
            {
                "id": 5,
                "name": {
                    "en": "Extras"
                },
                "min_select": 0,
                "max_select": null,
                "options": [
                    {
                        "id": 9,
                        "name": {
                            "en": "Cheese"
                        },
                        "price_minor": 100,
                        "is_available": true
                    }
                ]
            }
        ],
        "updated_at": "2026-09-29T10:12:00+00:00"
    },
    "meta": {
        "currency": "USD"
    },
    "links": {}
}
GET/items/{item}

One dish

Scope: menu:read

Parameters
NameInTypeRequired
itempathintegerRequired
Example answer 200
{
    "data": {
        "id": 42,
        "category_id": 7,
        "name": {
            "en": "Burger"
        },
        "description": {
            "en": "Beef, brioche"
        },
        "price_minor": 1100,
        "is_available": true,
        "show_online": true,
        "dietary": "none",
        "allergens": [
            "gluten",
            "milk"
        ],
        "sort_order": 1,
        "variants": [],
        "modifier_groups": [
            {
                "id": 5,
                "name": {
                    "en": "Extras"
                },
                "min_select": 0,
                "max_select": null,
                "options": [
                    {
                        "id": 9,
                        "name": {
                            "en": "Cheese"
                        },
                        "price_minor": 100,
                        "is_available": true
                    }
                ]
            }
        ],
        "updated_at": "2026-09-29T10:12:00+00:00"
    },
    "meta": {
        "currency": "USD"
    },
    "links": {}
}
PATCH/items/{item}

Change a dish

Only the fields sent change; variants and modifier groups are kept. A price change is recorded in the activity log. A dish priced per size refuses price_minor.

Scope: menu:write

Parameters
NameInTypeRequired
itempathintegerRequired
Request body
{
    "price_minor": 1250
}
Example answer 200
{
    "data": {
        "id": 42,
        "category_id": 7,
        "name": {
            "en": "Burger"
        },
        "description": {
            "en": "Beef, brioche"
        },
        "price_minor": 1100,
        "is_available": true,
        "show_online": true,
        "dietary": "none",
        "allergens": [
            "gluten",
            "milk"
        ],
        "sort_order": 1,
        "variants": [],
        "modifier_groups": [
            {
                "id": 5,
                "name": {
                    "en": "Extras"
                },
                "min_select": 0,
                "max_select": null,
                "options": [
                    {
                        "id": 9,
                        "name": {
                            "en": "Cheese"
                        },
                        "price_minor": 100,
                        "is_available": true
                    }
                ]
            }
        ],
        "updated_at": "2026-09-29T10:12:00+00:00"
    },
    "meta": {
        "currency": "USD"
    },
    "links": {}
}
PUT/items/{item}/availability

Sold out, and back

Recorded like a flip on the till.

Scope: menu:write

Parameters
NameInTypeRequired
itempathintegerRequired
Request body
{
    "is_available": false
}
Example answer 200
{
    "data": {
        "id": 42,
        "category_id": 7,
        "name": {
            "en": "Burger"
        },
        "description": {
            "en": "Beef, brioche"
        },
        "price_minor": 1100,
        "is_available": true,
        "show_online": true,
        "dietary": "none",
        "allergens": [
            "gluten",
            "milk"
        ],
        "sort_order": 1,
        "variants": [],
        "modifier_groups": [
            {
                "id": 5,
                "name": {
                    "en": "Extras"
                },
                "min_select": 0,
                "max_select": null,
                "options": [
                    {
                        "id": 9,
                        "name": {
                            "en": "Cheese"
                        },
                        "price_minor": 100,
                        "is_available": true
                    }
                ]
            }
        ],
        "updated_at": "2026-09-29T10:12:00+00:00"
    },
    "meta": {
        "currency": "USD"
    },
    "links": {}
}

Orders

4 endpoints

Orders on any channel, and their status.

GET/orders

Orders, newest first

Scope: orders:read

Parameters
NameInTypeRequired
statusquerystringOptional
typequerystring: "dine_in", "pickup", "delivery"Optional
channelquerystringOptional
placed_fromquerystringOptional
placed_toquerystringOptional
updated_sincequerystringOptional
per_pagequeryintegerOptional
cursorquerystringOptional
Example answer 200
{
    "data": [],
    "meta": {
        "per_page": 25,
        "next_cursor": null,
        "prev_cursor": null
    },
    "links": {
        "next": null,
        "prev": null
    }
}
POST/orders

Place an order

The order is priced from the menu (any price in the body is ignored), the owner's ordering rules apply (types offered, minimum order, delivery zones, opening hours; an order outside hours is held for the next opening), the kitchen gets its tickets and a connected POS gets the order. Dine-in orders need table_code.

Scope: orders:write

Parameters
NameInTypeRequired
Idempotency-KeyheaderstringOptional
Request body
{
    "type": "delivery",
    "channel": "web",
    "customer_name": "Ada Lovelace",
    "customer_phone": "+15550100",
    "customer_email": "ada@example.com",
    "note": "Ring twice",
    "delivery_address": {
        "line1": "12 Main St",
        "city": "Springfield"
    },
    "lines": [
        {
            "item_id": 42,
            "quantity": 2,
            "option_ids": [
                9
            ]
        },
        {
            "item_id": 43,
            "variant_id": 11,
            "quantity": 1
        }
    ]
}
Example answer 201
{
    "data": {
        "id": "9d1c7a52-8f0e-4b8c-a1f5-3a1a5b1f0c2d",
        "status": "placed",
        "type": "delivery",
        "channel": "web",
        "branch_id": 1,
        "table": null,
        "customer": {
            "name": "Ada Lovelace",
            "phone": "+15550100",
            "email": "ada@example.com"
        },
        "note": "Ring twice",
        "delivery_address": {
            "line1": "12 Main St",
            "city": "Springfield"
        },
        "currency": "USD",
        "subtotal_minor": 2300,
        "discount_minor": 0,
        "service_charge_minor": 0,
        "tax_added_minor": 0,
        "delivery_fee_minor": 300,
        "total_minor": 2600,
        "tax_breakdown": null,
        "cancel_reason": null,
        "items": [
            {
                "id": 1,
                "item_id": 42,
                "name": {
                    "en": "Burger"
                },
                "variant_name": null,
                "quantity": 2,
                "unit_price_minor": 1200,
                "line_total_minor": 2400,
                "voided": false,
                "modifiers": [
                    {
                        "name": {
                            "en": "Cheese"
                        },
                        "price_minor": 100
                    }
                ]
            }
        ],
        "placed_at": "2026-09-29T10:15:00+00:00",
        "scheduled_for": null,
        "paid_at": null,
        "updated_at": "2026-09-29T10:15:00+00:00"
    },
    "meta": {},
    "links": {}
}
GET/orders/{order}

One order

Scope: orders:read

Parameters
NameInTypeRequired
orderpathstringRequired
Example answer 200
{
    "data": {
        "id": "9d1c7a52-8f0e-4b8c-a1f5-3a1a5b1f0c2d",
        "status": "placed",
        "type": "delivery",
        "channel": "web",
        "branch_id": 1,
        "table": null,
        "customer": {
            "name": "Ada Lovelace",
            "phone": "+15550100",
            "email": "ada@example.com"
        },
        "note": "Ring twice",
        "delivery_address": {
            "line1": "12 Main St",
            "city": "Springfield"
        },
        "currency": "USD",
        "subtotal_minor": 2300,
        "discount_minor": 0,
        "service_charge_minor": 0,
        "tax_added_minor": 0,
        "delivery_fee_minor": 300,
        "total_minor": 2600,
        "tax_breakdown": null,
        "cancel_reason": null,
        "items": [
            {
                "id": 1,
                "item_id": 42,
                "name": {
                    "en": "Burger"
                },
                "variant_name": null,
                "quantity": 2,
                "unit_price_minor": 1200,
                "line_total_minor": 2400,
                "voided": false,
                "modifiers": [
                    {
                        "name": {
                            "en": "Cheese"
                        },
                        "price_minor": 100
                    }
                ]
            }
        ],
        "placed_at": "2026-09-29T10:15:00+00:00",
        "scheduled_for": null,
        "paid_at": null,
        "updated_at": "2026-09-29T10:15:00+00:00"
    },
    "meta": {},
    "links": {}
}
POST/orders/{order}/status

Move an order one step

placed to accepted or rejected, accepted to ready, ready to completed, and anything not finished to cancelled. Accepting fires the kitchen. A cancel with a reason (3 to 200 characters) also voids the kitchen tickets and keeps the reason.

Scope: orders:write

Parameters
NameInTypeRequired
orderpathstringRequired
Idempotency-KeyheaderstringOptional
Request body
{
    "status": "accepted"
}
Example answer 200
{
    "data": {
        "id": "9d1c7a52-8f0e-4b8c-a1f5-3a1a5b1f0c2d",
        "status": "placed",
        "type": "delivery",
        "channel": "web",
        "branch_id": 1,
        "table": null,
        "customer": {
            "name": "Ada Lovelace",
            "phone": "+15550100",
            "email": "ada@example.com"
        },
        "note": "Ring twice",
        "delivery_address": {
            "line1": "12 Main St",
            "city": "Springfield"
        },
        "currency": "USD",
        "subtotal_minor": 2300,
        "discount_minor": 0,
        "service_charge_minor": 0,
        "tax_added_minor": 0,
        "delivery_fee_minor": 300,
        "total_minor": 2600,
        "tax_breakdown": null,
        "cancel_reason": null,
        "items": [
            {
                "id": 1,
                "item_id": 42,
                "name": {
                    "en": "Burger"
                },
                "variant_name": null,
                "quantity": 2,
                "unit_price_minor": 1200,
                "line_total_minor": 2400,
                "voided": false,
                "modifiers": [
                    {
                        "name": {
                            "en": "Cheese"
                        },
                        "price_minor": 100
                    }
                ]
            }
        ],
        "placed_at": "2026-09-29T10:15:00+00:00",
        "scheduled_for": null,
        "paid_at": null,
        "updated_at": "2026-09-29T10:15:00+00:00"
    },
    "meta": {},
    "links": {}
}

Reservations

5 endpoints

Bookings and table availability. Needs a plan with reservations.

GET/reservations

Bookings for a day or a range

Scope: reservations:read

Parameters
NameInTypeRequired
datequerystringOptional
fromquerystringOptional
toquerystringOptional
statusquerystring: "pending", "confirmed", "declined", "cancelled", "seated", "no_show"Optional
per_pagequeryintegerOptional
cursorquerystringOptional
Example answer 200
{
    "data": [],
    "meta": {
        "per_page": 25,
        "next_cursor": null,
        "prev_cursor": null
    },
    "links": {
        "next": null,
        "prev": null
    }
}
POST/reservations

Write a booking down

The booking rules apply (party size, lead time, closing days); the guest gets the same confirmation as for a phone booking.

Scope: reservations:write

Parameters
NameInTypeRequired
Idempotency-KeyheaderstringOptional
Request body
{
    "name": "Ada Lovelace",
    "phone": "+15550100",
    "party_size": 2,
    "date": "2026-10-03",
    "time": "19:30"
}
Example answer 201
{
    "data": {
        "id": "1b6e1c1e-2f4f-4c43-9e0e-4b9d6f1a2c3d",
        "status": "confirmed",
        "name": "Ada Lovelace",
        "phone": "+15550100",
        "email": null,
        "party_size": 2,
        "date": "2026-10-03",
        "time": "19:30",
        "table_id": null,
        "note": null,
        "deposit_minor": null,
        "deposit_status": null,
        "created_at": "2026-10-01T09:00:00+00:00",
        "updated_at": "2026-10-01T09:00:00+00:00"
    },
    "meta": {},
    "links": {}
}
GET/availability

Bookable times for a day and a party

Scope: reservations:read

Parameters
NameInTypeRequired
datequerystringRequired
party_sizequeryintegerRequired
Example answer 200
{
    "data": {
        "date": "2026-10-03",
        "party_size": 2,
        "engine": true,
        "slots": [
            {
                "time": "19:00",
                "available": true,
                "guarantee": false
            },
            {
                "time": "19:30",
                "available": false,
                "guarantee": false
            }
        ],
        "guarantee": null,
        "notify": true
    },
    "meta": {},
    "links": {}
}
GET/reservations/{reservation}

One booking

Scope: reservations:read

Parameters
NameInTypeRequired
reservationpathstringRequired
Example answer 200
{
    "data": {
        "id": "1b6e1c1e-2f4f-4c43-9e0e-4b9d6f1a2c3d",
        "status": "confirmed",
        "name": "Ada Lovelace",
        "phone": "+15550100",
        "email": null,
        "party_size": 2,
        "date": "2026-10-03",
        "time": "19:30",
        "table_id": null,
        "note": null,
        "deposit_minor": null,
        "deposit_status": null,
        "created_at": "2026-10-01T09:00:00+00:00",
        "updated_at": "2026-10-01T09:00:00+00:00"
    },
    "meta": {},
    "links": {}
}
POST/reservations/{reservation}/cancel

Cancel a booking

Cancelled by the restaurant; the guest is told.

Scope: reservations:write

Parameters
NameInTypeRequired
reservationpathstringRequired
Idempotency-KeyheaderstringOptional
Example answer 200
{
    "data": {
        "id": "1b6e1c1e-2f4f-4c43-9e0e-4b9d6f1a2c3d",
        "status": "confirmed",
        "name": "Ada Lovelace",
        "phone": "+15550100",
        "email": null,
        "party_size": 2,
        "date": "2026-10-03",
        "time": "19:30",
        "table_id": null,
        "note": null,
        "deposit_minor": null,
        "deposit_status": null,
        "created_at": "2026-10-01T09:00:00+00:00",
        "updated_at": "2026-10-01T09:00:00+00:00"
    },
    "meta": {},
    "links": {}
}

Guests

2 endpoints

Guest profiles, with contact details only for guests who agreed to share them.

GET/guests

Guest profiles, a page at a time

Guests who asked to be forgotten never appear. Phone and email are included only for guests who agreed to hear from the restaurant. Staff notes are never included.

Scope: guests:read

Parameters
NameInTypeRequired
marketing_opted_inquerystring: "true", "false", "1", "0"Optional
updated_sincequerystringOptional
per_pagequeryintegerOptional
cursorquerystringOptional
Example answer 200
{
    "data": [
        {
            "id": 5,
            "name": "Ada Lovelace",
            "phone": "+15550100",
            "email": "ada@example.com",
            "contact_shared": true,
            "locale": "en",
            "marketing_opted_in": true,
            "marketing_opted_in_at": "2026-09-01T12:00:00+00:00",
            "visits_count": 4,
            "total_spend_minor": 9200,
            "last_visit_at": "2026-09-28T20:15:00+00:00",
            "created_at": "2026-06-02T19:40:00+00:00"
        }
    ],
    "meta": {
        "per_page": 25,
        "next_cursor": null,
        "prev_cursor": null
    },
    "links": {
        "next": null,
        "prev": null
    }
}
GET/guests/{guest}

One guest profile

Scope: guests:read

Parameters
NameInTypeRequired
guestpathintegerRequired
Example answer 200
{
    "data": {
        "id": 6,
        "name": "Alan Turing",
        "phone": null,
        "email": null,
        "contact_shared": false,
        "locale": null,
        "marketing_opted_in": false,
        "marketing_opted_in_at": null,
        "visits_count": 1,
        "total_spend_minor": 2400,
        "last_visit_at": null,
        "created_at": "2026-09-30T11:00:00+00:00"
    },
    "meta": {},
    "links": {}
}

Reports

1 endpoints

Sales figures. Needs a plan with reporting.

GET/reports/sales-summary

Revenue, orders and average order for a period

With the preceding period of the same length. Defaults to today on the restaurant's clock; at most 366 days.

Scope: reports:read

Parameters
NameInTypeRequired
fromquerystringOptional
toquerystringOptional
branch_idqueryintegerOptional
Example answer 200
{
    "data": {
        "from": "2026-09-01",
        "to": "2026-09-30",
        "branch_id": null,
        "currency": "EUR",
        "orders": 812,
        "revenue_minor": 1843200,
        "average_order_minor": 2270,
        "previous": {
            "orders": 760,
            "revenue_minor": 1690000,
            "average_order_minor": 2223
        },
        "change": {
            "revenue_pct": 9.1,
            "orders_pct": 6.8
        }
    },
    "meta": {},
    "links": {}
}

OAuth

2 endpoints

Apps from the marketplace: a venue owner allows an app on the consent screen, and the app exchanges the code for an access token (used as the bearer token of every operation above) and a refresh token. Authorization code with PKCE (S256).

POST/oauth/token

Exchange a code, or refresh, for tokens

authorization_code: redeem the code the venue's consent sent to your redirect address, with the PKCE verifier. refresh_token: get a new access token and a new refresh token; the old pair stops working. Authenticate with your client id and secret, as HTTP Basic or as client_id and client_secret in the body. Answers are never cached.

Request body
{
    "grant_type": "authorization_code",
    "code": "bnd_ac_9fK2...",
    "redirect_uri": "https://app.example.com/callback",
    "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
    "client_id": "bnd_app_4kq8...",
    "client_secret": "bnd_cs_..."
}
Example answer 200
{
    "access_token": "812|bnd_oat_Xf3k...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "bnd_ort_q8Lz...",
    "scope": "menu:read orders:read"
}
POST/oauth/revoke

Revoke a token

Gives back a refresh token (with its access token) or an access token (RFC 7009). An unknown token is not an error. Authenticate with your client id and secret, as HTTP Basic or as client_id and client_secret in the body. To end the whole grant, the venue uninstalls the app.

Request body
{
    "token": "bnd_ort_q8Lz...",
    "client_id": "bnd_app_4kq8...",
    "client_secret": "bnd_cs_..."
}
Example answer 200
{}

Webhooks

Add an https address on Settings, then Developers, and choose its events. Each delivery is a signed POST: answer with any 2xx within 10 seconds. A failure is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, and the delivery log can replay it.

Events

order.created
An order is placed on any channel.
order.status_changed
An order moves to another status.
order.paid
Payments cover the order.
reservation.created
A booking is made, by the guest or the staff.
reservation.updated
A booking changes.
reservation.cancelled
A booking is cancelled.
menu.item_updated
A dish changes: price, name, sold out.
payment.refunded
A payment is refunded.
guest.created
A guest profile is created.
guest.updated
A guest's details or consent change, or the guest is erased.
giftcard.issued
A gift card is sold or issued.
giftcard.redeemed
A gift card pays for an order.
review.created
A guest submits a rating.
stock.below_par
An ingredient drops below its par level at a branch.
shift.published
A manager publishes a week of shifts.
experience.booked
Tickets to an experience are paid for.

Verify the signature

BookNDish-Signature carries t, a Unix time, and v1, the hex HMAC-SHA256 of t, a dot and the raw body, keyed with your signing secret. Compare in constant time and refuse a t older than five minutes.

function bookndishSignatureIsValid(string $payload, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $pair) {
        [$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
        $parts[$key][] = $value;
    }

    $timestamp = (int) ($parts['t'][0] ?? 0);
    if ($timestamp === 0 || abs(time() - $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp.'.'.$payload, $secret);
    foreach ($parts['v1'] ?? [] as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

Sandbox

A sandbox is a sample venue of its own, with a menu, tables, guests, a booking and an order, behind one token with every scope. It answers on the same address with a BookNDish-Sandbox header, never touches the live restaurant, and goes back to its sample data every night.

Create one on Settings, then Developers, then Sandbox. Its token starts with bnd_sbx_.

Limits and errors

  • Each token may make 60 requests a minute by default. Every answer carries X-RateLimit-Remaining, and a 429 carries Retry-After.
  • A failure answers with error.code, error.message and error.details. A token only ever sees its own restaurant: anything else is a 404.
  • v1 only grows: new endpoints, optional fields and events. A change that would break a client goes into v2, with v1 kept alongside it.