REST API
Everything an assistant can do through MCP is also a plain HTTP request, authenticated with the same MCP key. Use this if you are writing a script, a scheduled job, or an agent runtime of your own.
If you just want to connect an off-the-shelf assistant, you do not need this page — see MCP keys or Run the connector on your machine.
Base URL
https://sea-appointment-booking.firebaseapp.com/rest_api/v2🔑 Authenticating
Send your key as a bearer token. Nothing else identifies the store — the key already belongs to one.
curl https://sea-appointment-booking.firebaseapp.com/rest_api/v2/services \
-H "Authorization: Bearer mcp_your-key-here"Access tokens issued to an assistant that signed in with Shopify work here too, with the permissions granted on the approval screen.
📦 Responses
Every answer uses the same envelope.
{"success": true, "data": [...], "meta": {"count": 12}}{"success": false, "error": "Service not found", "code": "NOT_FOUND"}code is stable and safe to branch on; error is a sentence meant to be read.
| Status | code | What to do |
|---|---|---|
| 401 | AUTH_FAILED | The key is wrong or was deleted. Do not retry |
| 401 | KEY_EXPIRED | Create a new key under Settings > Developers |
| 403 | SCOPE_DENIED | The key lacks that permission — the message names it. Do not retry |
| 404 | NOT_FOUND | Wrong id, or the record belongs to another store |
| 400 | VALIDATION_FAILED | Fix the request body; the same call will fail again unchanged |
| 429 | RATE_LIMITED | 60 requests per minute per key. Wait for Retry-After |
🪪 What this key can do
GET /whoamiReturns the store, the permissions on the key, and fieldGroups — the map of which fields each
edit permission may write. Read it once at startup rather than hard-coding field lists.
{
"success": true,
"data": {
"shopId": "…", "shopDomain": "demo.myshopify.com",
"keyName": "Support agent", "active": true,
"read": ["service", "appointment"],
"actions": ["service.edit_general"],
"fieldGroups": {"service": {"service.edit_general": ["price", "status", "…"]}, "…": {}}
}
}👀 Reading
| Endpoint | Permission | Notes |
|---|---|---|
GET /dashboard/stats | Dashboard | startDate, endDate optional — defaults to the last 30 days |
GET /services | Service | searchText, status, limit (1–100, default 20) |
GET /services/:id | Service | |
GET /packages | Package | searchText, status, limit |
GET /packages/:id | Package | |
GET /appointments | Appointment | status, paymentStatus, serviceId, staffId, startDate, endDate, limit |
GET /appointments/:id | Appointment | |
GET /staffs · GET /staffs/:id | Staff | |
GET /customers · GET /customers/:id | Customer | |
GET /customers/:id/appointments | Customer | Booking history for one customer |
GET /settings | Setting | SMTP credentials are never included |
GET /integrations/:provider | Manage integrations | connected: false rather than 404 when nothing is connected |
✍️ Writing
| Endpoint | Permission |
|---|---|
POST /services | Create service |
PUT /services/:id | Any Service edit permission |
DELETE /services — body {"ids": ["…"]} | Delete service |
POST /packages | Create package |
PUT /packages/:id | Any Package edit permission |
DELETE /packages/:id | Delete package |
POST /appointments | Create appointment |
PUT /appointments/:id | Edit appointment |
POST /appointments/:id/reschedule | Reschedule / cancel |
PUT /settings | Any Setting edit permission |
DELETE /integrations/:provider | Manage integrations |
One endpoint, tab-shaped permissions
There is a single PUT per record, but permissions are granted per edit tab. A request only writes
the fields the key's permissions cover; anything else is dropped and named back to you:
{
"updated": true,
"data": {"...": "..."},
"ignoredFields": ["timeSlots"],
"note": "Fields outside this permission were ignored."
}So a key holding only Service > General can send price and timeSlots together — the price
changes, availability does not, and ignoredFields says so. Never assume a write landed because the
call returned 200; check ignoredFields.
GET /whoami → fieldGroups tells you which fields a given permission covers, so you can avoid
sending fields that will be dropped.
Every POST that creates a record — services, packages and appointments alike — answers in this
same shape, so the record you just made arrives under data and anything the endpoint would not
accept is listed beside it rather than dropped in silence.
Some fields are never writable, whatever the key holds — package totals, money and order links on
appointments, record identity, SMTP credentials. They appear in ignoredFields too. See
What each permission unlocks.
🕐 Dates and times
Dates and times are wall-clock, never UTC. On POST /appointments they are read in timezone —
the customer's — which defaults to the store's when you leave it out; read the store's own from
GET /settings. The booking is stored in both frames, so a customer abroad is booked in one call.
On POST /appointments/:id/reschedule the same customer timezone is called clientTimezone, and
the fields carrying the new slot are shopDayStart / shopTimeStart / shopTimeEnd — they are read
in clientTimezone despite the shop prefix. A timezone sent to that endpoint is the store's own,
which the app fills in itself: it comes back in ignoredFields rather than being written.
Send all three slot fields or none. A reschedule naming only part of a slot answers 400 saying
which are missing: a partial slot used to rewrite the time shown on the booking while leaving the
booking itself where it was, so the calendar, the reminder email and the widget all disagreed with
the record.
curl -X POST https://sea-appointment-booking.firebaseapp.com/rest_api/v2/appointments \
-H "Authorization: Bearer mcp_your-key-here" \
-H "Content-Type: application/json" \
-d '{
"serviceId": "svc_123",
"timezone": "Asia/Bangkok",
"date": "2026-09-01",
"startTime": "10:00",
"customerEmail": "[email protected]",
"customerName": "Alex"
}'Price, duration and staff come from the service, so only the slot and the customer are needed. A
service that runs at a store branch needs locationId too once the store has more than one — the
call answers 400 listing the branches to choose from. Every new booking is written APPROVED
and UNPAID; status and paymentStatus sent to this endpoint land in ignoredFields, and
moving either afterwards is PUT /appointments/:id under the Edit appointment permission.
The slot itself has to be one the store would sell: in the future, past the service's minimum notice,
and inside its booking window. One that is not answers 400 VALIDATION_FAILED naming which of the
three it broke, on this endpoint and on reschedule alike.
Changelog
Changes to this API are listed here, newest first. A field that stops being accepted comes back in
ignoredFields rather than erroring, so a caller that never reads it sees only silence.
2026-08 — appointment writes
This is the shape v2 launches with. The entries below record what moved during the preview, for anyone who wrote against a staging build before release — none of them break a published caller, because there was not one yet.
POST /appointmentsacceptstimezone(the customer's) andlocationId(the branch). Dates and times were previously read in the store's timezone with no way to say otherwise.POST /appointmentsno longer acceptsstatusorpaymentStatus. Every booking is created APPROVED and UNPAID; both now appear inignoredFields. UsePUT /appointments/:id.POST /appointments/:id/rescheduleno longer acceptstimezone— the store's timezone is the store's to state, and it now appears inignoredFields.clientTimezoneis unchanged and is still how you name the customer's.POST /appointments/:id/reschedulerequires the whole slot —shopDayStart,shopTimeStartandshopTimeEndtogether — or none of it. Sending part of one now answers400; it previously wrote the new wall-clock without moving the booking.- Cancelling a paid booking now requires
refundCustomer. Cancellation is the only moment a refund can be issued from the app, so leaving it unsaid answers400instead of quietly keeping the money. POST /appointmentsanswers{"updated": true, "data": {…}}, the same shape asPOST /servicesandPOST /packages— so the booking is underdata.data, not at the top level. The three create endpoints had drifted apart during the preview; this is what makes them agree.- Bookings are held to the service's own booking window — past dates, its booking range and its minimum notice — on both endpoints. These rules ran only in the storefront's slot grid before.
Limits
- 60 requests per minute per key. Beyond that you get
429withRetry-After. - A key reaches exactly one store — there is no cross-store call, and an id from another store
answers
404. - Deleting or regenerating a key cuts off every caller using it immediately.