# The partner API, documented.

Every endpoint of the partner API: a request and a response for each, the errors, the limits, the promises.

Host https://pro.pamprr.me; every path begins /api/v1. OpenAPI 3.1.1 document: https://pro.pamprr.me/api/v1/openapi.json. Document version 1.1.0.

## Getting started

From a key to the first successful call in three steps.

1. **Mint a key** In Settings, Operations, API keys, create a key with the Read only access setting, copy it from the one time reveal, and store it in your server's environment as PAMPRR_API_KEY.
2. **Make the first call** Ask who the key belongs to. The answer is your business record: its id, names, slug, email, time zone and currency.
3. **Then the diary** List services, staff and locations to learn the ids a booking needs, search availability for a free slot, and, with a key that carries bookings:write, create the booking.

The first call, curl:

```bash
curl "https://pro.pamprr.me/api/v1/business" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

The first call, JavaScript:

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/business",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { business } = await response.json();
```

The first call, Python:

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/business",
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
business = response.json()["business"]
```

## Authentication

Every call carries the key as a bearer token; the key is the business.

Send the key as Authorization: Bearer with every request. The API is server to server: keep the key in your backend's environment, never in a browser page or a mobile app.

- A key is minted by an owner or admin in Settings, Operations, API keys, revealed once, and never stored in plain text by pamprr.
- The first eight characters after the prefix are what the Settings list shows, so they are safe to quote; the rest is the secret.
- A missing key, a malformed key and a key that does not verify all receive the same 401 with the code UNAUTHORIZED and no reason.
- A key that verified but is revoked or expired receives the same code with a reason, because only the holder of the full secret can learn it.
- Every 401 carries a WWW-Authenticate challenge.

Provide your pamprr_live_<secret> API key as Bearer <token>. Keys are minted in the operator Settings UI and revealed exactly once. Every key carries an access setting (a list of scopes such as bookings:read); each operation names the one scope it requires in x-pamprr-required-scope, and a key without it receives 403 FORBIDDEN. The security requirement on each operation names that scope as a role, the OpenAPI 3.1 form; x-pamprr-required-scope carries the same name for tools that ignore roles on http schemes.

| Header | What it says |
| --- | --- |
| WWW-Authenticate | RFC 6750 bearer challenge. On a 401: Bearer error="invalid_token" when a credential was presented, or the bare challenge Bearer when none was. On a 403: Bearer error="insufficient_scope", scope="<the required scope>". |

## Access and scopes

A key carries an access setting; each endpoint requires exactly one scope.

The Settings picker offers Read only (the default, every read scope), Full access (every scope) and Custom (read or write per resource).

- Your business record is always readable.
- A write scope stores its read scope beside it.
- A call outside the key's access receives 403 FORBIDDEN with requiredScope naming the scope it lacks; the request was authenticated and counted against the allowance.

| Scope | Endpoints that require it |
| --- | --- |
| availability:read | GET /api/v1/availability |
| bookings:read | GET /api/v1/bookings, GET /api/v1/bookings/{id} |
| bookings:write | POST /api/v1/bookings, POST /api/v1/bookings/{id}/cancel, POST /api/v1/bookings/{id}/reschedule |
| business:read | GET /api/v1/business |
| clients:read | GET /api/v1/clients, GET /api/v1/clients/{id} |
| clients:write | POST /api/v1/clients |
| locations:read | GET /api/v1/locations |
| services:read | GET /api/v1/services, GET /api/v1/services/{id}/eligible-staff |
| staff:read | GET /api/v1/staff |

## Conventions

One set of shapes everywhere.

| Convention | The rule |
| --- | --- |
| Requests and responses | JSON. Send Content-Type: application/json on a write. Every response body is a JSON object. |
| Times | ISO 8601 in UTC with a Z suffix. Every business runs on Europe/London today; the business and location records name the zone, and availability answers carry a localTime beside each UTC start. |
| Money | Integers in pence. The business record names the currency; GBP for every business today. |
| Ids | UUIDs. A path id that is not a UUID, or that names another business's record, answers 404. |
| Names | camelCase fields. Absent values are null, never omitted. |
| Envelopes | A list answers { <resource>: [...], nextCursor }; a single record answers { business }, { booking } or { client }; a cancel adds alreadyCancelled and a client create may add existing; the availability search and the eligible staff answer their own object. |
| Host | One host, https://pro.pamprr.me, and one version prefix, /api/v1, carried by every path. |

## Errors

One envelope on every 4xx and 5xx; branch on the code, never on the sentence.

Every error body is { error, code, requestId }: a sentence for a human, the word your software branches on, and the id to quote to support. Some codes add a field beside those three, shown in the catalogue below.

| Status | Meaning on this API |
| --- | --- |
| 200 | The call worked: a read, a change, or a client that already existed. |
| 201 | A booking or a client was created. |
| 400 | The request is malformed, a value is out of bounds, a cursor is bad, an idempotency key is malformed, or a branch is not usable. |
| 401 | No key, a key that does not verify, or a key that is revoked or expired. |
| 402 | The booking must be made through the consumer booking flow: a deposit or a saved card is required. |
| 403 | The key verified but lacks the scope the endpoint requires. |
| 404 | Not yours, gone, archived, or not a UUID; the API never says which. |
| 409 | The state refuses the change: a taken or held slot, a terminal booking, or a retry still in flight. |
| 422 | The idempotency key was already used for a different request. |
| 429 | Too many requests: the allowance, or abuse protection. |
| 500 | Something went wrong at pamprr; quote the request id. |

| Code | Status | When | Example body |
| --- | --- | --- | --- |
| INVALID_JSON | 400 | The body is not JSON | `{"error":"Request body must be valid JSON.","code":"INVALID_JSON","requestId":"req_000000000000000000000001"}` |
| VALIDATION_FAILED | 400 | A value fails its rule; field names it where one is named | `{"error":"locationId must be a UUID.","code":"VALIDATION_FAILED","field":"locationId","requestId":"req_000000000000000000000002"}` |
| INVALID_START_TIME | 400 | startTime does not parse | `{"error":"Booking start time is not a valid ISO date.","code":"INVALID_START_TIME","requestId":"req_000000000000000000000003"}` |
| PAST_START_TIME | 400 | startTime is in the past | `{"error":"Booking start time is in the past.","code":"PAST_START_TIME","requestId":"req_000000000000000000000004"}` |
| INVALID_LOCATION | 400 | The branch does not offer the service | `{"error":"This service is not offered at the requested location.","code":"INVALID_LOCATION","requestId":"req_000000000000000000000005"}` |
| INVALID_DATE_RANGE | 400 | A date bound does not parse | `{"error":"startDate and endDate must be valid ISO dates.","code":"INVALID_DATE_RANGE","requestId":"req_000000000000000000000006"}` |
| INVALID_CURSOR | 400 | The cursor does not decode or belongs to other filters | `{"error":"The cursor is not valid for this list and these filters. Start again from the first page.","code":"INVALID_CURSOR","requestId":"req_000000000000000000000007"}` |
| IDEMPOTENCY_KEY_INVALID | 400 | The Idempotency-Key is malformed | `{"error":"Idempotency-Key must be 1 to 255 printable ASCII characters; a UUID is recommended.","code":"IDEMPOTENCY_KEY_INVALID","requestId":"req_000000000000000000000008"}` |
| UNAUTHORIZED | 401 | No key, or a key that does not verify: the one collapsed refusal | `{"error":"Invalid API key.","code":"UNAUTHORIZED","requestId":"req_00000000000000000000000a"}` |
| DEPOSIT_REQUIRED | 402 | The service's effective booking protection includes a deposit and Stripe is live | `{"error":"A deposit is required for this service. Bookings for this service must be made through the consumer booking flow.","code":"DEPOSIT_REQUIRED","requestId":"req_000000000000000000000017"}` |
| CARD_CAPTURE_REQUIRED | 402 | The service's effective booking protection is card capture and Stripe is live | `{"error":"This service requires a saved payment method and consent before booking. Bookings for this service must be made through the consumer booking flow.","code":"CARD_CAPTURE_REQUIRED","requestId":"req_000000000000000000000018"}` |
| FORBIDDEN | 403 | A read only key attempting a write | `{"error":"This API key does not have the bookings:write scope.","code":"FORBIDDEN","requiredScope":"bookings:write","requestId":"req_00000000000000000000000d"}` |
| NOT_FOUND | 404 | Not yours, gone, archived, or not a UUID; the API never says which | `{"error":"Booking not found.","code":"NOT_FOUND","requestId":"req_000000000000000000000010"}` |
| BOOKING_OVERLAP | 409 | Another booking has the slot | `{"error":"This time slot is already booked.","code":"BOOKING_OVERLAP","requestId":"req_000000000000000000000011"}` |
| BOOKING_COMPLETED | 409 | A completed booking cannot be cancelled or rescheduled through the API | `{"error":"This booking is completed and cannot be cancelled through the API.","code":"BOOKING_COMPLETED","requestId":"req_000000000000000000000013"}` |
| BOOKING_NO_SHOW | 409 | A no show booking cannot be cancelled or rescheduled through the API | `{"error":"This booking was marked as a no show and cannot be cancelled through the API.","code":"BOOKING_NO_SHOW","requestId":"req_000000000000000000000014"}` |
| BOOKING_CANCELLED | 409 | A cancelled booking cannot be rescheduled | `{"error":"This booking is cancelled and cannot be rescheduled.","code":"BOOKING_CANCELLED","requestId":"req_000000000000000000000015"}` |
| SLOT_HELD | 409 | A customer is mid checkout for the slot | `{"error":"Someone else is booking this time right now. It frees up in a few minutes if they do not complete their booking. Please choose another time or try again shortly.","code":"SLOT_HELD","requestId":"req_000000000000000000000016"}` |
| IDEMPOTENCY_REQUEST_IN_PROGRESS | 409 | A retry while the first request is still running | `{"error":"A request with this idempotency key is still being processed. Retry shortly.","code":"IDEMPOTENCY_REQUEST_IN_PROGRESS","requestId":"req_000000000000000000000012"}` |
| IDEMPOTENCY_KEY_REUSED | 422 | The same key with a different request | `{"error":"This idempotency key was used for a different request. Use a new key for a new request.","code":"IDEMPOTENCY_KEY_REUSED","requestId":"req_00000000000000000000000f"}` |
| RATE_LIMIT_EXCEEDED | 429 | The key's sliding sixty second allowance is spent; X-RateLimit-Limit is present | `{"error":"Rate limit exceeded.","code":"RATE_LIMIT_EXCEEDED","requestId":"req_000000000000000000000019"}` |
| INTERNAL_ERROR | 500 | An unexpected failure; the body carries no internal detail | `{"error":"Could not list bookings. Please try again.","code":"INTERNAL_ERROR","requestId":"req_00000000000000000000001b"}` |

## Rate limits

One allowance per key over a sliding sixty second window, stated on every authenticated response.

| Header | What it says |
| --- | --- |
| X-RateLimit-Limit | The allowance in effect for this key and endpoint, per sliding sixty second window (100 today). Authoritative: read it rather than hard coding the number. It may rise without notice and never falls without a deprecation notice. Present on every authenticated response; absent from a 429 raised by abuse protection rather than the allowance. |
| X-RateLimit-Remaining | Requests left in the current window after this one; 0 on a 429. Every authenticated request counts, errors and idempotent replays included. |
| X-RateLimit-Reset | ISO 8601 UTC timestamp by which the window will have fully cleared. An upper bound, not the exact moment one slot frees. |
| Retry-After | Whole seconds to wait before retrying, rounded up. Present on every 429. |

## Pagination

The lists page by an opaque cursor; the eligible staff answer is the whole set.

limit: Rows per page, 1 to 100 (a value above 100 is treated as 100).

cursor: Opaque keyset cursor from the previous page's nextCursor. Store and pass it back unchanged with the same filters; absent on the first page. A cursor never expires; one from another list, or from different filters, is refused with 400 INVALID_CURSOR.

nextCursor: Pass back as cursor to fetch the next page; null when this page was the last.

## Idempotency

A write retried with the same key replays the first outcome.

Idempotency-Key: Optional but recommended: any string of 1 to 255 printable ASCII characters, a UUID v4 by preference, unique per request. For 24 hours the same key with the same request replays the first response (2xx, 4xx or 5xx alike) with Idempotent-Replayed: true; the same key with a different request is refused with 422 IDEMPOTENCY_KEY_REUSED; a retry while the first request is still running is refused with 409 IDEMPOTENCY_REQUEST_IN_PROGRESS; a malformed key is refused with 400 IDEMPOTENCY_KEY_INVALID. Keys are scoped to your business, so a rotated API key still replays. Requests are processed once, with one qualification: a retry more than sixty seconds after a request that never completed may execute afresh, so in the rare event of a timeout retry with the same key promptly.

| Header | What it says |
| --- | --- |
| Idempotent-Replayed | Present, with the value true, only when this response is the stored outcome of an earlier request that carried the same Idempotency-Key. The body is byte identical to the original. |
| X-Original-Request-Id | On a replay: the id of the request that did the work. X-Request-Id names this request. |

## Request ids

Every response names its request.

| Header | What it says |
| --- | --- |
| X-Request-Id | Server generated id for this request, present on every response. Quote it to support; it is meaningless otherwise. |

requestId: The same id as the X-Request-Id header. Quote it to support.

## Business

The record the key belongs to.

### The Business object

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| id | uuid | yes |  |
| name | string | yes |  |
| slug | string | yes |  |
| displayName | string | yes |  |
| email | string or null | yes |  |
| timezone | string | yes | The IANA zone the business runs on; Europe/London for every business today. Render local times with it; every time in this API is UTC. |
| currency | string | yes | The ISO 4217 code every price is in; GBP for every business today. Prices are in pence. |

### GET /api/v1/business

Scope: business:read.

The business the key belongs to.

Its names, slug and email, the time zone every time in this API is read against (Europe/London for every business today) and the currency every price is in (GBP, in pence).

No parameters: the key selects the business.

Request: Who the key belongs to

```bash
curl "https://pro.pamprr.me/api/v1/business" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/business",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { business } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/business",
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
business = response.json()["business"]
```

Response 200 (business): The business the key belongs to Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "business": {
    "id": "00000000-0000-4000-8000-0000000b0001",
    "name": "Example Studio Ltd",
    "slug": "example-studio",
    "displayName": "Example Studio",
    "email": "hello@example.test",
    "timezone": "Europe/London",
    "currency": "GBP"
  }
}
```

Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

