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

MethodPathDescriptionScope
GET/api/v1/servicesBookable catalog: industries → services → parameters/extras, frequencies, and active fee definitionscatalog:read
POST/api/v1/quotePrice a selection (server-authoritative; validates coupons and applies date, ZIP, and service fee rules)catalog:read
GET/api/v1/availabilityOpen start times for a service/date/zip, drive-time rankedcatalog:read
GET/api/v1/bookingsList bookings — status, customer, crew, date window, updatedSincebookings:read
POST/api/v1/bookingsCreate a booking, optionally recurring, multi-day, or several dates at oncebookings:write
GET/api/v1/bookings/:idFetch by id or human code (BB-xxxxx)bookings:read
POST/api/v1/bookings/:id/cancelCancel one visit, this-and-future, or end a plan after a datebookings:write
POST/api/v1/bookings/:id/rescheduleMove 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 logbookings:write
GET/api/v1/customersList/search customers with churn risk & lifetime stats. A record merged into another is omitted, as it is from the Customers listcustomers:read
POST/api/v1/customersCreate a customer, or return the existing one for that email — the survivor, if that record has since been merged into anothercustomers:write
GET/api/v1/leadsList enquiries by stage, owner, or recent changeleads:read
POST/api/v1/leadsCapture an enquiry — folds into an open lead with matching contactleads:write
GET/api/v1/leads/:idOne lead with its full timelineleads:read
PATCH/api/v1/leads/:idMove stage, reassign, or set a follow-up dateleads:write
GET/api/v1/quotesList quotes by status, lead, or customerquotes:read
POST/api/v1/quotesCreate a quote, optionally sending it immediatelyquotes:write
GET/api/v1/quotes/:idOne quote and its scheduled follow-upsquotes:read
POST/api/v1/quotes/:idsend · 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 passedquotes:write
GET/api/v1/paymentsThe ledger, filtered by customer, booking, or datepayments:read
POST/api/v1/paymentsRecord money received outside Groutpayments:write
PATCH/api/v1/paymentsReverse an entry recorded by hand (card money is refunded, not reversed; a card entry, or spent credit, answers 422)payments:write
GET/api/v1/crewActive crew members (not teams) with service areas, the services each crew member doesn't do (excludedServiceIds), and the deprecated serviceIdscrews:read
GET/api/v1/webhooksYour webhook endpointswebhooks:manage
POST/api/v1/webhooksRegister an endpoint — the secret is returned oncewebhooks:manage
PATCH/api/v1/webhooks/:idChange the URL, subscription, or active statewebhooks:manage
DELETE/api/v1/webhooks/:idStop delivering to an endpointwebhooks: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:write as well as quotes:write, since a booking is made.
  • The reply is the converted quote and the new bookingId.

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:

  • creditCents is gone from GET /api/v1/customers and POST /api/v1/customers.
  • No payment carries method: "CREDIT" any more. Earlier ones were replaced by entries of the same amount, kind, booking and dates with method: "OTHER", offline: true and a note saying wallet credit settled them, so balances did not move. Since 2026-10-09 these cannot be reversed: PATCH /api/v1/payments answers 422. A refund of money that did not come in on a card now appears as method: "OFFLINE", offline: true — the company paid it back by hand.
  • A booking's price may carry referralDiscountCents: 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.

On this page