> ## Knowledge Base Index
> Fetch the complete knowledge base index at: https://help.useapprentice.com/sitemap.xml
> Use this file to discover available pages before exploring further.
> Pure-Markdown content can be obtained by appending a '.md' suffix to the content URLs listed in the sitemap (without the trailing slash).

# Apprentice API Reference

## Overview

Apprentice is a booking and client management platform for tattoo, piercing and body art artists and shops. This page documents the API behind the Apprentice integration for Zapier. You do not need it to build Zaps. Use it to check what a step sends and receives, to debug an error, or to call the API yourself.

**Base URL**

```
https://app.useapprentice.com/api/integrations/zapier
```

**Version.** The API version is a date. This page describes `2026-02-01`. Each connection is pinned to the version it was created on.

**Data format.** Requests and responses are JSON. Times are ISO 8601 in UTC unless a field says otherwise.

**What a connection can see.** A connection is tied to one workspace. An artist connection can reach that artist's data. A shop connection can reach data for the shop's artists. Nothing else is returned.

**Sensitive data.** The API does not return waiver text, signatures, medical or allergy answers, guardian consent state, internal notes or pricing. Details are in [What the API does not return](#2-what-the-api-does-not-return).

### Endpoint index

Every path below is relative to the base URL. Send `Authorization: Bearer <access_token>` on every call. Send `Content-Type: application/json` when you include a JSON body. `{id}` means the UUID of the appointment, client or subscription named by the path.

These are all 19 operations called by the current Zapier integration, plus the two hook maintenance operations described here. OAuth authorization and token endpoints are documented in [Authentication](#2-authentication). Payment actions were removed in version 2.0.0 and are not part of this integration.

| Method | Path | Required scope | Request and response |
| --- | --- | --- | --- |
| GET | `/auth/test` | Valid token | [Connection test](#3-connection-test) |
| POST | `/hooks` | `hooks:write` | [Subscribe](#3-subscribe) |
| DELETE | `/hooks/{id}` | `hooks:write` | [Unsubscribe](#3-unsubscribe) |
| GET | `/hooks` | `hooks:write` | [List subscriptions](#3-list-subscriptions) |
| PATCH | `/hooks/{id}` | `hooks:write` | [Re-enable a subscription](#3-re-enable-a-subscription) |
| POST | `/appointments` | `appointments:write` | [Create Appointment](#3-create-appointment) |
| PATCH | `/appointments/{id}` | `appointments:write` | [Update Appointment](#3-update-appointment) |
| POST | `/appointments/{id}/confirm` | `appointments:write` | [Confirm Appointment](#3-confirm-appointment) |
| DELETE | `/appointments/{id}` | `appointments:write` | [Cancel Appointment](#3-cancel-appointment) |
| POST | `/appointments/{id}/notes` | `notes:write` | [Add Appointment Note](#3-add-appointment-note) |
| GET | `/appointments/search` | `appointments:read` | [Find Appointment](#3-find-appointment) |
| POST | `/clients` | `clients:write` | [Create Client](#3-create-client) |
| POST | `/clients/find-or-create` | `clients:write` | [Find or Create Client](#3-find-or-create-client) |
| PATCH | `/clients/{id}` | `clients:write` | [Update Client](#3-update-client) |
| POST | `/clients/{id}/notes` | `notes:write` | [Add Client Note](#3-add-client-note) |
| GET | `/clients/search` | `clients:read` | [Find Client](#3-find-client) |
| POST | `/notifications` | `notifications:write` | [Send Notification](#3-send-notification) |
| POST | `/booking-requests` | `booking_submissions:write` | [Create Booking Request](#3-create-booking-request) |
| POST | `/booking-submissions` | `booking_submissions:write` | [Book Appointment From Booking Page](#3-book-appointment-from-booking-page) |
| GET | `/locations` | `locations:read` | [Locations](#3-locations) |
| GET | `/booking-pages` | `booking_pages:read` | [Booking pages](#3-booking-pages) |

### Contents

1. [Authentication](#2-authentication)
2. [Shared behavior](#2-shared-behavior)
3. [Events (triggers)](#2-events-triggers)
4. [Actions](#2-actions)
5. [Searches and dropdowns](#2-searches-and-dropdowns)
6. [Services with extra rules](#2-services-with-extra-rules)
7. [Errors](#2-errors)
8. [What the API does not return](#2-what-the-api-does-not-return)
9. [Changelog](#2-changelog)

---

## Authentication

Apprentice uses OAuth 2.0 with the authorization code flow and PKCE. You do not enter an API key. Sign in once and approve access.

| Item | Value |
| --- | --- |
| Authorize URL | `https://qgrdfaeclgcyrgmafatm.supabase.co/auth/v1/oauth/authorize` |
| Token URL | `https://qgrdfaeclgcyrgmafatm.supabase.co/auth/v1/oauth/token` |
| Refresh URL | Same as the token URL |
| Grant types | `authorization_code`, `refresh_token` |
| PKCE | Yes. Method `S256`. |
| Client authentication | HTTP Basic header with the client ID and secret. Not in the body. |
| Token type | `Bearer` |

The Apprentice sign-in service is hosted on Supabase, which is why the URLs are on a `supabase.co` address. The API itself is on `app.useapprentice.com`.

### The flow

1. Send the user to the authorize URL with `client_id`, `response_type=code`, `redirect_uri`, `state`, `code_challenge` and `code_challenge_method=S256`.
2. The user signs in to Apprentice. A consent screen asks which workspace to connect: their own artist account, or a shop where they are an owner or admin. The user approves.
3. Apprentice redirects to your `redirect_uri` with a `code` and your `state`.
4. Exchange the code with a `POST` to the token URL. The body is `application/x-www-form-urlencoded`: `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`.
5. Send `Authorization: Bearer <access_token>` on every API call.

### Refresh

When the access token expires, or after any `401`, `POST` to the token URL with `grant_type=refresh_token` and `refresh_token`. The response can include a new refresh token. Always store the newest one. The token response includes `expires_in` (seconds). Refresh before it runs out.

### OAuth request examples

Zapier supplies the registered redirect URI, `state` and PKCE values. Other OAuth clients need their own approved client credentials and registered redirect URI. An ordinary Apprentice session token cannot replace this connection.

Authorization request (open in the user's browser):

```http
GET https://qgrdfaeclgcyrgmafatm.supabase.co/auth/v1/oauth/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_URL_ENCODED_REDIRECT_URI&state=YOUR_RANDOM_STATE&code_challenge=YOUR_S256_CHALLENGE&code_challenge_method=S256
```

Check that the returned `state` matches the one you sent. Exchange the authorization code:

```sh
curl --request POST \
  'https://qgrdfaeclgcyrgmafatm.supabase.co/auth/v1/oauth/token' \
  --user "$CLIENT_ID:$CLIENT_SECRET" \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode "code=$AUTHORIZATION_CODE" \
  --data-urlencode "redirect_uri=$REDIRECT_URI" \
  --data-urlencode "code_verifier=$CODE_VERIFIER"
```

Successful token responses contain `access_token`, `refresh_token`, `token_type` and `expires_in`. `token_type` is `bearer`; `expires_in` is the lifetime in seconds. Use the returned lifetime rather than hardcoding one.

Refresh request:

```sh
curl --request POST \
  'https://qgrdfaeclgcyrgmafatm.supabase.co/auth/v1/oauth/token' \
  --user "$CLIENT_ID:$CLIENT_SECRET" \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=refresh_token' \
  --data-urlencode "refresh_token=$REFRESH_TOKEN"
```

The OAuth service has its own error responses. The API error envelope later on this page applies to the Apprentice API base URL. If refresh fails or the connection was disconnected, reconnect through Zapier.

### Scopes

The authorize request does not choose scopes. When the user approves, the connection receives all of the scopes below. They are fixed for the life of the connection.

| Scope | What it allows |
| --- | --- |
| `appointments:read` | Find appointments |
| `appointments:write` | Create, update, confirm and cancel appointments |
| `clients:read` | Find clients |
| `clients:write` | Create, find or create, and update clients |
| `notes:write` | Add notes to appointments and clients |
| `notifications:write` | Send notifications to clients |
| `booking_submissions:write` | Create booking requests and book from a booking page |
| `booking_pages:read` | List booking pages |
| `locations:read` | List locations |
| `hooks:write` | Subscribe to and unsubscribe from events |
| `consent:read`, `payments:read`, `payments:write` | Granted with the connection. The Zapier integration does not use them. It has no waiver-reading or payment steps. |

A call without the scope it needs returns `403 OWNER_SCOPE_VIOLATION`.

### Connection test

`GET /auth/test`. Needs a valid token and no scope. Zapier calls it when a user connects.

```json
{
  "data": {
    "owner_type": "artist",
    "owner_id": "3f1c2a9e-5b7d-4e0a-9c11-7d2f6a8b4e10",
    "environment": "live",
    "scopes": ["appointments:read", "appointments:write", "clients:read"],
    "pinned_api_version": "2026-02-01",
    "label": "Alex Smith",
    "auth_type": "oauth",
    "connection_id": "9d8e7c6b-5a4f-4321-8a0b-1c2d3e4f5a6b"
  }
}
```

`label` is the account name for an artist, the shop name for a shop, or the account email if no name is set. Zapier shows it as `Alex Smith (artist)` in the connection list. It never contains a token or secret.

### Disconnecting

A user can disconnect in Apprentice settings. The connection stops working, and its event subscriptions are turned off. A shop connection also stops working if the person who connected it is no longer an owner or admin of the shop. To use Apprentice again, connect again.

---

## Shared behavior

### Make an API request

All IDs, dates and names in examples are illustrative. Replace them with records in your connected workspace. Running a write example creates or changes real data. No example requires a payment or a charge.

```sh
export APPRENTICE_API='https://app.useapprentice.com/api/integrations/zapier'
# Set ACCESS_TOKEN privately to the access_token returned by OAuth.
curl "$APPRENTICE_API/auth/test" \
  --header "Authorization: Bearer $ACCESS_TOKEN"
```

For a write, send a distinct idempotency key and the JSON body shown under that endpoint:

```sh
curl --request POST "$APPRENTICE_API/clients" \
  --header "Authorization: Bearer $ACCESS_TOKEN" \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: external-form-submission-12345' \
  --data '{"email":"jane@example.com","first_name":"Jane","last_name":"Doe"}'
```

This example uses an artist connection. A shop connection also needs `artist_id`. Keep the key when retrying this same operation; change it for a different operation.

### Idempotency

Send an `Idempotency-Key` header on writes. Use one new value for each logical operation.

- The same key and the same body within 24 hours returns the first response, with the header `X-Idempotency-Replay: true`. The write is not repeated.
- The same key with a different body returns `409` with a plain body: `{ "error": "Idempotency key conflict: same key used with different request body" }`. This is the one error that does not use the error shape below.
- Server errors (`5xx`) and short-lived conflicts are not stored, so a retry runs again.
- Keys are scoped to the connection.
- If a request with the same key is still running, you get `409 IDEMPOTENCY_REQUEST_IN_PROGRESS` with `Retry-After: 1`. Retry.

The Zapier integration builds the key from the step name and its inputs, so a Zapier retry never creates a duplicate. Subscribing to an event does not use this cache. It is safe to repeat by design.

### Pagination

List endpoints use cursors.

| Parameter | Meaning |
| --- | --- |
| `limit` | Page size, 1 to 100. Default 20. |
| `cursor` | The `pagination.nextCursor` from the last page. Leave out for page 1. |

```json
{
  "data": [ ],
  "pagination": { "nextCursor": "eyJ2IjoiMjAyNi0wMS0xMiIsImlkIjoiLi4uIn0", "hasMore": true, "limit": 20, "returned": 20 }
}
```

`nextCursor` is `null` on the last page. Treat cursors as opaque. A malformed cursor returns `400 VALIDATION_INVALID_FORMAT`.

### Rate limits

Each connection can make **30 requests per minute** and **5,000 per day** (UTC day). Every response to an authenticated request carries these headers. A request that fails before the limit is checked (a missing or bad token, or a missing scope) does not, though it still has `X-Request-ID`.

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | The limit for the window |
| `X-RateLimit-Remaining` | Requests left in the window |
| `X-RateLimit-Reset` | When the window resets, in Unix seconds |
| `X-Request-ID` | A request ID. Quote it to support. |

When a limit is hit you get `429 RATE_LIMIT_EXCEEDED` and a `Retry-After` header (seconds). The headers then describe whichever window ran out. Repeated bad tokens from one network address are also limited.

A connection can hold up to 10 event subscriptions. A subscribe past that returns `400 VALIDATION_OUT_OF_RANGE`.

### Errors

Except for the idempotency body-conflict response described above, API errors have this shape:

```json
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "start_time: start_time must be a valid ISO 8601 datetime",
    "requestId": "3b1f6a52-8c0d-4e79-a1b4-5d2c7e9f0a18",
    "timestamp": "2026-03-01T14:00:00.000Z",
    "details": []
  }
}
```

Branch on `code`. The `message` is written for a person. `details` appears only on `4xx` errors. See [Errors](#2-errors) for the codes.

---

## Events (triggers)

Triggers are instant. Apprentice sends an event the moment something happens. There is no polling.

### Subscribe

`POST /hooks`. Scope `hooks:write`. Zapier calls it when a Zap is turned on.

```json
{ "event": "appointment.created", "target_url": "https://hooks.zapier.com/hooks/standard/123456/abcdef/" }
```

Response `201`:

```json
{
  "id": "0b2f6c1e-7d3a-4c58-9e41-2a6d8f0b1c33",
  "event": "appointment.created",
  "target_url": "https://hooks.zapier.com/hooks/standard/123456/abcdef/",
  "signing_secret": "5f2b1c0e9a8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a291807f6e5d4c3b2a19087"
}
```

- `target_url` must use HTTPS. Localhost and private network addresses are refused (`400 VALIDATION_INVALID_URL`).
- Subscribing again with the same event and URL returns the existing subscription, and turns it back on if it was disabled.
- An unknown `event` returns `400 VALIDATION_INVALID_ENUM`.

### Unsubscribe

`DELETE /hooks/{id}`. Scope `hooks:write`. Zapier calls it when a Zap is turned off or deleted. Response `200`: `{ "success": true }`. Pending deliveries are removed with the subscription. Errors: `400 VALIDATION_INVALID_UUID`, `404 RESOURCE_NOT_FOUND` (no subscription with this ID on this connection, or it was already deleted).

### List subscriptions

`GET /hooks`. Scope `hooks:write`. No body or query parameters. This maintenance call is not used by the Zapier app. It returns the connection's subscriptions, newest first, without signing secrets. It does not use cursor pagination.

Response `200`:

```json
{
  "data": [
    {
      "id": "0b2f6c1e-7d3a-4c58-9e41-2a6d8f0b1c33",
      "event": "appointment.created",
      "targetUrl": "https://hooks.zapier.com/hooks/standard/123456/abcdef/",
      "status": "active",
      "environment": "live",
      "consecutiveFailures": 0,
      "disabledAt": null,
      "disabledReason": null,
      "createdAt": "2026-03-01T12:00:00.000Z"
    }
  ]
}
```

An empty list returns `{ "data": [] }`. Shared authentication, permission, rate-limit and server errors apply.

### Re-enable a subscription

`PATCH /hooks/{id}`. Scope `hooks:write`. This maintenance call is not used by the Zapier app. Use the subscription ID returned by Subscribe or List subscriptions.

Request:

```json
{ "status": "active" }
```

Response `200`:

```json
{ "success": true }
```

This resets the failure count and clears the disabled reason. It does not replay exhausted deliveries. An invalid UUID or any status other than `active` returns `400`. Shared errors also apply.

### Event list

| Zapier trigger | Event | `resource_type` |
| --- | --- | --- |
| New Appointment | `appointment.created` | `appointment` |
| Appointment Status Changed | `appointment.status_updated` | `appointment` |
| New Client Profile | `client.created` | `client` |
| New Scheduled Booking | `booking.submitted` | `booking_submission` |
| New Booking Request | `booking.requested` | `booking_request` |
| Waiver Signed | `consent.signed` | `consent` |
| Client Checked In | `checkin.completed` | `visit` |

### Envelope

Every event has the same outer shape. The event details are in `data`.

| Field | Meaning |
| --- | --- |
| `id` | Event ID (UUID) |
| `event` | Event name, for example `appointment.created` |
| `api_version` | `2026-02-01` |
| `created_at` | When the event was created |
| `resource_type` | See the table above |
| `resource_id` | ID of the thing the event is about |
| `data` | The payload, below |

### Webhook delivery

Apprentice sends each event as an HTTPS `POST` with `Content-Type: application/json` and these headers:

| Header | Value |
| --- | --- |
| `X-Apprentice-Event` | The event name |
| `X-Apprentice-Delivery` | A unique delivery ID. Use it to ignore repeats. |
| `X-Apprentice-Api-Version` | `2026-02-01` |
| `X-Apprentice-Signature` | `t=<unix seconds>,v1=<hex signature>` |
| `User-Agent` | `Apprentice-Webhooks/1.0` |

**Signature.** `v1` is a hex HMAC-SHA256. The key is the `signing_secret` from the subscribe response. The signed text is the timestamp, a period, then the raw request body: `<t>.<body>`. Compute it, compare in constant time, and reject an old `t`. Zapier does not check signatures, so Zaps need nothing extra.

```js
const crypto = require("crypto");
const [t, v1] = header.split(",").map((part) => part.split("=")[1]);
const expected = crypto.createHmac("sha256", signingSecret).update(`${t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
```

**Success and retries.**

- Any `2xx` response counts as delivered. Reply within 30 seconds.
- Any other status, a timeout, or a redirect counts as a failure. Redirects are not followed.
- A failed delivery is tried again, up to 5 attempts in total. The waits are about 1 minute, 5 minutes, 30 minutes and 2 hours, each with a little random jitter.
- After the last attempt the delivery is dropped.
- After 10 failed attempts in a row, the subscription is disabled. Any success resets the count. Re-enable with `PATCH /hooks/{id}`, or subscribe again.

Events can arrive more than once, and not always in order. Use `X-Apprentice-Delivery` or the event `id` to ignore repeats.

### New Appointment: `appointment.created`

`data` fields:

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Appointment ID |
| `artist_id`, `client_id` | string | |
| `start_time`, `end_time` | string | |
| `status` | string | For example `scheduled` |
| `appointment_type` | string | The kind of session: `consultation`, `tattoo`, `touch_up` or `healing_check`. For the service, use `service_type_slug`. |
| `title` | string or null | |
| `service_type_slug` | string | `tattoo`, `piercing`, `pmu`, `laser_removal`, `scarification`, `tooth_gem` or `implants` |
| `service_display_name` | string | For example `Tattoo` |
| `service_summary_line` | string | Short label for lists |
| `service_payload` | object | Service details. `{}` if none. Health keys are removed. |
| `location_id`, `client_email`, `client_first_name`, `client_last_name`, `client_phone`, `artist_first_name`, `artist_last_name`, `location_name`, `location_timezone` | string | Optional. Left out when not known. |

```json
{
  "id": "8f3c1a7e-2b9d-4c60-a5e1-3d7f0b9c2a44",
  "event": "appointment.created",
  "api_version": "2026-02-01",
  "created_at": "2026-03-01T14:00:00.000Z",
  "resource_type": "appointment",
  "resource_id": "2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80",
  "data": {
    "id": "2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80",
    "artist_id": "3f1c2a9e-5b7d-4e0a-9c11-7d2f6a8b4e10",
    "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
    "location_id": "5d9b2e71-4a06-4c83-b1f7-0e8a3c6d2f95",
    "start_time": "2026-03-01T14:00:00.000Z",
    "end_time": "2026-03-01T16:00:00.000Z",
    "status": "scheduled",
    "appointment_type": "consultation",
    "title": "New Tattoo Session",
    "client_email": "jane@example.com",
    "client_first_name": "Jane",
    "client_last_name": "Doe",
    "client_phone": "+15551234567",
    "artist_first_name": "Alex",
    "artist_last_name": "Smith",
    "location_name": "Downtown Studio",
    "location_timezone": "America/New_York",
    "service_type_slug": "tattoo",
    "service_display_name": "Tattoo",
    "service_summary_line": "Tattoo",
    "service_payload": {}
  }
}
```

### Appointment Status Changed: `appointment.status_updated`

Sent when an appointment is confirmed, rescheduled, cancelled, completed or marked no-show. `data` has every field of `appointment.created`, plus `previous_status` (string).

```json
{
  "id": "1e6a9d30-7c52-4b88-8f14-6a0c3e5b7d21",
  "event": "appointment.status_updated",
  "api_version": "2026-02-01",
  "created_at": "2026-03-01T14:00:00.000Z",
  "resource_type": "appointment",
  "resource_id": "2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80",
  "data": {
    "id": "2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80",
    "artist_id": "3f1c2a9e-5b7d-4e0a-9c11-7d2f6a8b4e10",
    "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
    "start_time": "2026-03-01T14:00:00.000Z",
    "end_time": "2026-03-01T16:00:00.000Z",
    "status": "confirmed",
    "previous_status": "scheduled",
    "appointment_type": "tattoo",
    "title": "Tattoo Session",
    "service_type_slug": "tattoo",
    "service_display_name": "Tattoo",
    "service_summary_line": "Tattoo",
    "service_payload": {}
  }
}
```

### New Client Profile: `client.created`

`data`: `id`, `artist_id`, `first_name`, `last_name`, `email` (string or null), `phone` (string or null). At least one of `email` and `phone` is set.

```json
{
  "id": "4a7c2e90-1b35-4d68-9f03-8e5a1c7b3d52",
  "event": "client.created",
  "api_version": "2026-02-01",
  "created_at": "2026-03-01T12:00:00.000Z",
  "resource_type": "client",
  "resource_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
  "data": {
    "id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
    "artist_id": "3f1c2a9e-5b7d-4e0a-9c11-7d2f6a8b4e10",
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane@example.com",
    "phone": "+15551234567"
  }
}
```

### New Scheduled Booking: `booking.submitted`

Sent when a client books an exact appointment from a booking page.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Booking submission ID |
| `appointment_id`, `artist_id`, `client_id` | string | |
| `guest_name`, `guest_email` | string | |
| `submitted_at`, `start_time`, `end_time` | string | |
| `service_type_slug`, `service_display_name`, `service_summary_line`, `service_payload` | | Same as appointments |
| `guest_phone`, `booking_page_id`, `location_id`, `artist_name`, `location_name` | string | Optional |

```json
{
  "id": "9c2e4a60-3d17-4f85-b0a9-7e1d5c3b8f26",
  "event": "booking.submitted",
  "api_version": "2026-02-01",
  "created_at": "2026-03-01T14:00:00.000Z",
  "resource_type": "booking_submission",
  "resource_id": "5b8d0f12-6a34-4c97-8e21-1f9a3c7d5e40",
  "data": {
    "id": "5b8d0f12-6a34-4c97-8e21-1f9a3c7d5e40",
    "appointment_id": "2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80",
    "artist_id": "3f1c2a9e-5b7d-4e0a-9c11-7d2f6a8b4e10",
    "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
    "guest_name": "Jane Doe",
    "guest_email": "jane@example.com",
    "submitted_at": "2026-03-01T14:00:00.000Z",
    "start_time": "2026-03-01T14:00:00.000Z",
    "end_time": "2026-03-01T16:00:00.000Z",
    "service_type_slug": "tattoo",
    "service_display_name": "Tattoo",
    "service_summary_line": "Tattoo",
    "service_payload": {},
    "guest_phone": "+15551234567",
    "booking_page_id": "4e7a9c21-6d38-4b15-a0f2-8c1b3d5e7f90",
    "location_id": "5d9b2e71-4a06-4c83-b1f7-0e8a3c6d2f95",
    "artist_name": "Alex Smith",
    "location_name": "Downtown Studio"
  }
}
```

### New Booking Request: `booking.requested`

Sent when a pending request is created, from a booking page or from the Create Booking Request action.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Request ID |
| `artist_id`, `booking_page_id` | string | |
| `status` | string | Always `pending` |
| `client_name`, `client_email` | string | |
| `requested_at` | string | |
| `verification_required` | boolean | Always `true` |
| `service_type_slug`, `service_display_name`, `service_summary_line`, `service_payload` | | Same as appointments |
| `client_phone`, `preferred_dates_text` | string | Optional |

```json
{
  "id": "6d0f2b84-9a31-4e57-a8c3-2b7e1d5f9a60",
  "event": "booking.requested",
  "api_version": "2026-02-01",
  "created_at": "2026-03-01T14:00:00.000Z",
  "resource_type": "booking_request",
  "resource_id": "7b3d1f95-2e48-4a60-91c7-5d0e8a2b4c16",
  "data": {
    "id": "7b3d1f95-2e48-4a60-91c7-5d0e8a2b4c16",
    "artist_id": "3f1c2a9e-5b7d-4e0a-9c11-7d2f6a8b4e10",
    "booking_page_id": "4e7a9c21-6d38-4b15-a0f2-8c1b3d5e7f90",
    "status": "pending",
    "client_name": "Jane Doe",
    "client_email": "jane@example.com",
    "requested_at": "2026-03-01T14:00:00.000Z",
    "verification_required": true,
    "service_type_slug": "tattoo",
    "service_display_name": "Tattoo",
    "service_summary_line": "Tattoo",
    "service_payload": {},
    "client_phone": "+15551234567",
    "preferred_dates_text": "Weekday afternoons in September"
  }
}
```

### Waiver Signed: `consent.signed`

Sent when a client signs a waiver. The event says that a waiver was signed, and when. It does **not** include the waiver text, the client's answers or the signature.

`data`: `prep_request_id`, `template_id`, `signed_at`. No other fields.

```json
{
  "id": "3a5c7e19-4b20-4d86-9f71-0c2e8a6b1d35",
  "event": "consent.signed",
  "api_version": "2026-02-01",
  "created_at": "2026-03-01T13:45:00.000Z",
  "resource_type": "consent",
  "resource_id": "8d1f3b57-2c64-4a90-b5e8-6a0c9e2d4f71",
  "data": {
    "prep_request_id": "8d1f3b57-2c64-4a90-b5e8-6a0c9e2d4f71",
    "template_id": "0f4a6c82-1e53-4b79-8d20-5c7b9e3a1d64",
    "signed_at": "2026-03-01T13:45:00.000Z"
  }
}
```

### Client Checked In: `checkin.completed`

`data`: `visit_id`, `action` (`check_in` or `confirm_arrival`), `checked_in_at`, and optional `artist_id` and `shop_id`.

```json
{
  "id": "2d4f6a81-5c39-4e07-a1b6-8e3c0d7f9a52",
  "event": "checkin.completed",
  "api_version": "2026-02-01",
  "created_at": "2026-03-01T13:55:00.000Z",
  "resource_type": "visit",
  "resource_id": "9e2a4c60-7b18-4d35-8f91-3a5d1c7e0b84",
  "data": {
    "visit_id": "9e2a4c60-7b18-4d35-8f91-3a5d1c7e0b84",
    "action": "check_in",
    "checked_in_at": "2026-03-01T13:55:00.000Z",
    "artist_id": "3f1c2a9e-5b7d-4e0a-9c11-7d2f6a8b4e10",
    "shop_id": "1a3c5e70-9b24-4f68-8d02-7c4e6a8b0d93"
  }
}
```

---

## Actions

All action endpoints need the scope shown. All writes accept `Idempotency-Key`. Responses wrap the result in `{ "data": ... }`.

| Zapier action | Method and path | Scope |
| --- | --- | --- |
| Create Appointment | `POST /appointments` | `appointments:write` |
| Update Appointment | `PATCH /appointments/{id}` | `appointments:write` |
| Confirm Appointment | `POST /appointments/{id}/confirm` | `appointments:write` |
| Cancel Appointment | `DELETE /appointments/{id}` | `appointments:write` |
| Add Appointment Note | `POST /appointments/{id}/notes` | `notes:write` |
| Create Client | `POST /clients` | `clients:write` |
| Find or Create Client | `POST /clients/find-or-create` | `clients:write` |
| Update Client | `PATCH /clients/{id}` | `clients:write` |
| Add Client Note | `POST /clients/{id}/notes` | `notes:write` |
| Send Notification | `POST /notifications` | `notifications:write` |
| Create Booking Request | `POST /booking-requests` | `booking_submissions:write` |
| Book Appointment From Booking Page | `POST /booking-submissions` | `booking_submissions:write` |

### The appointment object

Create, update, confirm and cancel return an appointment. So does Find Appointment. These are the only fields it has:

| Field | Type | Notes |
| --- | --- | --- |
| `id`, `artist_id`, `client_id`, `location_id`, `project_id` | string (UUID) or null | |
| `start_time`, `end_time` | string | |
| `status` | string | `pending_verification`, `scheduled`, `confirmed`, `in_progress`, `completed`, `cancelled`, `rescheduled` or `no_show` |
| `appointment_type` | string | `consultation`, `tattoo`, `touch_up` or `healing_check` |
| `title` | string or null | |
| `confirmed_at`, `cancelled_at`, `created_at`, `updated_at` | string or null | |
| `client_email`, `client_first_name`, `client_last_name`, `client_phone` | string or null | |
| `artist_first_name`, `artist_last_name` | string or null | |
| `location_name`, `location_timezone` | string or null | |
| `service_type_slug`, `service_display_name`, `service_summary_line`, `service_payload` | | As in the events |

```json
{
  "data": {
    "id": "2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80",
    "artist_id": "3f1c2a9e-5b7d-4e0a-9c11-7d2f6a8b4e10",
    "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
    "location_id": "5d9b2e71-4a06-4c83-b1f7-0e8a3c6d2f95",
    "project_id": "0c6e8a24-3f51-4b97-9d10-2a4c6e8f1b73",
    "start_time": "2026-03-01T14:00:00.000Z",
    "end_time": "2026-03-01T16:00:00.000Z",
    "status": "confirmed",
    "appointment_type": "tattoo",
    "title": "Forearm session",
    "confirmed_at": "2026-02-20T09:30:00.000Z",
    "cancelled_at": null,
    "created_at": "2026-02-18T17:05:00.000Z",
    "updated_at": "2026-02-20T09:30:00.000Z",
    "client_email": "jane@example.com",
    "client_first_name": "Jane",
    "client_last_name": "Doe",
    "client_phone": "+15551234567",
    "artist_first_name": "Alex",
    "artist_last_name": "Smith",
    "location_name": "Downtown Studio",
    "location_timezone": "America/New_York",
    "service_type_slug": "tattoo",
    "service_display_name": "Tattoo",
    "service_summary_line": "Tattoo",
    "service_payload": {}
  }
}
```

### Create Appointment

`POST /appointments`

| Body field | Required | Notes |
| --- | --- | --- |
| `client_id` | Yes | UUID of one of your clients |
| `start_time`, `end_time` | Yes | ISO 8601 date and time |
| `artist_id` | Shops: yes | UUID. Leave out on an artist connection to book on your own calendar. An artist connection can only name itself. |
| `location_id` | No | Defaults to the artist's primary location |
| `project_id` | No | An existing project for this client and artist. A new project is created if you leave it out. |
| `title` | No | |
| `appointment_type` | No | `consultation`, `tattoo`, `touch_up`, `healing_check`. Default `tattoo`. |
| `service_type_slug` | No | See [service values](#2-services-with-extra-rules). Default `tattoo`, or the linked project's service. |
| `notes` | No | |
| `status` | No | Default `scheduled` |
| `scarificationTechnique`, `scarificationHealingIntent` | Scarification | See [Scarification](#3-scarification) |

Example request body:

```json
{
  "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
  "start_time": "2026-11-10T15:00:00.000Z",
  "end_time": "2026-11-10T16:00:00.000Z"
}
```

Response `201`: an [appointment](#3-the-appointment-object). Apprentice checks for a time conflict first.

Errors: `400 VALIDATION_FAILED`, `400 VALIDATION_REQUIRED_FIELD` (shop key without `artist_id`), `403`, `404 RESOURCE_NOT_FOUND` (client not yours), `409 APPOINTMENT_CONFLICT`.

### Update Appointment

`PATCH /appointments/{id}`. Send only what changes: `start_time`, `end_time`, `location_id`, `title`, `notes`, `status`, `appointment_type`.

- Changing the time re-checks for conflicts (`409 APPOINTMENT_CONFLICT`).
- A status change has to be a valid move. Otherwise: `400 VALIDATION_FAILED`, "Invalid status transition from 'cancelled' to 'confirmed'".
- Completing a minor's appointment without the required guardian verification returns `409 GUARDIAN_VERIFICATION_REQUIRED`.

Example request body:

```json
{
  "title": "Consultation follow-up"
}
```

Response `200`: an appointment. Errors also include `404 APPOINTMENT_NOT_FOUND`.

### Confirm Appointment

`POST /appointments/{id}/confirm`. No body. Moves the appointment to `confirmed`.

Example request (with your Bearer header):

```http
POST /appointments/2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80/confirm
```

Response `200`: an appointment. Errors: `404 APPOINTMENT_NOT_FOUND`, `422 APPOINTMENT_ALREADY_CONFIRMED`, `400 VALIDATION_FAILED` (status cannot move to confirmed), `422 BUSINESS_LOGIC_VIOLATION` ("Deposit payment is required before confirming this appointment", when the booking page needs a deposit that has not been paid).

### Cancel Appointment

`DELETE /appointments/{id}`. No body. Sets the status to `cancelled`. The appointment is kept. A linked visit is cancelled too.

Example request (with your Bearer header):

```http
DELETE /appointments/2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80
```

Response `200`: an appointment. Errors: `404 APPOINTMENT_NOT_FOUND`, `410 APPOINTMENT_ALREADY_CANCELLED`, `422 APPOINTMENT_CANNOT_CANCEL`.

### Add Appointment Note

`POST /appointments/{id}/notes`. Body: `content` (1 to 5,000 characters). The text is added to the end of the appointment's notes, starting with `[Zapier <time>]`.

Example request body:

```json
{
  "content": "Client prefers minimal shading."
}
```

Response `200`:

```json
{
  "data": {
    "id": "2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80",
    "appointment_id": "2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80",
    "content": "Client prefers minimal shading.",
    "updated_at": "2026-03-01T12:00:00.000Z"
  }
}
```

Only the note you sent comes back. Earlier notes never do. Errors: `400 VALIDATION_FAILED`, `404 APPOINTMENT_NOT_FOUND`.

### Create Client

`POST /clients`

| Body field | Required | Notes |
| --- | --- | --- |
| `email` | Yes | |
| `first_name`, `last_name` | Yes | 1 to 100 characters |
| `phone` | No | International format, for example `+15551234567` |
| `artist_id` | Shops: yes | Leave out on an artist connection |
| `tags` | No | Up to 20 tags, 40 characters each |
| `referral_source` | No | Up to 255 characters |
| `preferred_contact` | No | `email`, `phone` or `app` |

Example request body:

```json
{
  "email": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe"
}
```

Response `201`:

```json
{ "data": { "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe" } }
```

Errors: `400 VALIDATION_FAILED`, `400 VALIDATION_REQUIRED_FIELD`, `409 RESOURCE_ALREADY_EXISTS` (a client with this email exists). Emits `client.created`.

### Find or Create Client

`POST /clients/find-or-create`. Same body as Create Client except `preferred_contact`. Looks up the client by email. If none exists, creates one. If one exists and the name or phone you sent is different, updates it.

Example request body:

```json
{
  "email": "jane@example.com",
  "first_name": "Jane",
  "last_name": "Doe"
}
```

Response `201` (created) or `200` (found):

```json
{
  "data": {
    "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
    "created": false,
    "created_relationship": true,
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "phone": "+15551234567"
  }
}
```

`created_relationship` is `true` when this artist had no earlier link to the client. Errors: `400`, `404 RESOURCE_NOT_FOUND` (the email belongs to someone who is not one of your clients).

### Update Client

`PATCH /clients/{id}`. Send at least one of `first_name`, `last_name`, `email`, `phone`, `tags` (replaces all tags), `is_vip`, `referral_source`, `preferred_contact`, `general_notes`.

Example request body:

```json
{
  "first_name": "Janet"
}
```

Response `200`:

```json
{ "data": { "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48", "first_name": "Janet", "last_name": "Doe", "email": "jane@example.com", "phone": "+15551234567" } }
```

Errors: `400 VALIDATION_FAILED`, `404 RESOURCE_NOT_FOUND`.

### Add Client Note

`POST /clients/{id}/notes`. Body: `content` (1 to 5,000 characters), and `artist_id` (optional for shop connections, required for platform connections). The text is added to the client's general notes with a time stamp.

Example request body:

```json
{
  "content": "Prefers afternoon appointments."
}
```

Response `200`, or `201` if this was the artist's first note for the client:

```json
{ "data": { "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48", "note_added": true } }
```

Stored notes are never returned.

### Send Notification

`POST /notifications`. Queues a message to one of your clients.

| Body field | Required | Notes |
| --- | --- | --- |
| `recipient_id` | Yes | The client's UUID |
| `template_id` | Yes | One of the four messages below |
| `variables` | Yes, in practice | Object of text values. A missing value leaves a blank in the message, so send every variable the template uses (table below). |
| `channels` | No | Array of `email`, `sms`, `push`, `in_app`. Defaults to the template's channels. A channel the template does not support returns `400 VALIDATION_INVALID_ENUM`. `message` (Custom message) has no email version: use `sms`, `push` or `in_app`. The other three also support `email`. |

| `template_id` | Variables to send | Optional, used by the email or text |
| --- | --- | --- |
| `appointment_scheduled` | `artist_name`, `appointment_date`, `appointment_time` | `appointment_location`, `appointment_type`, `manage_url`, `dashboard_url`, `client_first_name` |
| `appointment_confirmed` | `artist_name`, `appointment_date`, `appointment_time`, and `confirmed_by_artist` set to `true` (without it the message is blank) | `appointment_location` |
| `appointment_cancelled` | `artist_name`, `appointment_date`, `appointment_time` | `cancelling_party_name` |
| `message` | `sender_name`, `message_preview` | |

The dates and times are text. In-app, push and text messages show them as you send them. The email uses Apprentice's saved email template, so it also reads the optional variables above. Any variable you leave out shows as a blank.

Example request body:

```json
{
  "recipient_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
  "template_id": "message",
  "channels": [
    "in_app"
  ],
  "variables": {
    "sender_name": "Alex",
    "message_preview": "Please check your booking details."
  }
}
```

Response `202`:

```json
{ "data": { "queued": true, "template_id": "appointment_scheduled", "recipient_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48" } }
```

`queued` means accepted, not delivered. Delivery follows the client's own notification settings. The API also knows other message types, for example `appointment_reminder` and `appointment_rescheduled`. They are not offered here because Apprentice builds them from its own appointment data. A reminder is dropped without any error unless its variables name one of your appointments for that client. A reschedule email needs the appointment's saved time zone and its old and new start times, and fails inside the queue while the call still returns `202`. Errors: `400 VALIDATION_FAILED`, `400 VALIDATION_INVALID_ENUM` (a channel the template does not support), `404 NOTIFICATION_TEMPLATE_NOT_FOUND`, `422 NOTIFICATION_RECIPIENT_INVALID` (not your client).

### Create Booking Request

`POST /booking-requests`. Creates a `pending` request for the artist to review. It does not reserve calendar time and does not charge a deposit. The client receives a verification email and the artist receives a notification. Emits `booking.requested`.

| Body field | Required | Notes |
| --- | --- | --- |
| `booking_page_id` | Yes | UUID. The page decides the artist, location and service. |
| `client_name` | Yes | Up to 255 characters |
| `client_email` | Yes | |
| `description` | Yes | Up to 5,000 characters |
| `client_phone` | No | Required if `transactional_sms_opt_in` is `true` |
| `preferred_datetime` | No | A time the client really chose, ISO 8601 with a time zone. Do not send the form submission time. |
| `preferred_dates_text` | No | Free text, up to 1,000 characters |
| `preferred_date_range_start`, `preferred_date_range_end` | No | `YYYY-MM-DD` |
| `placement`, `size_estimate`, `style`, `color_scheme`, `budget_range` | No | Text |
| `has_tattoos` | No | Boolean |
| `reference_images` | No | Up to 10 public image URLs. The links are saved. The files are not copied. |
| `custom_fields` | No | Object of up to 20 text answers |
| `transactional_sms_opt_in` | No | `true` only if the client actively agreed to booking-related texts. Default `false`. |
| `sms_consent_captured_at` | No | When they agreed. Defaults to the time Apprentice receives the request. |

A phone number alone never gives text consent. This consent covers booking-related texts only, not marketing.

Example request body:

```json
{
  "booking_page_id": "4e7a9c21-6d38-4b15-a0f2-8c1b3d5e7f90",
  "client_name": "Jane Doe",
  "client_email": "jane@example.com",
  "description": "A small botanical design on my forearm."
}
```

Response `201`:

```json
{
  "data": {
    "request_id": "7b3d1f95-2e48-4a60-91c7-5d0e8a2b4c16",
    "status": "pending",
    "service_type_slug": "tattoo",
    "preferred_dates_text": "Weekday afternoons in September",
    "verification_required": true,
    "booking_page_id": "4e7a9c21-6d38-4b15-a0f2-8c1b3d5e7f90",
    "booking_page_title": "Creative Consultations",
    "client_name": "Jane Doe",
    "client_email": "jane@example.com",
    "transactional_sms_opt_in": false
  }
}
```

Errors: `400 VALIDATION_FAILED`, `404 RESOURCE_NOT_FOUND` (page missing, inactive or not yours).

### Book Appointment From Booking Page

`POST /booking-submissions`. Books a confirmed appointment at an exact date and time. It uses the same checks as the public booking page: availability, buffers, daily limits and conflicts. Emits `booking.submitted`.

| Body field | Required | Notes |
| --- | --- | --- |
| `booking_page_id` | Yes | UUID |
| `client_name`, `client_email` | Yes | |
| `requested_date` | Yes | `YYYY-MM-DD`, in the booking location's time zone |
| `requested_time` | Yes | `HH:MM`, 24 hour, in the booking location's time zone |
| `description` | Yes | Up to 5,000 characters |
| `client_phone` | No | |
| `service_type_slug` | No | A service the page offers. Defaults to the page's default service. |
| `placement` | Some services | Body location. Required for Laser Removal and Implants. |
| `size_estimate`, `budget_range` | No | Text |
| `reference_images` | No | Up to 10 URLs |
| `booking_policy_acceptance` | If the page has a policy | `{ "versionId": "<booking_policy_version_id>", "accepted": true }` |
| `scarificationTechnique`, `scarificationHealingIntent` | Scarification | See [Scarification](#3-scarification) |

This action cannot book a page that requires a deposit. Use Create Booking Request instead.

A consultation page (phone or in person) books here too. The page sets the format, the length and the offered times, and a time it does not offer returns `409 APPOINTMENT_CONFLICT`. A phone consultation needs `client_phone`. Send no tattoo details. The booking never joins an existing project: the artist sees it as a new consultation inquiry. You get `400 VALIDATION_FAILED` if the consultation needs a deposit or a signed waiver, because this action can collect neither. Share the booking page with the client instead.

If the page has a booking policy, show the client `booking_policy_text` from the booking page list. Send `booking_policy_acceptance` only after they agree. Without it you get `400 VALIDATION_FAILED`, "Please review and accept the current booking policy before booking.", with `details.policyDecision` set to `booking_policy_acceptance_required`.

Example request body:

```json
{
  "booking_page_id": "4e7a9c21-6d38-4b15-a0f2-8c1b3d5e7f90",
  "client_name": "Jane Doe",
  "client_email": "jane@example.com",
  "requested_date": "2026-11-10",
  "requested_time": "10:00",
  "description": "A small botanical design on my forearm."
}
```

Response `201`:

```json
{
  "data": {
    "appointment_id": "2c8e5b1f-0a47-4d93-b6e2-9f3a7c5d1e80",
    "submission_id": "5b8d0f12-6a34-4c97-8e21-1f9a3c7d5e40",
    "client_id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
    "is_new_client": false,
    "start_time": "2026-03-01T14:00:00.000Z",
    "end_time": "2026-03-01T16:00:00.000Z",
    "management_url": "https://app.useapprentice.com/manage/sample-token",
    "client_name": "Jane Doe",
    "client_email": "jane@example.com",
    "artist_name": "Alex Smith",
    "location_name": "Downtown Studio",
    "service_type_slug": "tattoo",
    "service_display_name": "Tattoo",
    "service_summary_line": "Tattoo",
    "service_payload": {}
  }
}
```

`management_url` is a private link where the client can view or change the appointment. Treat it like a password.

Errors: `400 VALIDATION_FAILED` (the message says what to fix), `404 RESOURCE_NOT_FOUND`, `409 APPOINTMENT_CONFLICT` ("Requested time slot is not available"), `422 SUBSCRIPTION_UPGRADE_REQUIRED` ("This booking cannot be completed. Please contact the artist.").

---

## Searches and dropdowns

Searches return a page of results. A search with no match returns `200` with `"data": []`, never an error. All use [cursor pagination](#3-pagination).

| Zapier step | Method and path | Scope |
| --- | --- | --- |
| Find Appointment | `GET /appointments/search` | `appointments:read` |
| Find Client | `GET /clients/search` | `clients:read` |
| Find Location, and the Location dropdown | `GET /locations` | `locations:read` |
| Find Booking Page, and the Booking page dropdown | `GET /booking-pages` | `booking_pages:read` |

### Find Appointment

`GET /appointments/search`. Newest start time first. Filters combine with AND.

Example request (with your Bearer header):

```http
GET /appointments/search?status=confirmed&limit=20
```

| Query parameter | Notes |
| --- | --- |
| `status` | One of the appointment statuses |
| `start_date` | Only appointments that start at or after this time |
| `end_date` | Only appointments that start at or before this time |
| `client_id` | UUID. Only this client's appointments. |
| `cursor`, `limit` | Pagination |

Response: `{ "data": [appointment, ...], "pagination": {...} }`. See [the appointment object](#3-the-appointment-object). Errors: `400 VALIDATION_INVALID_FORMAT` (bad `client_id` or `cursor`).

### Find Client

`GET /clients/search`. Newest client first.

Example request (with your Bearer header):

```http
GET /clients/search?email=jane%40example.com&limit=20
```

| Query parameter | Notes |
| --- | --- |
| `email`, `phone` | Partial match, not case sensitive |
| `name` | Partial match on first or last name |
| `cursor`, `limit` | Pagination |

Each result:

```json
{
  "id": "6a1d4c52-93be-4f07-8b2a-0c7e5d9f1a48",
  "first_name": "Jane",
  "last_name": "Doe",
  "email": "jane@example.com",
  "phone": "+15551234567",
  "avatar_url": null,
  "status": "active",
  "member_since": "2026-01-12T10:00:00.000Z",
  "total_appointments": 5,
  "artist_id": "3f1c2a9e-5b7d-4e0a-9c11-7d2f6a8b4e10"
}
```

### Locations

`GET /locations`. Your active locations, newest first. Optional query `name` returns only locations whose name contains that text (case-insensitive; blank is ignored; asterisks are ignored; only the first 100 characters are used). Each has `id`, `location_name`, `timezone` (IANA), `address_line1`, `address_line2`, `city`, `state`, `postal_code`, `country` and `is_active`. Other fields may be present and are not part of this contract.

Example request (with your Bearer header):

```http
GET /locations?limit=20
```

```json
{
  "data": [
    {
      "id": "5d9b2e71-4a06-4c83-b1f7-0e8a3c6d2f95",
      "location_name": "Downtown Studio",
      "timezone": "America/New_York",
      "address_line1": "123 Main St",
      "city": "New York",
      "state": "NY",
      "postal_code": "10001",
      "is_active": true
    }
  ],
  "pagination": { "nextCursor": null, "hasMore": false, "limit": 20, "returned": 1 }
}
```

### Booking pages

`GET /booking-pages`. Your active booking pages, newest first. Optional query `title` returns only booking pages whose title contains that text (case-insensitive; blank is ignored; asterisks are ignored; only the first 100 characters are used). Each has `id`, `title`, `slug`, `is_active`, `duration_minutes`, `default_service_type_slug` and `location_id`. Other fields may be present and are not part of this contract.

Example request (with your Bearer header):

```http
GET /booking-pages?limit=20
```

If a page has a booking policy, these fields carry it. Otherwise they are `null`.

| Field | Meaning |
| --- | --- |
| `booking_policy_version_id` | The current policy version |
| `booking_policy_text` | The terms to show the client |
| `booking_policy` | `versionId`, `ownerType`, `ownerId`, `noticeHours`, `maxReschedules`, `depositNonrefundable`, `text` |

```json
{
  "id": "4e7a9c21-6d38-4b15-a0f2-8c1b3d5e7f90",
  "title": "Custom Tattoo Consultation",
  "slug": "custom-tattoo",
  "is_active": true,
  "duration_minutes": 120,
  "booking_policy_version_id": null,
  "booking_policy_text": null,
  "booking_policy": null
}
```

---

## Services with extra rules

`service_type_slug` is one of: `tattoo`, `piercing`, `pmu` (permanent makeup), `laser_removal`, `scarification`, `tooth_gem`, `implants`.

### Scarification

Scarification needs two answers. They drive the consent review and the aftercare instructions.

| Field | Values |
| --- | --- |
| `scarificationTechnique` | `cutting`, `branding`, `ink_rubbing`, `skin_peel`, `not_sure` |
| `scarificationHealingIntent` | `keloid_preferred` (a raised scar), `flat_preferred` (a flatter scar), `no_preference` |

- **Create Appointment:** required for Scarification. If you link a `project_id` whose stored answers already include both, you can leave them out. Values you send win over the project's.
- **Book Appointment From Booking Page:** required when `service_type_slug` is `scarification`. If you leave `service_type_slug` out and the page's default service is Scarification, they are required too. For other services they are ignored.
- A missing or wrong value returns `400 VALIDATION_FAILED`: "Scarification needs a technique and healing intent: scarificationTechnique is required; ...". For a wrong value the message lists the allowed values.

### Laser removal and implants

`placement` (the body location to treat) is required when booking Laser Removal or Implants from a booking page. Without it: `400 VALIDATION_FAILED`, "placement is required for Laser Removal: the body location to treat."

### When a service is refused

Laser removal, scarification and implants follow the artist's consent timing. This applies to Book Appointment From Booking Page.

- If the artist collects a **signed consent form at booking**, the request is refused. An integration cannot collect a signature. The error is `400 VALIDATION_FAILED`: "The artist requires a signed Scarification consent form before the appointment is booked, and Zapier can't collect a signature. Share the booking page with the client instead." Use Create Booking Request, or send the client to the booking page.
- If the form is sent **after booking** (in the prep step), the booking goes through. Apprentice first runs the same live checks as the booking page: the artist must offer the service, and the shop's policy and required forms must be in place. If the service cannot be booked right now you get `400 VALIDATION_FAILED`: "Scarification isn't available to book right now."
- If Apprentice cannot confirm the consent settings, it returns `500 SERVER_DATABASE_ERROR` and nothing is booked. Try again.

Create Booking Request never needs a signature. It makes a pending request the artist reviews.

---

## Errors

HTTP status and `error.code`. Codes can be added over time, so handle unknown ones by status.

| Status | Code | When |
| --- | --- | --- |
| 400 | `VALIDATION_FAILED` | A field is missing or wrong, or a rule was broken. `message` says which. |
| 400 | `VALIDATION_REQUIRED_FIELD` | A required field is missing for this kind of connection |
| 400 | `VALIDATION_INVALID_UUID`, `VALIDATION_INVALID_FORMAT`, `VALIDATION_INVALID_DATE`, `VALIDATION_INVALID_ENUM`, `VALIDATION_INVALID_URL`, `VALIDATION_OUT_OF_RANGE` | A value has the wrong form or is out of range |
| 401 | `AUTH_REQUIRED` | No `Authorization: Bearer` header |
| 401 | `AUTH_TOKEN_INVALID` | The token is malformed, from an unknown client, or has no active connection. Refresh and retry. |
| 403 | `OWNER_SCOPE_VIOLATION` | The connection does not have the scope this call needs |
| 403 | `AUTH_INSUFFICIENT_PERMISSIONS`, `AUTH_FORBIDDEN` | The person who connected can no longer do this, or the shop does not allow exporting that client |
| 404 | `RESOURCE_NOT_FOUND`, `APPOINTMENT_NOT_FOUND`, `NOTIFICATION_TEMPLATE_NOT_FOUND` | The thing does not exist, or is not yours |
| 409 | `APPOINTMENT_CONFLICT` | The time overlaps another appointment |
| 409 | `RESOURCE_ALREADY_EXISTS`, `CONFLICT` | The record already exists, or cannot be reused |
| 409 | `IDEMPOTENCY_REQUEST_IN_PROGRESS` | Same `Idempotency-Key` is still running. Retry after `Retry-After`. |
| 409 | `GUARDIAN_VERIFICATION_REQUIRED` | A minor's appointment needs guardian verification first |
| 410 | `APPOINTMENT_ALREADY_CANCELLED` | |
| 422 | `APPOINTMENT_ALREADY_CONFIRMED`, `APPOINTMENT_CANNOT_CANCEL`, `BUSINESS_LOGIC_VIOLATION`, `NOTIFICATION_RECIPIENT_INVALID`, `SUBSCRIPTION_UPGRADE_REQUIRED` | The request is valid but the current state does not allow it |
| 429 | `RATE_LIMIT_EXCEEDED` | See [Rate limits](#3-rate-limits). Wait `Retry-After` seconds. |
| 500 | `SERVER_DATABASE_ERROR`, `SERVER_CONFIGURATION_ERROR` | Something failed on our side. Safe to retry. |
| 503 | `INTEGRATION_DISABLED`, `IDEMPOTENCY_UNAVAILABLE` | The integration is paused, or the safety check was unavailable. `Retry-After` says when to retry. |

---

## What the API does not return

To keep health and consent information out of automation tools:

- **Waivers.** `consent.signed` carries only IDs and the time. Waiver text, answers and signatures are never sent or returned.
- **Health answers.** In `service_payload`, answers that hold health information, such as a tooth gem client's metal allergy or dental work, are removed everywhere: events, actions and searches.
- **Guardian and age data.** Appointment responses do not include whether a client is a minor, their age, or guardian consent status.
- **Notes.** Appointment and client notes are write-only. Responses to the note actions return only what you just sent. Appointment responses do not include the `notes` field.
- **Secrets and money.** Access tokens and verification tokens are never returned. Appointment, client and event data carry no pricing or payment details, and the Zapier integration has no payment steps. Two things do return more: a booking page in the booking page list includes the page's own settings, which can include its deposit settings (these are your settings, not a client's), and Book Appointment From Booking Page returns `management_url`, a private link for the client. Treat that link like a password.

Free text you put in a field, for example a note or a booking description, is stored as you send it. Do not send health details through an automation you do not need to.

---

## Changelog

The integration version is separate from the API version. Full notes are in the integration's changelog.

| Version | Date | Summary |
| --- | --- | --- |
| Documentation | 2026-10-12 | Added endpoint index, OAuth and request examples, hook maintenance responses, and working section links. |
| 2.0.2 | 2026-10-08 | Find Location and Find Booking Page take an optional name or title filter. `DELETE /hooks/{id}` for an unknown subscription returns `404` instead of `200`. |
| 2.0.1 | 2026-10-06 | Fixes: Send Notification choices that failed, optional Artist on Create Appointment, location dropdown labels, output fields that did not match responses, readable error messages. Removed the unused Note Type input. Appointment responses use a fixed field list. |
| 2.0.0 | 2026-10-02 | Removed Create Payment Link, Find Payment and Payment Status Changed. Breaking for Zaps that used them. |
| 1.3.1 | 2026-10-01 | Scarification fields optional when Service Type is blank on Book Appointment From Booking Page. |
| 1.3.0 | 2026-10-01 | Scarification technique and healing intent. Hardened services follow the artist's consent timing. |
| 1.2.1 | 2026-08-24 | SMS consent fields on Create Booking Request. |
| 1.2.0 | 2026-08-24 | Added Create Booking Request. Renamed the older booking action to Book Appointment From Booking Page. |
| 1.1.0 | 2026-05-21 | Service fields on appointment and booking events. |
| 1.0.0 | 2026-02-12 | First release. |

---

## Tips

- Test with your own booking page and a client email you control.
- Use `Idempotency-Key` on every write you might retry.
- Store the newest refresh token every time you refresh.
- Keep `signing_secret` private. It lets anyone forge events to your URL.

## Common Issues

- **Issue**: `401 AUTH_TOKEN_INVALID` after a while: **Solution**: The access token expired. Refresh it. If refresh fails, connect again.
- **Issue**: `403 OWNER_SCOPE_VIOLATION`: **Solution**: The connection is missing a scope. Remove the connection and connect again.
- **Issue**: `409 APPOINTMENT_CONFLICT`: **Solution**: Pick another time, or check the artist's calendar.
- **Issue**: A trigger stopped firing: **Solution**: After 10 failed deliveries in a row the subscription is disabled. Turn the Zap off and on again, or call `PATCH /hooks/{id}`.
- **Issue**: Scarification, laser removal or implant booking is refused: **Solution**: See [When a service is refused](#3-when-a-service-is-refused). Use Create Booking Request.

## Related Articles

- [Connecting Apprentice to Zapier](https://help.useapprentice.com/en/article/connecting-apprentice-to-zapier-23ptq9/)
- [Understanding Data Privacy on Apprentice](https://help.useapprentice.com/en/article/understanding-data-privacy-on-apprentice-15xqeyp/)
- [Opting In/Out of SMS Notifications](https://help.useapprentice.com/en/article/opting-inout-of-sms-notifications-1nrw2ya/)

---

_Need more help? Contact support at [help.useapprentice.com](https://help.useapprentice.com)_