Services
The Services module covers paid workshop services a customer drops their own gear off for (e.g. an edge tune, a boot fit). A service booking is a booking with type: service whose line items are workshop jobs. Each job moves through a drop-off lifecycle:
Awaiting drop-off → In workshop → Ready for pickup → Picked up
A service can also be booked for a time with a person (booking_mode: time), such as a fitting or a lesson-style session. The customer picks a start time, and optionally who does it. These jobs carry start_at, end_at and staff instead of a drop-off day. Use Offered times to find free times, then send start_at on the booking line.
Key business rules (identical to the portal, POS and widget - the API calls the same code):
- Capacity is per location. A service's
daily_cap_quotais the cap per location per day, not a global total. - Not assumed dropped off. A new service booking starts awaiting drop-off - the SLA clock starts when you mark the job
dropped_off. - Ready-by respects opening hours.
ready_by= the service'ssla_hoursfrom the effective drop-off (the drop-off moment if within rental opening hours, otherwise the next opening), in the supplier's timezone.
Authentication & scopes
| Scope | Grants |
|---|---|
services:read | View the service catalog and service jobs |
services:write | Create service bookings, update job status and move appointments |
settings:write | Set up services (create and update) |
List services (catalog)
GET /v1/services
Requires services:read.
Query parameters
| Parameter | Type | Description |
|---|---|---|
location_id | integer | Only services offered at this location: ones assigned to it, plus ones with no locations set (offered at every location). |
date | date | Include per-day, per-location remaining availability (availability object) for this drop-off date (YYYY-MM-DD). |
active | boolean | Filter by active status. |
search | string | Match the service name. |
page, per_page | integer | Pagination (max 100). |
sort | string | name, base_price, sort_order, created_at (prefix - for desc; default sort_order). |
Response
{
"data": [
{
"id": 12,
"name": "Edge tune & hot wax",
"description": "Sharpen edges, fill base, hot wax & buff.",
"base_price": 49.95,
"sla_hours": 24,
"requires_dropoff": true,
"daily_cap_quota": 8,
"booking_mode": "dropoff",
"duration_minutes": null,
"gap_after_minutes": null,
"max_at_once": null,
"min_notice_minutes": null,
"max_days_ahead": null,
"staff": [],
"is_active": true,
"sort_order": 0,
"location_ids": [2, 3],
"availability": { "daily_cap": 8, "available": 5, "is_full": false, "unlimited": false },
"created_at": "2026-06-01T09:00:00+00:00",
"updated_at": "2026-06-01T09:00:00+00:00"
}
],
"meta": { "current_page": 1, "per_page": 25, "total": 1, "total_pages": 1, "request_id": "...", "timestamp": "..." },
"links": { "first": "...", "prev": null, "next": null, "last": "..." }
}
booking_mode is dropoff (the customer leaves gear with you) or time (booked for a time with a person). The fields below only apply to time services:
| Field | Description |
|---|---|
duration_minutes | How long one appointment takes. Times are offered in steps of this length. |
gap_after_minutes | Time kept free after each appointment before the same person starts the next. |
max_at_once | Most appointments of this service that can run at the same time. null = no limit beyond who is free. |
min_notice_minutes | Shortest notice before a start time. |
max_days_ahead | How far ahead a time can be booked. null = no limit. |
staff | Who does it, as { "id", "name" }. Empty = anyone on the team. |
location_ids lists the locations the service is offered at. Empty means every location. availability is only present when date is supplied.
Get a service
GET /v1/services/{id}
Requires services:read. Returns a single catalog service in the same shape as the list data item.
Create or update a service
POST /v1/services
PATCH /v1/services/{id}
Requires settings:write. Uses the same checks as Settings > Services in the app. On PATCH, only the fields you send change.
| Field | Type | Description |
|---|---|---|
name | string | Required on create. |
description | string | Optional. |
base_price | number | Required on create. |
booking_mode | string | dropoff (default) or time. |
sla_hours | integer | Turnaround for a drop-off service. |
daily_cap_quota | integer | Most per day, per location. |
duration_minutes | integer | Required when booking_mode is time. 5 to 720, in steps of 5. |
gap_after_minutes | integer | 0 to 240, in steps of 5. |
max_at_once | integer | 1 to 100. |
min_notice_minutes | integer | 0 to 43200. |
max_days_ahead | integer | 0 to 730. |
staff_ids | integer[] | Who does it. Each id must be on your team. Send [] to let anyone do it. |
location_ids | integer[] | Where it is offered. |
is_active | boolean | Default true on create. |
curl -X POST "https://app.equipdash.com/api/v1/services" \
-H "Authorization: Bearer eqd_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Boot fitting",
"base_price": 45,
"booking_mode": "time",
"duration_minutes": 45,
"gap_after_minutes": 15,
"staff_ids": [14, 15]
}'
Returns 201 (create) or 200 (update) with the service. A rule that fails returns 422 with error.code: validation_error and the field in error.details. A service that is not yours returns 404.
Offered times
GET /v1/services/{id}/times?date=YYYY-MM-DD
Requires services:read. Lists the start times a time service is offered on one day, in your shop's timezone, with the people free at each. This is the same list the booking page, POS and Dash show. A time is offered when someone who does the service is free (not on time off, another appointment or a departure they lead, and rostered or inside the hours they gave), the shop is open for the whole appointment, no closure covers it, and the notice, days-ahead, at-most-at-once and daily limits allow it.
| Parameter | Type | Required | Description |
|---|---|---|---|
date | date | yes | The day, in your shop's timezone (YYYY-MM-DD). |
staff_id | integer | no | Only times this person is free. They must do this service. |
location_id | integer | no | Apply that location's closures. |
ignore_job_id | integer | no | When changing an appointment, its id: its current time counts as free. Must be an appointment for this service. |
Response
{
"data": {
"service_id": 31,
"date": "2026-06-15",
"timezone": "America/Denver",
"duration_minutes": 60,
"staff_id": null,
"times": [
{
"start_at": "2026-06-15T09:00:00-06:00",
"end_at": "2026-06-15T10:00:00-06:00",
"time": "09:00",
"label": "9:00 AM",
"staff": [
{ "id": 14, "name": "Jane Doe" },
{ "id": 15, "name": "Alex Smith" }
]
}
]
},
"meta": { "request_id": "...", "timestamp": "..." }
}
An empty times list means nothing is free that day (or the shop is closed). A drop-off service returns 422 with error.code: not_time_booked. A missing or badly formatted date, or a staff_id who does not do the service, returns 422. A service that is not yours returns 404.
Create a service booking
POST /v1/services/bookings
Requires services:write. Creates a standalone service booking with one or more service lines. Each line becomes a workshop job (awaiting drop-off, unless a same-day in-person drop-off applies).
Body
| Field | Type | Required | Description |
|---|---|---|---|
customer_id | integer | yes | The customer. |
services | array | yes | One or more service lines. |
services[].service_type_id | integer | yes | The service to book. |
services[].dropoff_date | date | drop-off services | Drop-off date (YYYY-MM-DD). |
services[].start_at | string | time services | The start time, from Offered times. ISO 8601 with an offset (2026-06-15T09:00:00-06:00), or YYYY-MM-DD HH:MM, read in your shop's timezone. |
services[].staff_id | integer | no | Who does it, for a time service. Must be on your team and do this service. Leave it out for anyone: the person the customer saw last if free, otherwise whoever has the least booked that day. |
services[].price | number | no | Override price (defaults to the service base price). |
pickup_location_id | integer | no | Drop-off/pickup location (multi-site suppliers). Used for per-location capacity. |
status | string | no | draft or confirmed (default confirmed). |
notes | string | no | Internal note. |
Request example
curl -X POST "https://app.equipdash.com/api/v1/services/bookings" \
-H "Authorization: Bearer eqd_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"customer_id": 501,
"pickup_location_id": 2,
"services": [
{ "service_type_id": 12, "dropoff_date": "2026-06-15", "price": 49.95 }
]
}'
Response 201
Returns the created booking (standard booking shape) with a services array of jobs:
{
"data": {
"id": 9001,
"type": "service",
"status": "confirmed",
"services": [
{
"id": 4502,
"booking_id": 9001,
"service_type_id": 12,
"service_name": "Edge tune & hot wax",
"lifecycle_state": "awaiting_dropoff",
"drop_off_date": "2026-06-15T00:00:00+00:00",
"ready_by": "2026-06-16T09:00:00+00:00",
"price": 49.95
}
]
},
"meta": { "request_id": "...", "timestamp": "..." }
}
A time line comes back with start_at, end_at and staff:
{
"id": 4510,
"service_type_id": 31,
"service_name": "Boot fitting",
"is_appointment": true,
"start_at": "2026-06-15T09:00:00-06:00",
"end_at": "2026-06-15T10:00:00-06:00",
"staff": { "id": 14, "name": "Jane Doe" }
}
The time is checked again when the booking is saved. If it was taken in the meantime, the request returns 422 and nothing is booked. A staff_id that is not on your team returns 422 with "Pick a person from your team."
If the chosen day is full at that location, the request returns 422 with error.code: service_booking_failed (or the underlying validation error).
List service jobs
GET /v1/services/jobs
Requires services:read. Lists customer service jobs (the workshop jobs tied to service bookings).
| Parameter | Type | Description |
|---|---|---|
location_id | integer | Only jobs whose booking is at this location. |
date | date | Only jobs with this drop-off date. |
state | string | awaiting_dropoff, in_workshop, ready, or picked_up. |
page, per_page | integer | Pagination. |
Each item is a job object (same shape as the services[] entry above, plus customer_id, customer_name, dropped_off_at, ready_at, picked_up_at, location_id). Every job carries is_appointment; for a time service it also has start_at, end_at (ISO 8601 in your shop's timezone) and staff ({ "id", "name" }, or null when nobody is assigned). These are null on drop-off jobs.
Update a job's status (lifecycle)
POST /v1/services/jobs/{id}/dropped-off
POST /v1/services/jobs/{id}/ready
POST /v1/services/jobs/{id}/picked-up
Requires services:write. Advances a single job:
- dropped-off - the customer handed the gear over. Starts the SLA clock and recomputes
ready_byrespecting opening hours. Moves the job toin_workshop. - ready - work is finished; the job is ready for pickup. Triggers the customer "ready for pickup" email.
- picked-up - the customer collected. When every job on the booking is picked up, the booking moves to Picked up.
Response
{
"data": {
"id": 4502,
"lifecycle_state": "in_workshop",
"ready_by": "2026-06-16T09:00:00+00:00",
"dropped_off_at": "2026-06-15T15:00:00+00:00"
},
"meta": { "request_id": "...", "timestamp": "..." }
}
Move an appointment
POST /v1/services/jobs/{id}/move
Requires services:write. A cancelled booking or a finished appointment is refused. Moves a time job to a new start time and, optionally, a new person. The same checks as booking it apply, and the job's own current time does not count against it, so its old time is free again once it moves.
| Field | Type | Required | Description |
|---|---|---|---|
start_at | string | yes | The new start, as on a booking line. |
staff_id | integer | no | The new person. Leave it out to keep the current person if they are free, otherwise anyone free. |
curl -X POST "https://app.equipdash.com/api/v1/services/jobs/4510/move" \
-H "Authorization: Bearer eqd_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "start_at": "2026-06-15T14:00:00-06:00" }'
Returns the job with its new start_at, end_at and staff. A time that is not free, or a drop-off job, returns 422 with error.code: appointment_not_moved and a message saying why. A job that is not yours returns 404.
MCP tools
The same operations are available to AI agents over MCP:
| Tool | Scope | Purpose |
|---|---|---|
list_services | services:read | List the catalog + per-location availability |
create_service_booking | services:write | Book a service for a customer |
update_service_job_status | services:write | Advance a job (dropped_off / ready / picked_up) |