## Locations

The business's branches.

### The Location object

A branch of the business. Never the whole row: no coordinates, catchment, travel or hours columns.

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| id | uuid | yes |  |
| name | string | yes |  |
| address | string or null | yes |  |
| city | string or null | yes |  |
| postcode | string or null | yes |  |
| phone | string or null | yes | The branch's public contact, as shown to customers. |
| email | string or null | yes |  |
| website | string or null | yes |  |
| locationModel | fixed \| mobile \| both or null | yes | Whether the branch takes customers at its address, travels to them, or both. Null on a legacy row. |
| isPrimary | boolean | yes |  |
| isVisible | boolean | yes | False when the business has hidden the branch from customers: it is live for the business but cannot take a booking through the API or the online calendar. |
| timezone | string | yes | The IANA zone the branch's hours and its bookings' local times are in. Europe/London for every business today; the field is where a per business zone would appear. |
| createdAt | date-time | yes |  |

### GET /api/v1/locations

Scope: locations:read.

The business's branches, live ones only, the primary first.

A branch the business has hidden from customers is listed with isVisible false: it cannot take a booking through the API or the online calendar, but the business still sees it.

Every location carries timezone; every business runs on Europe/London today.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| limit | query | integer (1 to 100, default 50) | no | Rows per page, 1 to 100 (a value above 100 is treated as 100). Example: 50 |
| cursor | query | string | no | Opaque keyset cursor from the previous page's nextCursor. Store and pass it back unchanged with the same filters; absent on the first page. A cursor never expires; one from another list, or from different filters, is refused with 400 INVALID_CURSOR. Example: eyJ2IjoyLCJsIjoibG9jYXRpb25zIiwiayI6WyIyMDI2LTAzLTAyVDA5OjAwOjAwLjAwMFoiLCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDBjMDIiXSwiZiI6ImUzYjBjNDQyIn0 |

Request: The branches

```bash
curl "https://pro.pamprr.me/api/v1/locations?limit=50" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/locations?limit=50",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { locations, nextCursor } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/locations",
    params={
        "limit": 50,
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
data = response.json()
locations = data["locations"]
```

