REST API
Connect websites, automations, and internal tools to Grout.
Authentication
Create keys under Admin → API keys (/admin/api-keys, reached from
Settings in the sidebar).
Send the key with every request. Prefer the bearer header:
Authorization: Bearer grout_live_...
# or
x-api-key: grout_live_...Keys are stored as SHA-256 hashes and can be revoked or rotated. The full key is shown once, on the page, the moment you create or rotate it — copy it then and store it somewhere safe. It never appears in the address bar, and reloading the page will not show it again; if you lose it, revoke the key and create another. Each key has scopes. A request without its required scope returns insufficient_scope and identifies the missing scope. Grant only the scopes an integration needs and use a separate key for each integration.
The keys page
Create key in the page header opens the Create key page
(/admin/api-keys/new). Give the key a name — the tool that will use it — and
tick its permissions. It starts with read permissions only — catalog:read,
bookings:read, customers:read — and 120 requests per minute (anything from
10 to 6,000), so a key writes only if you tick a write permission on purpose.
Create key stays unavailable, with the reason beside it, until the key has
a name and at least one permission. Creating takes you back to the keys page,
which shows the new key once.
An active key's name opens its Edit key page, where you change its permissions and its rate limit; saving shows a "Permissions updated" banner. A revoked key can't be edited. Rotate and Revoke are in the … menu at the end of the key's row, and both ask first.
A key must carry at least one permission. Saving with every box cleared is refused, on create and on an existing key alike, with "Tick at least one permission — a key with none is refused on every request." Narrowing a key to nothing is not a way to disable it: revoke it instead.
Errors
Every failure uses one shape, so you only write one error handler:
{
"error": {
"code": "invalid_request",
"message": "Some fields are missing or invalid.",
"fieldErrors": { "address.zip": "Must be a 5-digit US zip code" },
"requestId": "0f4c…"
}
}code is stable and machine-readable; message is safe to show a person; fieldErrors
maps a field to what is wrong with it. The requestId is also returned as an
x-request-id header — quote it if you need help.
An id that names nothing — a typo, a stale id, or one belonging to another
company — is not_found (404), not internal_error. Those two cases give the
identical code and message on purpose, so nobody can learn which ids exist
elsewhere by comparing replies. internal_error (500) means the fault really is
ours: it is the only code worth retrying or escalating, and no reply ever carries
a database, ORM, or stack detail — that stays in our logs, findable by the
requestId.
Pagination
List endpoints use cursor pagination. Pass the nextCursor from the previous response.
GET /api/v1/bookings?limit=50
{ "data": [ … ], "hasMore": true, "nextCursor": "eyJ2IjoiMjAy…" }
GET /api/v1/bookings?limit=50&cursor=eyJ2IjoiMjAy…Most list endpoints accept updatedSince for incremental polling.
Idempotency
Writes that change money or the calendar require an Idempotency-Key header. This includes creating a booking or quote, acting on a quote, cancelling or rescheduling a booking, and recording or reversing a payment. Retrying the same body with the same key returns the original response. Reusing the key with a different body returns 409.
curl -X POST /api/v1/bookings \
-H "Authorization: Bearer grout_live_..." \
-H "Idempotency-Key: 7f3c9a1e-..." \
-H "Content-Type: application/json" \
-d '{ ... }'Rate limits
Every authenticated response includes x-ratelimit-limit, x-ratelimit-remaining, and x-ratelimit-reset — successes and refusals alike, so you can pace off a 404, a 422 or a 403 just as you would off a 200. Exceeding the limit returns 429 with retry-after. The only replies without these headers are the ones where no key was identified (401 unauthorized): there is no bucket to report yet.
Which company you reach
Grout is served at one address — https://app.<APP_DOMAIN> — and /api/v1 lives there. Your key names your company, and the endpoints take the company from the key alone: they do not look at the address you called, so a key cannot be pointed at another company by calling one. The example requests on Admin → API keys use this address.
Every customer-facing browser page — the booking form, the tracking page, the customer portal — names its company in the address instead, which is why the links in API replies carry it (see Conventions). The admin workspace at /admin and the crew portal at /crew take their company from whoever is signed in and carry no slug at all.
CORS
The unauthenticated /api/public/* endpoints the booking form calls (catalog, quote, slots, days, book, waitlist, booking-forms) answer cross-origin browser requests from any origin. Every response, success or error, carries Access-Control-Allow-Origin: *, and each route answers an OPTIONS preflight. No credentials (Access-Control-Allow-Credentials) are ever sent or accepted, since these endpoints read no cookie or session.
/api/v1 has no CORS on purpose. It authenticates with a secret key (Authorization: Bearer grout_live_...) that must stay server-side — call it from your backend, not a browser.
Field lengths and body size
Because the /api/public/* endpoints take no authentication, what they accept is bounded. A whole request body over 64 KB is refused 413 before its JSON is parsed, and a single field over its own ceiling is refused 422 naming the field and the limit — the request was understood and is too long, not malformed. Both refusals are worded for a person and carry the same { "error": "…" } shape as every other public refusal.
The ceilings: a name, city or street line in the low hundreds of characters (200, 120 and 200), an email address 254, a phone 40, access notes 2,000, one intake answer 500, a coupon or referral code 100. Repeated fields are capped by count too — 200 parameter or extra selections, 100 answers. If you drive the booking form yourself, put the same figures on your inputs as maxLength; the wizard we ship does, so a customer meets their browser's limit rather than a refusal from us.
Conventions
Money values use integer cents and rates use basis points. Times use ISO 8601. Bookings include a trackingUrl for the customer's public tracking page, and quotes a publicUrl for the quote the customer reads. Both are on your own domain once you've connected one to your website (see Your website), and on your Grout address — https://app.<APP_DOMAIN>/c/<your slug>/… — otherwise, because those pages resolve the company from the address they were opened at. Each URL carries a 256-bit random token as its own authority; treat it as a secret you pass to the customer, and expect a tracking URL to stop working roughly 90 days after the job is completed or cancelled.
Endpoints
| Method | Path | Description | Scope |
|---|---|---|---|
| GET | /api/v1/services | Bookable catalog: industries → services → parameters/extras, frequencies, and active fee definitions | catalog:read |
| POST | /api/v1/quote | Price a selection (server-authoritative; validates coupons and applies date, ZIP, and service fee rules) | catalog:read |
| GET | /api/v1/availability | Open start times for a service/date/zip, drive-time ranked | catalog:read |
| GET | /api/v1/bookings | List bookings — status, customer, crew, date window, updatedSince | bookings:read |
| POST | /api/v1/bookings | Create a booking, optionally recurring, multi-day, or several dates at once | bookings:write |
| GET | /api/v1/bookings/:id | Fetch by id or human code (BB-xxxxx) | bookings:read |
| POST | /api/v1/bookings/:id/cancel | Cancel one visit, this-and-future, or end a plan after a date | bookings:write |
| POST | /api/v1/bookings/:id/reschedule | Move to a new slot, validated against real availability. Admin-grade: not subject to the customer self-service reschedule cutoff, though an inside-cutoff move is noted in the activity log | bookings:write |
| GET | /api/v1/customers | List/search customers with churn risk & lifetime stats. A record merged into another is omitted, as it is from the Customers list | customers:read |
| POST | /api/v1/customers | Create a customer, or return the existing one for that email — the survivor, if that record has since been merged into another | customers:write |
| GET | /api/v1/leads | List enquiries by stage, owner, or recent change | leads:read |
| POST | /api/v1/leads | Capture an enquiry — folds into an open lead with matching contact | leads:write |
| GET | /api/v1/leads/:id | One lead with its full timeline | leads:read |
| PATCH | /api/v1/leads/:id | Move stage, reassign, or set a follow-up date | leads:write |
| GET | /api/v1/quotes | List quotes by status, lead, or customer | quotes:read |
| POST | /api/v1/quotes | Create a quote, optionally sending it immediately | quotes:write |
| GET | /api/v1/quotes/:id | One quote and its scheduled follow-ups | quotes:read |
| POST | /api/v1/quotes/:id | send · accept · reject · convert · pause · resume. Accept and reject answer 422 on a quote already answered, booked or past its expiry; convert books the quote — see Converting a quote; send answers 422 on a draft whose expiry date has passed | quotes:write |
| GET | /api/v1/payments | The ledger, filtered by customer, booking, or date | payments:read |
| POST | /api/v1/payments | Record money received outside Grout | payments:write |
| PATCH | /api/v1/payments | Reverse an entry recorded by hand (card money is refunded, not reversed; a card entry, or spent credit, answers 422) | payments:write |
| GET | /api/v1/crew | Active crew members (not teams) with service areas, the services each crew member doesn't do (excludedServiceIds), and the deprecated serviceIds | crews:read |
| GET | /api/v1/webhooks | Your webhook endpoints | webhooks:manage |
| POST | /api/v1/webhooks | Register an endpoint — the secret is returned once | webhooks:manage |
| PATCH | /api/v1/webhooks/:id | Change the URL, subscription, or active state | webhooks:manage |
| DELETE | /api/v1/webhooks/:id | Stop delivering to an endpoint | webhooks:manage |
A webhook endpoint URL must be a public https address; loopback, private, and
reserved destinations are refused. See Webhooks.
An extra in the catalog carries frequencySlugs — the plans it is offered on,
an empty list meaning all of them — alongside discountExempt and
firstVisitOnly. Read it before you build a selection: quoting or booking an
extra on a frequency outside its list is refused with unprocessable, naming
the extra, rather than priced with the line quietly dropped.
Converting a quote
POST /api/v1/quotes/:id with action: "convert" books an accepted quote, or
a sent one still in date (a yes on the phone; it is marked accepted first), and
marks it converted, in one step:
{ "action": "convert", "startIso": "2026-10-20T10:00:00Z", "serviceId": "svc_…", "manualFeeIds": ["fee_…"] }startIso— the visit's start, ISO 8601, on the quarter hour and not in the past.serviceId— only for a quote priced by hand (no catalog service): the service the booking is filed under.manualFeeIds— optional manual fees to add to the booking.- The key needs
bookings:writeas well asquotes:write, since a booking is made. - The reply is the converted
quoteand the newbookingId.
The quote is checked and claimed before any booking exists, so a refusal
(422 — a draft, a declined or expired quote, one that already has a booking,
or a hand-priced quote with no serviceId) or a second convert sent at the same
moment leaves no booking behind. The booking uses the customer's default saved
address.
Deprecated: bookingId. { "action": "convert", "bookingId": "…" } still
links a booking you made yourself with POST /api/v1/bookings. It answers 422
unless the quote is accepted, or sent and in date, and has no booking yet — so
ask with GET /api/v1/quotes/:id first, or move to startIso, which can't
leave a booking you no longer need behind.
Changes
2026-10-13 — teams leave the crew list. GET /api/v1/crew lists crew
members only, and no longer carries kind. Before, a team came back as one
more crew member with kind: "TEAM" (and every person with
kind: "INDIVIDUAL"). A team is now its own record — a group of crew members
booked together — and teams are not in the API yet. A team's job is still
each member's job, so GET /api/v1/bookings?crewId= finds it under any of them.
2026-10-13 — crew services. Each crew member in GET /api/v1/crew now
carries excludedServiceIds: the services they don't do. They can be given
every other service. serviceIds is deprecated and will be removed after
2027-01-13. Until then it lists the company's active services they may be
given, always in full. Before, it listed only the services they were cleared
for, and an empty list meant every service.
2026-10-13 — converting a quote. convert takes startIso (with
serviceId for a hand-priced quote, and optional manualFeeIds) and makes the
booking itself, refusing before any booking is made; it needs bookings:write
too. Before, convert only took a bookingId you had already created, and
linked it whatever state the quote was in — a draft, a declined or expired
quote, or one already converted, whose booking it overwrote. That bookingId
form is deprecated and now answers 422 in those cases. Also new: send
answers 422 on a draft whose expiry date has passed, and reject answers
422 on a quote already answered, booked or expired (before, it recorded the
decline over any status).
2026-10-10 — disconnecting Stripe. An admin can now disconnect the
company's Stripe account. Money that still moves on the old account — an
invoice paid there, a refund made in its Stripe dashboard, a dispute — is
written into the ledger as before, so it appears in GET /api/v1/payments and
fires payment.refunded as usual, and refunds made in Grout of payments taken
there still go back on it.
2026-10-09 — refunds and disputes made in Stripe. A refund the company
makes in its own Stripe dashboard now appears in GET /api/v1/payments as a
REFUND (or TIP_REFUND), and a dispute as a new kind, DISPUTE: a negative
amount that counts against the booking's paid total until the bank returns it,
when a REVERSAL (or TIP) puts it back. A disputed tip is written as a
TIP_REFUND, not a DISPUTE, so it stays out of the job's balance; its reverses names the dispute
entry when the whole of it came back. Each of these has the payment's Stripe PaymentIntent in externalRef.
The payment.refunded webhook fires for a dashboard refund of job money, with
bookingId, amountCents and note: "Refunded in Stripe".
2026-10-08 — tip refunds. A card tip can now be refunded. It appears in
GET /api/v1/payments as a new kind, TIP_REFUND: a negative amount whose
reverses names the tip. Like TIP, it is not part of a booking's paid total.
The payment.refunded webhook fires for it with kind: "TIP_REFUND" and
reverses set to the tip's id, alongside bookingId, amountCents and note.
2026-10-08 — wallet credit withdrawn. Grout no longer keeps a credit balance for customers, so this is a breaking change for anything that read it:
creditCentsis gone fromGET /api/v1/customersandPOST /api/v1/customers.- No payment carries
method: "CREDIT"any more. Earlier ones were replaced by entries of the same amount, kind, booking and dates withmethod: "OTHER",offline: trueand a note saying wallet credit settled them, so balances did not move. Since 2026-10-09 these cannot be reversed:PATCH /api/v1/paymentsanswers422. A refund of money that did not come in on a card now appears asmethod: "OFFLINE",offline: true— the company paid it back by hand. - A booking's
pricemay carryreferralDiscountCents: the one-time referral discount taken off that visit, before tax.
Example: create a booking
curl -X POST "https://app.example.com/api/v1/bookings" \
-H "Authorization: Bearer grout_live_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"serviceId": "service_id_from_catalog",
"frequencySlug": "one_time",
"startIso": "2026-08-10T09:00:00-05:00",
"customer": {"name": "Jane Doe", "email": "jane@example.com", "phone": "555-0100"},
"address": {"line1": "1 Main St", "city": "New York", "state": "NY", "zip": "10001"}
}'The machine-readable OpenAPI 3.1 specification lives at /api/openapi.json. You can also use the API playground.