Response 200 (branches): Three branches, the primary first, one hidden from customers Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "locations": [
    {
      "id": "00000000-0000-4000-8000-000000000c01",
      "name": "Example Studio, Soho",
      "address": "12 Example Street",
      "city": "London",
      "postcode": "EX1 1EX",
      "phone": "+44 20 7946 0100",
      "email": "soho@example.test",
      "website": "https://example.test",
      "locationModel": "both",
      "isPrimary": true,
      "isVisible": true,
      "timezone": "Europe/London",
      "createdAt": "2026-03-02T09:00:00.000Z"
    },
    {
      "id": "00000000-0000-4000-8000-000000000c02",
      "name": "Example Studio, Islington",
      "address": "4 Sample Road",
      "city": "London",
      "postcode": "EX2 2EX",
      "phone": "+44 20 7946 0101",
      "email": null,
      "website": null,
      "locationModel": "fixed",
      "isPrimary": false,
      "isVisible": true,
      "timezone": "Europe/London",
      "createdAt": "2026-05-14T11:20:00.000Z"
    },
    {
      "id": "00000000-0000-4000-8000-000000000c03",
      "name": "Example Studio, Pop up",
      "address": null,
      "city": null,
      "postcode": null,
      "phone": null,
      "email": null,
      "website": null,
      "locationModel": null,
      "isPrimary": false,
      "isVisible": false,
      "timezone": "Europe/London",
      "createdAt": "2026-08-01T08:00:00.000Z"
    }
  ],
  "nextCursor": null
}
```

Errors: 400 INVALID_CURSOR, 401 UNAUTHORIZED, 403 FORBIDDEN, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

## Staff

The roster's partner projection.

### The Staff object

A staff member's partner projection. Never email, phone, gender, pronouns, bio, specialisms, the not bookable reason or commission.

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| id | uuid | yes | The staffProfileId a booking names. |
| name | string | yes | The display name, else the first and last names; the identity a booking's staffName snapshot carries. |
| initials | string | yes |  |
| colour | string or null | yes | The calendar colour. |
| jobTitle | string or null | yes |  |
| profileImageUrl | string or null | yes |  |
| isBookable | boolean | yes |  |
| acceptsOnlineBookings | boolean | yes |  |
| deliveryMode | fixed \| mobile \| both | yes |  |
| homeLocationId | uuid or null | yes |  |
| locationIds | array of uuid | yes | Every branch the member works at, the home branch included. |
| serviceIds | array of uuid | yes | The services the member is assigned to perform. |
| createdAt | date-time | yes |  |

### GET /api/v1/staff

Scope: staff:read.

The roster's partner projection.

The staff id a booking needs, the name, initials, colour, job title, photo, the bookable flags, the delivery mode, the home branch, the branches each member works at and the services each performs.

Never contact or demographic fields.

Filter by locationId for the members of one branch.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| limit | query | integer (1 to 100, default 50) | no | Rows per page, 1 to 100 (a value above 100 is treated as 100). Example: 50 |
| cursor | query | string | no | Opaque keyset cursor from the previous page's nextCursor. Store and pass it back unchanged with the same filters; absent on the first page. A cursor never expires; one from another list, or from different filters, is refused with 400 INVALID_CURSOR. Example: eyJ2IjoyLCJsIjoic3RhZmYiLCJrIjpbIlRoZW8iLCJCcmFuZHQiLCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwYTIiXSwiZiI6ImUzYjBjNDQyIn0 |
| locationId | query | uuid | no | Only the members of this branch (a member works at every branch listed in locationIds). A branch that is not one of your business's live branches is refused with 400 INVALID_LOCATION; a value that is not a UUID with 400 VALIDATION_FAILED. Example: 00000000-0000-4000-8000-000000000c01 |

Request: The members of one branch

```bash
curl "https://pro.pamprr.me/api/v1/staff?locationId=00000000-0000-4000-8000-000000000c01" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/staff?locationId=00000000-0000-4000-8000-000000000c01",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { staff, nextCursor } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/staff",
    params={
        "locationId": "00000000-0000-4000-8000-000000000c01",
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
data = response.json()
staff = data["staff"]
```

Response 200 (roster): The members of one branch Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "staff": [
    {
      "id": "00000000-0000-4000-8000-0000000000a1",
      "name": "Maya Okafor",
      "initials": "MO",
      "colour": "#6B8E4E",
      "jobTitle": "Senior therapist",
      "profileImageUrl": "https://example.test/images/maya.jpg",
      "isBookable": true,
      "acceptsOnlineBookings": true,
      "deliveryMode": "both",
      "homeLocationId": "00000000-0000-4000-8000-000000000c01",
      "locationIds": [
        "00000000-0000-4000-8000-000000000c01",
        "00000000-0000-4000-8000-000000000c02"
      ],
      "serviceIds": [
        "00000000-0000-4000-8000-0000000000e1",
        "00000000-0000-4000-8000-0000000000e2"
      ],
      "createdAt": "2026-03-02T09:05:00.000Z"
    },
    {
      "id": "00000000-0000-4000-8000-0000000000a2",
      "name": "Theo Brandt",
      "initials": "TB",
      "colour": "#C58B3F",
      "jobTitle": "Facialist",
      "profileImageUrl": null,
      "isBookable": true,
      "acceptsOnlineBookings": false,
      "deliveryMode": "fixed",
      "homeLocationId": "00000000-0000-4000-8000-000000000c01",
      "locationIds": [
        "00000000-0000-4000-8000-000000000c01"
      ],
      "serviceIds": [
        "00000000-0000-4000-8000-0000000000e2"
      ],
      "createdAt": "2026-06-10T10:00:00.000Z"
    }
  ],
  "nextCursor": null
}
```

Errors: 400 INVALID_CURSOR, 400 INVALID_LOCATION, 400 VALIDATION_FAILED, 401 UNAUTHORIZED, 403 FORBIDDEN, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

## Services

The menu, and who performs each service.

### The Service object

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| id | uuid | yes |  |
| name | string | yes |  |
| description | string or null | yes |  |
| durationMinutes | integer | yes |  |
| priceInPence | integer | yes |  |
| isActive | boolean | yes |  |
| isOnlineBookable | boolean | yes |  |
| isAddOn | boolean | yes |  |
| locationIds | array of uuid | yes | Branch ids the service is offered at, from the service_locations junction (Catalogue Stage 1). Always present; empty when the service has no offering rows. |

### The EligibleStaff object

The members who can perform one service, and where.

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| serviceId | uuid | yes |  |
| serviceName | string | yes |  |
| locationId | uuid or null | yes | The branch asked for; null when the answer covers every offering branch. |
| staff | array of objects (id, name, initials, colour, jobTitle, profileImageUrl, isBookable, acceptsOnlineBookings, deliveryMode, locationIds) | yes |  |

### GET /api/v1/services

Scope: services:read.

The business's menu in its own order.

Every service with its duration, price in pence, flags and the branches offering it; inactive services are returned with isActive false.

Each row is the Service projection.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| limit | query | integer (1 to 100, default 50) | no | Rows per page, 1 to 100 (a value above 100 is treated as 100). Example: 50 |
| cursor | query | string | no | Opaque keyset cursor from the previous page's nextCursor. Store and pass it back unchanged with the same filters; absent on the first page. A cursor never expires; one from another list, or from different filters, is refused with 400 INVALID_CURSOR. Example: eyJ2IjoyLCJsIjoic2VydmljZXMiLCJrIjpbMiwiU2lnbmF0dXJlIEZhY2lhbCIsIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBlMiJdLCJmIjoiZTNiMGM0NDIifQ |

Request: The menu

```bash
curl "https://pro.pamprr.me/api/v1/services?limit=50" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/services?limit=50",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { services, nextCursor } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/services",
    params={
        "limit": 50,
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
data = response.json()
services = data["services"]
```

Response 200 (menu): The first page of the menu, a second page to follow Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "services": [
    {
      "id": "00000000-0000-4000-8000-0000000000e1",
      "name": "Consultation",
      "description": "A thirty minute skin consultation.",
      "durationMinutes": 30,
      "priceInPence": 0,
      "isActive": true,
      "isOnlineBookable": true,
      "isAddOn": false,
      "locationIds": [
        "00000000-0000-4000-8000-000000000c01",
        "00000000-0000-4000-8000-000000000c02"
      ]
    },
    {
      "id": "00000000-0000-4000-8000-0000000000e2",
      "name": "Signature Facial",
      "description": null,
      "durationMinutes": 60,
      "priceInPence": 6500,
      "isActive": true,
      "isOnlineBookable": true,
      "isAddOn": false,
      "locationIds": [
        "00000000-0000-4000-8000-000000000c01",
        "00000000-0000-4000-8000-000000000c02"
      ]
    }
  ],
  "nextCursor": "eyJ2IjoyLCJsIjoic2VydmljZXMiLCJrIjpbMiwiU2lnbmF0dXJlIEZhY2lhbCIsIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBlMiJdLCJmIjoiZTNiMGM0NDIifQ"
}
```

Errors: 400 INVALID_CURSOR, 401 UNAUTHORIZED, 403 FORBIDDEN, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

### GET /api/v1/services/{id}/eligible-staff

Scope: services:read.

Who can perform a service, and at which branches.

The members assigned to the service who are bookable, each with locationIds narrowed to the visible branches where they can perform it (their branch memberships intersected with the branches offering the service), from the same rules the availability search applies.

Every member listed can take a booking for this service at each listed branch, subject to hours and existing bookings; the availability search is the next call.

A listed member need not have any free slot.

The whole set, not paginated.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| id | path | uuid | yes | The service id from the services list. A service that is not yours, is deleted, is inactive or is offered at no branch customers can see is 404 NOT_FOUND. Example: 00000000-0000-4000-8000-0000000000e2 |
| locationId | query | uuid | no | Only the members who can perform the service at this branch. A branch that is not one of your visible branches offering this service is refused with 400 INVALID_LOCATION; a value that is not a UUID with 400 VALIDATION_FAILED. Example: 00000000-0000-4000-8000-000000000c01 |

Request: Who can perform a service, and where

```bash
curl "https://pro.pamprr.me/api/v1/services/00000000-0000-4000-8000-0000000000e2/eligible-staff" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/services/00000000-0000-4000-8000-0000000000e2/eligible-staff",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const result = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/services/00000000-0000-4000-8000-0000000000e2/eligible-staff",
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
result = response.json()
```

Response 200 (eligible): Every bookable member assigned to the service, with the branches each can perform it at Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "serviceId": "00000000-0000-4000-8000-0000000000e2",
  "serviceName": "Signature Facial",
  "locationId": null,
  "staff": [
    {
      "id": "00000000-0000-4000-8000-0000000000a1",
      "name": "Maya Okafor",
      "initials": "MO",
      "colour": "#6B8E4E",
      "jobTitle": "Senior therapist",
      "profileImageUrl": "https://example.test/images/maya.jpg",
      "isBookable": true,
      "acceptsOnlineBookings": true,
      "deliveryMode": "both",
      "locationIds": [
        "00000000-0000-4000-8000-000000000c01",
        "00000000-0000-4000-8000-000000000c02"
      ]
    },
    {
      "id": "00000000-0000-4000-8000-0000000000a2",
      "name": "Theo Brandt",
      "initials": "TB",
      "colour": "#C58B3F",
      "jobTitle": "Facialist",
      "profileImageUrl": null,
      "isBookable": true,
      "acceptsOnlineBookings": false,
      "deliveryMode": "fixed",
      "locationIds": [
        "00000000-0000-4000-8000-000000000c01"
      ]
    }
  ]
}
```

Errors: 400 INVALID_LOCATION, 400 VALIDATION_FAILED, 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

## Availability

The bookable slots, computed live from the business's own calendar rules.

### The AvailabilityDay object

The date mode: one day of slots.

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| date | date | yes |  |
| timezone | string | yes |  |
| serviceId | uuid | yes |  |
| slotMinutes | integer | yes | The step between candidate starts (15). |
| durationMinutes | integer | yes | The service's duration. |
| slots | array of AvailabilitySlot | yes |  |
| message | string or null | yes | Why there are no slots when there are none: the business is closed that day, or no staff member can take the service in the requested mode. |

### The AvailabilitySlot object

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| startTime | date-time | yes | The slot's start, UTC. |
| endTime | date-time | yes | The start plus the service's duration: the span a booking at this slot will occupy. Buffers between appointments are applied by the search and never appear here. |
| localTime | string | yes | The start as the business sees it, HH:MM in the response's timezone. |
| staff | array of objects (id, name, locationId) | yes | Who can take this slot, and the branch each would take it at. |

### The AvailabilitySummary object

The summary mode: a status per day over the window.

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| from | date | yes |  |
| timezone | string | yes |  |
| serviceId | uuid | yes |  |
| days | array of objects (date, status) | yes |  |
| message | string or null | yes |  |

### The AvailabilityWindow object

The slot window mode (detail=slots): the slots per day over up to seven days.

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| from | date | yes |  |
| timezone | string | yes |  |
| serviceId | uuid | yes |  |
| slotMinutes | integer | yes |  |
| durationMinutes | integer | yes |  |
| days | array of objects (date, status, slots) | yes |  |

### GET /api/v1/availability

Scope: availability:read.

The bookable slots for a service, computed live from the same rules the business's own online booking calendar uses.

Those rules, applied at the moment of the request: opening hours and closures, each staff member's schedule, existing bookings, customers mid checkout (their held slot reads as taken and frees itself within ten minutes if they abandon checkout), time blocks and travel time.

Three modes: date for one day of slots; from with days (1 to 31) for a per day status; from with days (1 to 7) and detail=slots for the slots per day.

Slots start on fifteen minute boundaries from the branch's opening time; today's slots start at least thirty minutes ahead.

The answer is advisory: a booking or a checkout after it can take a slot, and the create booking call is the truth (409 BOOKING_OVERLAP when the slot is gone, 409 SLOT_HELD while a customer holds it).

Every time is UTC; every business runs on Europe/London today and the response names the zone.

Never cached.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| serviceId | query | uuid | yes | The service. Must be active and offered at a branch customers can see; otherwise 404 NOT_FOUND. Example: 00000000-0000-4000-8000-0000000000e2 |
| date | query | date | no | One day of slots (YYYY-MM-DD). Send date or from, never both. Example: 2026-10-06 |
| from | query | date | no | The first day of a window (YYYY-MM-DD). Example: 2026-10-05 |
| days | query | integer (1 to 31) | no | With from: the window's length. 1 to 31 for the summary (default 14); 1 to 7 with detail=slots (default 7). Example: 7 |
| detail | query | summary \| slots | no | With from: summary (a status per day, the default) or slots (the slots per day, days capped at 7). Example: slots |
| staffProfileId | query | uuid | no | Only this staff member's slots. Must be one of your staff; otherwise 404 NOT_FOUND. Example: 00000000-0000-4000-8000-0000000000a2 |
| locationId | query | uuid | no | Only this branch's slots. Must be a branch customers can see that offers the service; otherwise 400 INVALID_LOCATION. Example: 00000000-0000-4000-8000-000000000c01 |
| deliveryMode | query | fixed \| mobile | no | Restrict to staff who work at the branch (fixed) or travel to the customer (mobile); absent means either. Mobile applies the business's travel buffer between visits. Example: fixed |

Request: One day of slots

```bash
curl "https://pro.pamprr.me/api/v1/availability?serviceId=00000000-0000-4000-8000-0000000000e2&date=2026-10-06" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/availability?serviceId=00000000-0000-4000-8000-0000000000e2&date=2026-10-06",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const result = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/availability",
    params={
        "serviceId": "00000000-0000-4000-8000-0000000000e2",
        "date": "2026-10-06",
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
result = response.json()
```

Request: A status per day

```bash
curl "https://pro.pamprr.me/api/v1/availability?serviceId=00000000-0000-4000-8000-0000000000e2&from=2026-10-05&days=7" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/availability?serviceId=00000000-0000-4000-8000-0000000000e2&from=2026-10-05&days=7",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const result = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/availability",
    params={
        "serviceId": "00000000-0000-4000-8000-0000000000e2",
        "from": "2026-10-05",
        "days": 7,
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
result = response.json()
```

Request: The slots per day for one therapist

```bash
curl "https://pro.pamprr.me/api/v1/availability?serviceId=00000000-0000-4000-8000-0000000000e2&from=2026-10-05&days=7&detail=slots&staffProfileId=00000000-0000-4000-8000-0000000000a2" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/availability?serviceId=00000000-0000-4000-8000-0000000000e2&from=2026-10-05&days=7&detail=slots&staffProfileId=00000000-0000-4000-8000-0000000000a2",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const result = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/availability",
    params={
        "serviceId": "00000000-0000-4000-8000-0000000000e2",
        "from": "2026-10-05",
        "days": 7,
        "detail": "slots",
        "staffProfileId": "00000000-0000-4000-8000-0000000000a2",
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
result = response.json()
```

Response 200 (day): date: one day of slots Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "date": "2026-10-06",
  "timezone": "Europe/London",
  "serviceId": "00000000-0000-4000-8000-0000000000e2",
  "slotMinutes": 15,
  "durationMinutes": 60,
  "slots": [
    {
      "startTime": "2026-10-06T09:15:00.000Z",
      "endTime": "2026-10-06T10:15:00.000Z",
      "localTime": "10:15",
      "staff": [
        {
          "id": "00000000-0000-4000-8000-0000000000a1",
          "name": "Maya Okafor",
          "locationId": "00000000-0000-4000-8000-000000000c01"
        }
      ]
    },
    {
      "startTime": "2026-10-06T13:30:00.000Z",
      "endTime": "2026-10-06T14:30:00.000Z",
      "localTime": "14:30",
      "staff": [
        {
          "id": "00000000-0000-4000-8000-0000000000a1",
          "name": "Maya Okafor",
          "locationId": "00000000-0000-4000-8000-000000000c02"
        },
        {
          "id": "00000000-0000-4000-8000-0000000000a2",
          "name": "Theo Brandt",
          "locationId": "00000000-0000-4000-8000-000000000c01"
        }
      ]
    }
  ],
  "message": null
}
```

Response 200 (summary): from and days: a status per day Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "from": "2026-10-05",
  "timezone": "Europe/London",
  "serviceId": "00000000-0000-4000-8000-0000000000e2",
  "days": [
    {
      "date": "2026-10-05",
      "status": "fully_booked"
    },
    {
      "date": "2026-10-06",
      "status": "available"
    },
    {
      "date": "2026-10-07",
      "status": "available"
    },
    {
      "date": "2026-10-08",
      "status": "available"
    },
    {
      "date": "2026-10-09",
      "status": "available"
    },
    {
      "date": "2026-10-10",
      "status": "available"
    },
    {
      "date": "2026-10-11",
      "status": "closed"
    }
  ],
  "message": null
}
```

Response 200 (window): from, days and detail=slots for one therapist: the slots per day Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "from": "2026-10-05",
  "timezone": "Europe/London",
  "serviceId": "00000000-0000-4000-8000-0000000000e2",
  "slotMinutes": 15,
  "durationMinutes": 60,
  "days": [
    {
      "date": "2026-10-05",
      "status": "fully_booked",
      "slots": []
    },
    {
      "date": "2026-10-06",
      "status": "available",
      "slots": [
        {
          "startTime": "2026-10-06T13:30:00.000Z",
          "endTime": "2026-10-06T14:30:00.000Z",
          "localTime": "14:30",
          "staff": [
            {
              "id": "00000000-0000-4000-8000-0000000000a2",
              "name": "Theo Brandt",
              "locationId": "00000000-0000-4000-8000-000000000c01"
            }
          ]
        }
      ]
    },
    {
      "date": "2026-10-07",
      "status": "available",
      "slots": [
        {
          "startTime": "2026-10-07T09:00:00.000Z",
          "endTime": "2026-10-07T10:00:00.000Z",
          "localTime": "10:00",
          "staff": [
            {
              "id": "00000000-0000-4000-8000-0000000000a2",
              "name": "Theo Brandt",
              "locationId": "00000000-0000-4000-8000-000000000c01"
            }
          ]
        }
      ]
    },
    {
      "date": "2026-10-08",
      "status": "fully_booked",
      "slots": []
    },
    {
      "date": "2026-10-09",
      "status": "available",
      "slots": [
        {
          "startTime": "2026-10-09T10:15:00.000Z",
          "endTime": "2026-10-09T11:15:00.000Z",
          "localTime": "11:15",
          "staff": [
            {
              "id": "00000000-0000-4000-8000-0000000000a2",
              "name": "Theo Brandt",
              "locationId": "00000000-0000-4000-8000-000000000c01"
            }
          ]
        }
      ]
    },
    {
      "date": "2026-10-10",
      "status": "fully_booked",
      "slots": []
    },
    {
      "date": "2026-10-11",
      "status": "closed",
      "slots": []
    }
  ]
}
```

Errors: 400 INVALID_LOCATION, 400 VALIDATION_FAILED, 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

## Bookings

The diary: list, create, read, cancel and reschedule.

### The Booking object

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| id | uuid | yes |  |
| customerName | string | yes |  |
| customerEmail | string or null | yes |  |
| customerPhone | string or null | yes |  |
| serviceName | string | yes |  |
| serviceDuration | integer | yes |  |
| staffName | string | yes |  |
| status | pending \| confirmed \| in_progress \| completed \| cancelled \| no_show | yes |  |
| source | online \| assisted \| walk_in \| phone \| api | yes | How the booking was made; api for a booking created through this API (the dashboard shows it as Partner). |
| paymentStatus | unpaid \| deposit \| paid | yes |  |
| startTime | date-time | yes |  |
| endTime | date-time | yes |  |
| priceInPence | integer or null | yes |  |
| notes | string or null | yes |  |
| createdAt | date-time | yes |  |
| serviceId | uuid or null | yes | The service booked; null once that service is deleted (the serviceName snapshot remains). |
| staffProfileId | uuid or null | yes | The staff member; null once unassigned (the staffName snapshot remains). |
| locationId | uuid or null | yes | The branch; null on bookings made before branches were recorded. |
| updatedAt | date-time or null | yes | When the booking last changed; null until it first changes after creation. The updatedSince filter reads this, else createdAt. |
| cancelledAt | date-time or null | yes | When the booking was cancelled; null unless cancelled. |

### GET /api/v1/bookings

Scope: bookings:read.

The bookings in a start time window, oldest first.

With no status filter every status but cancelled is returned; ask for status=cancelled or status=all to see cancellations.

To keep a copy in step, poll with updatedSince set to your last poll time, status=all and a wide window: every booking created, changed or cancelled since then comes back.

Each row is the Booking projection; the operator's private notes are never included.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| startDate | query | date | no | Inclusive lower bound on start_time. Defaults to 30 days ago. Example: 2026-10-05 |
| endDate | query | date | no | Inclusive upper bound on start_time. Defaults to 90 days from now. Example: 2026-10-11 |
| status | query | pending \| confirmed \| in_progress \| completed \| cancelled \| no_show \| all | no | Only this status; all for every status. Absent: every status but cancelled. Any other word is refused with 400 VALIDATION_FAILED. Example: all |
| updatedSince | query | date-time | no | Only bookings whose last change (updatedAt, else createdAt) is at or after this instant, inside the window. The order stays start time. A value that is not an ISO 8601 instant is refused with 400 VALIDATION_FAILED. Example: 2026-10-06T08:00:00Z |
| staffProfileId | query | uuid | no | Only this staff member's bookings. A value that is not a UUID is refused with 400 VALIDATION_FAILED. Example: 00000000-0000-4000-8000-0000000000a1 |
| locationId | query | uuid | no | Only this branch's bookings. A value that is not a UUID is refused with 400 VALIDATION_FAILED. Example: 00000000-0000-4000-8000-000000000c01 |
| limit | query | integer (1 to 100, default 50) | no | Rows per page, 1 to 100 (a value above 100 is treated as 100). Example: 50 |
| cursor | query | string | no | Opaque keyset cursor from the previous page's nextCursor. Store and pass it back unchanged with the same filters; absent on the first page. A cursor never expires; one from another list, or from different filters, is refused with 400 INVALID_CURSOR. Example: eyJ2IjoyLCJsIjoiYm9va2luZ3MiLCJrIjpbIjIwMjYtMTAtMDZUMDk6MTU6MDAuMDAwWiIsIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBiMiJdLCJmIjoiNWQ0MTQwMmEifQ |

Request: A date window

```bash
curl "https://pro.pamprr.me/api/v1/bookings?startDate=2026-10-05&endDate=2026-10-11&limit=50" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/bookings?startDate=2026-10-05&endDate=2026-10-11&limit=50",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { bookings, nextCursor } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/bookings",
    params={
        "startDate": "2026-10-05",
        "endDate": "2026-10-11",
        "limit": 50,
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
data = response.json()
bookings = data["bookings"]
```

Request: A sync poll: everything changed since the last poll

```bash
curl "https://pro.pamprr.me/api/v1/bookings?startDate=2026-10-05&endDate=2026-10-11&status=all&updatedSince=2026-10-06T08%3A00%3A00Z" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/bookings?startDate=2026-10-05&endDate=2026-10-11&status=all&updatedSince=2026-10-06T08%3A00%3A00Z",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { bookings, nextCursor } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/bookings",
    params={
        "startDate": "2026-10-05",
        "endDate": "2026-10-11",
        "status": "all",
        "updatedSince": "2026-10-06T08:00:00Z",
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
data = response.json()
bookings = data["bookings"]
```

Request: The next page, the same filters and the cursor (The cursor shown is the document's example; pass back the nextCursor you were given, with the same filters.)

```bash
curl "https://pro.pamprr.me/api/v1/bookings?startDate=2026-10-05&endDate=2026-10-11&limit=50&cursor=eyJ2IjoyLCJsIjoiYm9va2luZ3MiLCJrIjpbIjIwMjYtMTAtMDZUMDk6MTU6MDAuMDAwWiIsIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBiMiJdLCJmIjoiNWQ0MTQwMmEifQ" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/bookings?startDate=2026-10-05&endDate=2026-10-11&limit=50&cursor=eyJ2IjoyLCJsIjoiYm9va2luZ3MiLCJrIjpbIjIwMjYtMTAtMDZUMDk6MTU6MDAuMDAwWiIsIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBiMiJdLCJmIjoiNWQ0MTQwMmEifQ",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { bookings, nextCursor } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/bookings",
    params={
        "startDate": "2026-10-05",
        "endDate": "2026-10-11",
        "limit": 50,
        "cursor": "eyJ2IjoyLCJsIjoiYm9va2luZ3MiLCJrIjpbIjIwMjYtMTAtMDZUMDk6MTU6MDAuMDAwWiIsIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBiMiJdLCJmIjoiNWQ0MTQwMmEifQ",
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
data = response.json()
bookings = data["bookings"]
```

Response 200 (window): Two bookings in the window, a second page to follow Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "bookings": [
    {
      "id": "00000000-0000-4000-8000-0000000000b1",
      "customerName": "Alex Example",
      "customerEmail": "alex@example.test",
      "customerPhone": "+44 7700 900123",
      "serviceName": "Signature Facial",
      "serviceDuration": 60,
      "staffName": "Maya Okafor",
      "status": "confirmed",
      "source": "online",
      "paymentStatus": "deposit",
      "startTime": "2026-10-06T09:15:00.000Z",
      "endTime": "2026-10-06T10:15:00.000Z",
      "priceInPence": 6500,
      "notes": null,
      "createdAt": "2026-09-20T14:02:11.000Z",
      "serviceId": "00000000-0000-4000-8000-0000000000e2",
      "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
      "locationId": "00000000-0000-4000-8000-000000000c01",
      "updatedAt": null,
      "cancelledAt": null
    },
    {
      "id": "00000000-0000-4000-8000-0000000000b2",
      "customerName": "Sam Example",
      "customerEmail": "sam@example.test",
      "customerPhone": null,
      "serviceName": "Consultation",
      "serviceDuration": 30,
      "staffName": "Theo Brandt",
      "status": "pending",
      "source": "phone",
      "paymentStatus": "unpaid",
      "startTime": "2026-10-06T13:00:00.000Z",
      "endTime": "2026-10-06T13:30:00.000Z",
      "priceInPence": 0,
      "notes": "First visit",
      "createdAt": "2026-09-28T09:41:00.000Z",
      "serviceId": "00000000-0000-4000-8000-0000000000e1",
      "staffProfileId": "00000000-0000-4000-8000-0000000000a2",
      "locationId": "00000000-0000-4000-8000-000000000c01",
      "updatedAt": "2026-09-29T16:10:00.000Z",
      "cancelledAt": null
    }
  ],
  "nextCursor": "eyJ2IjoyLCJsIjoiYm9va2luZ3MiLCJrIjpbIjIwMjYtMTAtMDZUMDk6MTU6MDAuMDAwWiIsIjAwMDAwMDAwLTAwMDAtNDAwMC04MDAwLTAwMDAwMDAwMDBiMiJdLCJmIjoiNWQ0MTQwMmEifQ"
}
```

Errors: 400 INVALID_CURSOR, 400 INVALID_DATE_RANGE, 400 VALIDATION_FAILED, 401 UNAUTHORIZED, 403 FORBIDDEN, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

### POST /api/v1/bookings

Scope: bookings:write. Idempotency-Key accepted.

Creates a booking on behalf of the calling business, confirmed and unpaid, recorded with the source api (the dashboard shows it as Partner).

When the service's effective booking protection includes a deposit (deposit-only or both) and the business has Stripe live, the request is refused with 402 DEPOSIT_REQUIRED; when it is card capture, with 402 CARD_CAPTURE_REQUIRED; those bookings must be made through the consumer booking flow, which takes the deposit or the card.

The slot must be free for the staff member: 409 BOOKING_OVERLAP when another booking has it, 409 SLOT_HELD while a customer is mid checkout for it (the hold frees itself within ten minutes if they abandon checkout).

When the API key notifies customers, the customer receives the confirmation email with a calendar attachment if the booking carries an email, and the confirmation SMS if it carries a phone, subject to the business's own SMS settings.

Send an Idempotency-Key so a retried request replays the first outcome instead of creating a second booking.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| Idempotency-Key | header | string (1 to 255 characters) | no | Optional but recommended: any string of 1 to 255 printable ASCII characters, a UUID v4 by preference, unique per request. For 24 hours the same key with the same request replays the first response (2xx, 4xx or 5xx alike) with Idempotent-Replayed: true; the same key with a different request is refused with 422 IDEMPOTENCY_KEY_REUSED; a retry while the first request is still running is refused with 409 IDEMPOTENCY_REQUEST_IN_PROGRESS; a malformed key is refused with 400 IDEMPOTENCY_KEY_INVALID. Keys are scoped to your business, so a rotated API key still replays. Requests are processed once, with one qualification: a retry more than sixty seconds after a request that never completed may execute afresh, so in the rare event of a timeout retry with the same key promptly. Example: 0b3f6c2e-8f0a-4d5b-9c1e-7a2b3c4d5e6f |
| customerName | body | string | yes |  |
| customerEmail | body | string or null | no |  |
| customerPhone | body | string or null | no |  |
| serviceId | body | uuid | yes |  |
| staffProfileId | body | uuid | yes |  |
| startTime | body | date-time | yes |  |
| notes | body | string or null | no |  |
| locationId | body | uuid | no | Optional branch id. Validated when supplied (the branch must offer the service, be live and visible, and the staff member must belong to it; failures return 400 INVALID_LOCATION). When omitted the booking resolves to the single offering branch, else the staff member's home branch, else the primary location. |

Request: Create a booking (curl shows the document's example Idempotency-Key; send a fresh UUID with every new request, as the other two samples do.)

```bash
curl -X POST "https://pro.pamprr.me/api/v1/bookings" \
  -H "Authorization: Bearer $PAMPRR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 0b3f6c2e-8f0a-4d5b-9c1e-7a2b3c4d5e6f" \
  -d '{"customerName":"Alex Example","customerEmail":"alex@example.test","customerPhone":"+44 7700 900123","serviceId":"00000000-0000-4000-8000-0000000000e2","staffProfileId":"00000000-0000-4000-8000-0000000000a1","startTime":"2026-10-06T13:30:00.000Z","locationId":"00000000-0000-4000-8000-000000000c02","notes":"Booked by the hotel concierge"}'
```

```javascript
const response = await fetch("https://pro.pamprr.me/api/v1/bookings", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAMPRR_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "customerName": "Alex Example",
    "customerEmail": "alex@example.test",
    "customerPhone": "+44 7700 900123",
    "serviceId": "00000000-0000-4000-8000-0000000000e2",
    "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
    "startTime": "2026-10-06T13:30:00.000Z",
    "locationId": "00000000-0000-4000-8000-000000000c02",
    "notes": "Booked by the hotel concierge"
  }),
});
const { booking } = await response.json();
```

```python
import os, uuid, requests

response = requests.post(
    "https://pro.pamprr.me/api/v1/bookings",
    headers={
        "Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "customerName": "Alex Example",
        "customerEmail": "alex@example.test",
        "customerPhone": "+44 7700 900123",
        "serviceId": "00000000-0000-4000-8000-0000000000e2",
        "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
        "startTime": "2026-10-06T13:30:00.000Z",
        "locationId": "00000000-0000-4000-8000-000000000c02",
        "notes": "Booked by the hotel concierge",
    },
)
booking = response.json()["booking"]
```

Response 201 (created): The booking as created: confirmed, unpaid, source api Headers: X-Request-Id, Idempotent-Replayed, X-Original-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "booking": {
    "id": "00000000-0000-4000-8000-0000000000b3",
    "customerName": "Alex Example",
    "customerEmail": "alex@example.test",
    "customerPhone": "+44 7700 900123",
    "serviceName": "Signature Facial",
    "serviceDuration": 60,
    "staffName": "Maya Okafor",
    "status": "confirmed",
    "source": "api",
    "paymentStatus": "unpaid",
    "startTime": "2026-10-06T13:30:00.000Z",
    "endTime": "2026-10-06T14:30:00.000Z",
    "priceInPence": 6500,
    "notes": "Booked by the hotel concierge",
    "createdAt": "2026-10-06T08:31:12.000Z",
    "serviceId": "00000000-0000-4000-8000-0000000000e2",
    "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
    "locationId": "00000000-0000-4000-8000-000000000c02",
    "updatedAt": null,
    "cancelledAt": null
  }
}
```

Response 201 (replayed): The same body on a retry with the same Idempotency-Key; the response carries Idempotent-Replayed: true and X-Original-Request-Id req_9a8b7c6d5e4f3a2b1c0d9e8f Headers: X-Request-Id, Idempotent-Replayed, X-Original-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "booking": {
    "id": "00000000-0000-4000-8000-0000000000b3",
    "customerName": "Alex Example",
    "customerEmail": "alex@example.test",
    "customerPhone": "+44 7700 900123",
    "serviceName": "Signature Facial",
    "serviceDuration": 60,
    "staffName": "Maya Okafor",
    "status": "confirmed",
    "source": "api",
    "paymentStatus": "unpaid",
    "startTime": "2026-10-06T13:30:00.000Z",
    "endTime": "2026-10-06T14:30:00.000Z",
    "priceInPence": 6500,
    "notes": "Booked by the hotel concierge",
    "createdAt": "2026-10-06T08:31:12.000Z",
    "serviceId": "00000000-0000-4000-8000-0000000000e2",
    "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
    "locationId": "00000000-0000-4000-8000-000000000c02",
    "updatedAt": null,
    "cancelledAt": null
  }
}
```

Errors: 400 IDEMPOTENCY_KEY_INVALID, 400 INVALID_JSON, 400 INVALID_LOCATION, 400 INVALID_START_TIME, 400 PAST_START_TIME, 400 VALIDATION_FAILED, 401 UNAUTHORIZED, 402 CARD_CAPTURE_REQUIRED, 402 DEPOSIT_REQUIRED, 403 FORBIDDEN, 404 NOT_FOUND, 409 BOOKING_OVERLAP, 409 IDEMPOTENCY_REQUEST_IN_PROGRESS, 409 SLOT_HELD, 422 IDEMPOTENCY_KEY_REUSED, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

### GET /api/v1/bookings/{id}

Scope: bookings:read.

One booking by id, the Booking projection: the same fields as a list row.

A cancelled booking is returned (you asked for it by id).

A booking that is not yours, or an id that is not a UUID, is 404 NOT_FOUND.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| id | path | uuid | yes | The booking id. Example: 00000000-0000-4000-8000-0000000000b3 |

Request: One booking by id

```bash
curl "https://pro.pamprr.me/api/v1/bookings/00000000-0000-4000-8000-0000000000b3" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/bookings/00000000-0000-4000-8000-0000000000b3",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { booking } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/bookings/00000000-0000-4000-8000-0000000000b3",
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
booking = response.json()["booking"]
```

Response 200 (booking): One booking by id, the same projection as a list row Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "booking": {
    "id": "00000000-0000-4000-8000-0000000000b3",
    "customerName": "Alex Example",
    "customerEmail": "alex@example.test",
    "customerPhone": "+44 7700 900123",
    "serviceName": "Signature Facial",
    "serviceDuration": 60,
    "staffName": "Maya Okafor",
    "status": "confirmed",
    "source": "api",
    "paymentStatus": "unpaid",
    "startTime": "2026-10-06T13:30:00.000Z",
    "endTime": "2026-10-06T14:30:00.000Z",
    "priceInPence": 6500,
    "notes": "Booked by the hotel concierge",
    "createdAt": "2026-10-06T08:31:12.000Z",
    "serviceId": "00000000-0000-4000-8000-0000000000e2",
    "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
    "locationId": "00000000-0000-4000-8000-000000000c02",
    "updatedAt": null,
    "cancelledAt": null
  }
}
```

Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

### POST /api/v1/bookings/{id}/cancel

Scope: bookings:write. Idempotency-Key accepted.

Cancels a booking the way the business cancels one in its dashboard.

The booking is marked cancelled with an optional reason, any unpaid checkout link for it is closed, anyone on the waiting list for the slot is notified, and the customer is told through the pamprr app if they booked with it and the API key notifies customers.

Cancelling through the API does not refund a deposit or charge a late cancellation fee; refunds are made by the business in its dashboard.

A booking that is already cancelled is returned as it stands with alreadyCancelled set, and nothing fires again.

Send an Idempotency-Key so a retried request replays the first outcome.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| id | path | uuid | yes | The booking id. Example: 00000000-0000-4000-8000-0000000000b3 |
| Idempotency-Key | header | string (1 to 255 characters) | no | Optional but recommended: the same rules as on create booking. For 24 hours the same key with the same request replays the first response; the same key against another booking is refused with 422 IDEMPOTENCY_KEY_REUSED. Example: 4e7d1c2b-8a9f-4b3e-a1c5-6d7e8f9a0b1c |
| reason | body | string or null | no | Free text recorded on the booking and its event, up to 500 characters. |

Request: Cancel a booking with a reason (curl shows the document's example Idempotency-Key; send a fresh UUID with every new request, as the other two samples do.)

```bash
curl -X POST "https://pro.pamprr.me/api/v1/bookings/00000000-0000-4000-8000-0000000000b3/cancel" \
  -H "Authorization: Bearer $PAMPRR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 4e7d1c2b-8a9f-4b3e-a1c5-6d7e8f9a0b1c" \
  -d '{"reason":"Guest checked out early"}'
```

```javascript
const response = await fetch("https://pro.pamprr.me/api/v1/bookings/00000000-0000-4000-8000-0000000000b3/cancel", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAMPRR_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "reason": "Guest checked out early"
  }),
});
const { booking, alreadyCancelled } = await response.json();
```

```python
import os, uuid, requests

response = requests.post(
    "https://pro.pamprr.me/api/v1/bookings/00000000-0000-4000-8000-0000000000b3/cancel",
    headers={
        "Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "reason": "Guest checked out early",
    },
)
data = response.json()
booking = data["booking"]
```

Response 200 (cancelled): The booking cancelled by this call Headers: X-Request-Id, Idempotent-Replayed, X-Original-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "booking": {
    "id": "00000000-0000-4000-8000-0000000000b3",
    "customerName": "Alex Example",
    "customerEmail": "alex@example.test",
    "customerPhone": "+44 7700 900123",
    "serviceName": "Signature Facial",
    "serviceDuration": 60,
    "staffName": "Maya Okafor",
    "status": "cancelled",
    "source": "api",
    "paymentStatus": "unpaid",
    "startTime": "2026-10-07T09:15:00.000Z",
    "endTime": "2026-10-07T10:15:00.000Z",
    "priceInPence": 6500,
    "notes": "Booked by the hotel concierge",
    "createdAt": "2026-10-06T08:31:12.000Z",
    "serviceId": "00000000-0000-4000-8000-0000000000e2",
    "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
    "locationId": "00000000-0000-4000-8000-000000000c02",
    "updatedAt": "2026-10-06T08:45:30.000Z",
    "cancelledAt": "2026-10-06T08:45:30.000Z"
  },
  "alreadyCancelled": false
}
```

Response 200 (alreadyCancelled): The booking was cancelled before this call; nothing fired Headers: X-Request-Id, Idempotent-Replayed, X-Original-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "booking": {
    "id": "00000000-0000-4000-8000-0000000000b3",
    "customerName": "Alex Example",
    "customerEmail": "alex@example.test",
    "customerPhone": "+44 7700 900123",
    "serviceName": "Signature Facial",
    "serviceDuration": 60,
    "staffName": "Maya Okafor",
    "status": "cancelled",
    "source": "api",
    "paymentStatus": "unpaid",
    "startTime": "2026-10-07T09:15:00.000Z",
    "endTime": "2026-10-07T10:15:00.000Z",
    "priceInPence": 6500,
    "notes": "Booked by the hotel concierge",
    "createdAt": "2026-10-06T08:31:12.000Z",
    "serviceId": "00000000-0000-4000-8000-0000000000e2",
    "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
    "locationId": "00000000-0000-4000-8000-000000000c02",
    "updatedAt": "2026-10-06T08:45:30.000Z",
    "cancelledAt": "2026-10-06T08:45:30.000Z"
  },
  "alreadyCancelled": true
}
```

Errors: 400 IDEMPOTENCY_KEY_INVALID, 400 INVALID_JSON, 400 VALIDATION_FAILED, 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 409 BOOKING_COMPLETED, 409 BOOKING_NO_SHOW, 409 IDEMPOTENCY_REQUEST_IN_PROGRESS, 422 IDEMPOTENCY_KEY_REUSED, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

### POST /api/v1/bookings/{id}/reschedule

Scope: bookings:write. Idempotency-Key accepted.

Moves a booking to a new start time, and optionally to another staff member, the way the business does in its dashboard.

The end time follows from the booking's own duration; the customer's reminders are reset for the new time; the customer is told through the pamprr app if they booked with it and the API key notifies customers.

The new time must be free for the staff member: 409 BOOKING_OVERLAP when another booking has it, 409 SLOT_HELD while a customer is mid checkout for it.

The API does not check the business's opening hours or the staff member's schedule for a reschedule, so check availability first.

A new staff member must be one of your staff (404 otherwise) and must work at the booking's branch (400 INVALID_LOCATION otherwise).

Send an Idempotency-Key so a retried request replays the first outcome.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| id | path | uuid | yes | The booking id. Example: 00000000-0000-4000-8000-0000000000b3 |
| Idempotency-Key | header | string (1 to 255 characters) | no | Optional but recommended: the same rules as on create booking. For 24 hours the same key with the same request replays the first response; the same key against another booking or another time is refused with 422 IDEMPOTENCY_KEY_REUSED. Example: 9c2a5e71-4d0b-4f8e-9a6c-2b1d3e4f5a6b |
| startTime | body | date-time | yes | The new start, in the future. The end follows from the booking's duration. |
| staffProfileId | body | uuid | no | Optional: move the booking to this staff member. One of your staff who works at the booking's branch; the staff name on the booking follows from the profile. |

Request: Move a booking (curl shows the document's example Idempotency-Key; send a fresh UUID with every new request, as the other two samples do.)

```bash
curl -X POST "https://pro.pamprr.me/api/v1/bookings/00000000-0000-4000-8000-0000000000b3/reschedule" \
  -H "Authorization: Bearer $PAMPRR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9c2a5e71-4d0b-4f8e-9a6c-2b1d3e4f5a6b" \
  -d '{"startTime":"2026-10-07T09:15:00.000Z","staffProfileId":"00000000-0000-4000-8000-0000000000a1"}'
```

```javascript
const response = await fetch("https://pro.pamprr.me/api/v1/bookings/00000000-0000-4000-8000-0000000000b3/reschedule", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAMPRR_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "startTime": "2026-10-07T09:15:00.000Z",
    "staffProfileId": "00000000-0000-4000-8000-0000000000a1"
  }),
});
const { booking } = await response.json();
```

```python
import os, uuid, requests

response = requests.post(
    "https://pro.pamprr.me/api/v1/bookings/00000000-0000-4000-8000-0000000000b3/reschedule",
    headers={
        "Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "startTime": "2026-10-07T09:15:00.000Z",
        "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
    },
)
booking = response.json()["booking"]
```

Response 200 (moved): The booking at its new time; updatedAt set Headers: X-Request-Id, Idempotent-Replayed, X-Original-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "booking": {
    "id": "00000000-0000-4000-8000-0000000000b3",
    "customerName": "Alex Example",
    "customerEmail": "alex@example.test",
    "customerPhone": "+44 7700 900123",
    "serviceName": "Signature Facial",
    "serviceDuration": 60,
    "staffName": "Maya Okafor",
    "status": "confirmed",
    "source": "api",
    "paymentStatus": "unpaid",
    "startTime": "2026-10-07T09:15:00.000Z",
    "endTime": "2026-10-07T10:15:00.000Z",
    "priceInPence": 6500,
    "notes": "Booked by the hotel concierge",
    "createdAt": "2026-10-06T08:31:12.000Z",
    "serviceId": "00000000-0000-4000-8000-0000000000e2",
    "staffProfileId": "00000000-0000-4000-8000-0000000000a1",
    "locationId": "00000000-0000-4000-8000-000000000c02",
    "updatedAt": "2026-10-06T08:40:02.000Z",
    "cancelledAt": null
  }
}
```

Errors: 400 IDEMPOTENCY_KEY_INVALID, 400 INVALID_JSON, 400 INVALID_LOCATION, 400 INVALID_START_TIME, 400 PAST_START_TIME, 400 VALIDATION_FAILED, 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 409 BOOKING_CANCELLED, 409 BOOKING_COMPLETED, 409 BOOKING_NO_SHOW, 409 BOOKING_OVERLAP, 409 IDEMPOTENCY_REQUEST_IN_PROGRESS, 409 SLOT_HELD, 422 IDEMPOTENCY_KEY_REUSED, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

## Clients

The client book: list, create and read.

### The Client object

| Field | Type | Always | Meaning |
| --- | --- | --- | --- |
| id | uuid | yes |  |
| name | string | yes |  |
| email | string or null | yes |  |
| phone | string or null | yes |  |
| createdAt | date-time | yes |  |
| updatedAt | date-time or null | yes | When the client record last changed; null until it first changes after creation. |

### GET /api/v1/clients

Scope: clients:read.

The business's active clients by name.

With search, only those whose name, email or phone contains the text (case insensitive, the text taken literally).

Each row is the Client projection; the operator's private notes are never included.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| search | query | string (2 to 100 characters) | no | Only clients whose name, email or phone contains this text, case insensitive; the text is taken literally. Two to one hundred characters, otherwise 400 VALIDATION_FAILED. Example: example |
| limit | query | integer (1 to 100, default 50) | no | Rows per page, 1 to 100 (a value above 100 is treated as 100). Example: 50 |
| cursor | query | string | no | Opaque keyset cursor from the previous page's nextCursor. Store and pass it back unchanged with the same filters; absent on the first page. A cursor never expires; one from another list, or from different filters, is refused with 400 INVALID_CURSOR. Example: eyJ2IjoyLCJsIjoiY2xpZW50cyIsImsiOlsiU2FtIEV4YW1wbGUiLCIwMDAwMDAwMC0wMDAwLTQwMDAtODAwMC0wMDAwMDAwMDAwYzIiXSwiZiI6ImUzYjBjNDQyIn0 |

Request: Search the client book

```bash
curl "https://pro.pamprr.me/api/v1/clients?search=example&limit=50" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/clients?search=example&limit=50",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { clients, nextCursor } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/clients",
    params={
        "search": "example",
        "limit": 50,
    },
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
data = response.json()
clients = data["clients"]
```

Response 200 (byName): The active clients matching the search, in name order Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "clients": [
    {
      "id": "00000000-0000-4000-8000-0000000000c1",
      "name": "Alex Example",
      "email": "alex@example.test",
      "phone": "+44 7700 900123",
      "createdAt": "2026-04-11T10:00:00.000Z",
      "updatedAt": null
    },
    {
      "id": "00000000-0000-4000-8000-0000000000c2",
      "name": "Sam Example",
      "email": "sam@example.test",
      "phone": null,
      "createdAt": "2026-09-28T09:40:00.000Z",
      "updatedAt": "2026-09-29T16:10:00.000Z"
    }
  ],
  "nextCursor": null
}
```

Errors: 400 INVALID_CURSOR, 400 VALIDATION_FAILED, 401 UNAUTHORIZED, 403 FORBIDDEN, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

### POST /api/v1/clients

Scope: clients:write. Idempotency-Key accepted.

Creates a client record for the business, or returns the existing one.

When an active client already has the given email (compared case insensitively) it is returned with existing set instead of a duplicate being created, and when only an archived client has it that record is restored and returned the same way, so the client id stays stable.

The email is stored lower cased.

Private notes cannot be set through the API.

Send an Idempotency-Key so a retried request replays the first outcome.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| Idempotency-Key | header | string (1 to 255 characters) | no | Optional but recommended: the same rules as on create booking. For 24 hours the same key with the same request replays the first response (the 201 or the 200); the same key with a different request is refused with 422 IDEMPOTENCY_KEY_REUSED. Example: 7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e |
| name | body | string (1 to 200 characters) | yes |  |
| email | body | string or null | no | Stored lower cased; the duplicate check compares it case insensitively. |
| phone | body | string or null | no |  |

Request: Create a client (curl shows the document's example Idempotency-Key; send a fresh UUID with every new request, as the other two samples do.)

```bash
curl -X POST "https://pro.pamprr.me/api/v1/clients" \
  -H "Authorization: Bearer $PAMPRR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7b8c9d0e-1f2a-4b3c-8d4e-5f6a7b8c9d0e" \
  -d '{"name":"Robin Example","email":"Robin@Example.test","phone":"+44 7700 900456"}'
```

```javascript
const response = await fetch("https://pro.pamprr.me/api/v1/clients", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAMPRR_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    "name": "Robin Example",
    "email": "Robin@Example.test",
    "phone": "+44 7700 900456"
  }),
});
const { client, existing } = await response.json();
```

```python
import os, uuid, requests

response = requests.post(
    "https://pro.pamprr.me/api/v1/clients",
    headers={
        "Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "name": "Robin Example",
        "email": "Robin@Example.test",
        "phone": "+44 7700 900456",
    },
)
data = response.json()
client = data["client"]
```

Response 200 (existing): An active client already had that email (or an archived one was restored) Headers: X-Request-Id, Idempotent-Replayed, X-Original-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "client": {
    "id": "00000000-0000-4000-8000-0000000000c1",
    "name": "Alex Example",
    "email": "alex@example.test",
    "phone": "+44 7700 900123",
    "createdAt": "2026-04-11T10:00:00.000Z",
    "updatedAt": null
  },
  "existing": true
}
```

Response 201 (created): A new client record Headers: X-Request-Id, Idempotent-Replayed, X-Original-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "client": {
    "id": "00000000-0000-4000-8000-0000000000c3",
    "name": "Robin Example",
    "email": "robin@example.test",
    "phone": "+44 7700 900456",
    "createdAt": "2026-10-06T08:50:00.000Z",
    "updatedAt": null
  }
}
```

Errors: 400 IDEMPOTENCY_KEY_INVALID, 400 INVALID_JSON, 400 VALIDATION_FAILED, 401 UNAUTHORIZED, 403 FORBIDDEN, 409 IDEMPOTENCY_REQUEST_IN_PROGRESS, 422 IDEMPOTENCY_KEY_REUSED, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

### GET /api/v1/clients/{id}

Scope: clients:read.

One client by id, the Client projection: the same fields as a list row.

An archived client, a client that is not yours, or an id that is not a UUID is 404 NOT_FOUND.

| Name | Where | Type | Required | Meaning |
| --- | --- | --- | --- | --- |
| id | path | uuid | yes | The client id. Example: 00000000-0000-4000-8000-0000000000c1 |

Request: One client by id

```bash
curl "https://pro.pamprr.me/api/v1/clients/00000000-0000-4000-8000-0000000000c1" \
  -H "Authorization: Bearer $PAMPRR_API_KEY"
```

```javascript
const response = await fetch(
  "https://pro.pamprr.me/api/v1/clients/00000000-0000-4000-8000-0000000000c1",
  { headers: { Authorization: `Bearer ${process.env.PAMPRR_API_KEY}` } },
);
const { client } = await response.json();
```

```python
import os, requests

response = requests.get(
    "https://pro.pamprr.me/api/v1/clients/00000000-0000-4000-8000-0000000000c1",
    headers={"Authorization": f"Bearer {os.environ['PAMPRR_API_KEY']}"},
)
client = response.json()["client"]
```

Response 200 (client): One client by id Headers: X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset.

```json
{
  "client": {
    "id": "00000000-0000-4000-8000-0000000000c1",
    "name": "Alex Example",
    "email": "alex@example.test",
    "phone": "+44 7700 900123",
    "createdAt": "2026-04-11T10:00:00.000Z",
    "updatedAt": null
  }
}
```

Errors: 401 UNAUTHORIZED, 403 FORBIDDEN, 404 NOT_FOUND, 429 RATE_LIMIT_EXCEEDED, 500 INTERNAL_ERROR.

## Notifications and partner bookings

What a partner action tells the customer, and how the business sees it.

- A booking created through the API is recorded with the source api and shows as Partner in the business's diary.
- A key created with notifications on sends the customer the confirmation email and SMS on create, and the app notification on cancel and reschedule, subject to the business's own notification settings and to the customer having an email, a phone or the pamprr app. A key with notifications off sends nothing.
- Cancelling through the API never refunds a deposit or charges a late cancellation fee; refunds are made by the business in its dashboard. Anyone on the waiting list for a freed slot is notified either way.

## Versioning and deprecation

The version is the path prefix; changes are additive; a breaking change carries at least six months' notice.

Fields, headers, parameters, enum values and endpoints are added, never removed or renamed, without notice.

- A change that would break an integration (a removed field, a renamed header, a changed meaning) is announced in the changelog at least six months before it happens.
- The notice period is only ever extended, never shortened.
- The document's version tracks additive releases.

Host https://pro.pamprr.me; every path begins /api/v1. The document's version today: 1.1.0.

## Changelog

Dated entries, newest first.

### 1.1.0, 2026-09-11: The first published reference

- This page, its markdown twin at /developers.md, and the OpenAPI 3.1.1 document with an example on every parameter, request body and response.
- The surface at launch: the business record, locations, staff, services and the eligible staff for one, the availability search, bookings (list, create, read, cancel, reschedule) and clients (list, create, read), on the foundations of request ids, the rate limit headers, scoped keys, cursor pagination and idempotency keys.

## The OpenAPI document

The machine readable description of everything on this page, with the same examples.

- The OpenAPI 3.1.1 document: https://pro.pamprr.me/api/v1/openapi.json
- This page: https://pro.pamprr.me/developers

## Support

Quote the request id.

Every response carries X-Request-Id; quote it.

Write through the contact page, or through the business that minted your key, whose owner can reach pamprr from the dashboard's help centre. The contact page: https://www.pamprr.me/contact.

Keys are minted, edited, rotated and revoked in the business's Settings.
