Loading
Loading
Developer docs · REST API
Live — read-only today
Customers, appointments, and invoices are readable right now with a real key from Settings → API Keys. Only GET endpoints exist — creating, updating, or deleting records through this API isn't available yet. Need push instead of pull? See the Webhooks docs.
A reference for the live SalesThumb REST API: base URL, authentication, pagination, rate limits, scopes, and the customers, appointments, and invoices endpoints.
The SalesThumb REST API lets you read shop data programmatically. Today it covers exactly three resources — customers, appointments, and invoices — each with a list endpoint and a get-by-ID endpoint (six endpoints total, all GET). All requests and responses are JSON, every row is scoped to the one shop your key belongs to, and the API is versioned (the current and only version is v1).
If you only need to react to events (e.g. a new invoice was paid), prefer webhooks — they require no polling, cover create/update/delete-shaped events, and are available on all plans today.
Requests authenticate with an API key issued from Settings → API Keys, passed as a bearer token in the Authorization header — not as a query parameter, a custom header, or in the request body.
GET /api/v1/customers HTTP/1.1
Host: yourshop.salesthumb.com
Authorization: Bearer st_live_••••••••••••••••••••••••••••••••
Accept: application/jsonLanguage: httpA key resolves to exactly one shop — it can never read another shop's data, even if you manage multiple shops under the same account. Every request also needs the scope for the resource it's calling; see Scopes below.
401 { "error": "Missing or invalid API key" } — deliberately non-specific, so a failed request never tells a caller which of those is true.https://app.salesthumb.com/api/v1Language: textThere is no separate api.salesthumb.com host — every endpoint lives under /api/v1 on the same domain the app runs on. Your shop's own domain (e.g. yourshop.salesthumb.com) works identically to app.salesthumb.com: the API key in the Authorization header is what determines which shop's data a request can see, not the hostname you call it on.
All endpoints in this document are relative to that base. A breaking change, if one is ever needed, ships under a new version prefix (e.g. /api/v2) rather than changing v1 underneath you. Pin your code to named JSON fields, not positional assumptions, to stay forward-compatible with additive changes.
All three list endpoints use cursor-based pagination. Pass limit (default 25, max 100 — anything higher is silently clamped) and cursor (the id of the last item from the previous page) to page forward.
GET /api/v1/customers?limit=25&cursor=3fa85f64-5717-4562-b3fc-2c963f66afa6Language: httpEvery list response is a flat object with two fields:
{
"items": [ /* array of objects */ ],
"nextCursor": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}Language: jsonnextCursor is null on the last page — that's the only end-of-collection signal; there is no has_more flag or total count. GET /api/v1/appointments also accepts optional startDate and endDate query params (ISO date strings, inclusive) filtering on the appointment's start time.
The API allows 300 requests per minute per API key, in a rolling 60-second window. The limit is bucketed per key, not per shop or per IP — two keys on the same shop each get their own 300/min budget.
// Response 429
{
"error": "Rate limit exceeded"
}Language: jsonWhen you exceed the limit the API returns HTTP 429 with that body. No Retry-After or X-RateLimit-* headers are sent on any response today — implement your own backoff (waiting roughly a minute is a safe default) rather than reading response headers for a retry hint.
Every key carries one or more scopes, chosen when the key is created (Settings → API Keys offers Read-only, Read + write, and Admin presets). A request without the scope its endpoint requires gets a 403 that names the missing scope:
// Response 403
{
"error": "This API key does not have the \"read:customers\" scope"
}Language: json| Scope | Grants |
|---|---|
| read:customers | GET /api/v1/customers and GET /api/v1/customers/{id} |
| read:appointments | GET /api/v1/appointments and GET /api/v1/appointments/{id} |
| read:invoices | GET /api/v1/invoices and GET /api/v1/invoices/{id} |
| write:customers / write:appointments / write:invoices | Reserved for future create/update endpoints. Selectable when creating a key today, but nothing enforces them yet — there's nothing to write to. |
| admin:* | Wildcard — satisfies every scope check above, present or future. |
Every error response is a flat JSON object with a single error string, matching the HTTP status:
{
"error": "Missing or invalid API key"
}Language: json| Status | error message | Meaning |
|---|---|---|
| 400 | Invalid cursor | The cursor query param isn't a UUID, or doesn't match a row this key can see. |
| 401 | Missing or invalid API key | The Authorization header is absent, malformed, or doesn't match an active key. Deliberately the same message whether the key is wrong, revoked, or expired — it never says which, so a failed request can't be used to narrow a guess. |
| 403 | This API key does not have the "read:customers" scope | The key authenticated fine but doesn't carry the scope this endpoint requires. The message names the missing scope so you know what to re-mint. |
| 404 | Customer not found | No row with that id exists for this key's shop. A real id belonging to another shop 404s identically — it never leaks that the row exists elsewhere. |
| 429 | Rate limit exceeded | More than 300 requests in the last 60 seconds on this key. No Retry-After header is sent — back off on a timer, don't wait for one. |
| 500 | Internal server error | Something went wrong on our end. Safe to retry. |
A Customer represents a person or business the shop has scheduled, quoted, or invoiced. Requires the read:customers scope.
| Field | Type | Description |
|---|---|---|
| id | string (UUID) | Unique customer identifier. |
| firstName | string | First name. |
| lastName | string | Last name. |
| string | null | Email address. | |
| phone | string | null | Phone number. |
| companyName | string | null | Business name, for fleet/B2B customers. |
| isFleet | boolean | Whether this is a fleet/business account. |
| address1 | string | null | Street address line 1. |
| address2 | string | null | Street address line 2. |
| city | string | null | City. |
| state | string | null | State/province. |
| zipCode | string | null | Postal code. |
| tags | string[] | Shop-defined tags on the customer. |
| marketingOptIn | boolean | Whether the customer opted into marketing messages. |
| createdAt | ISO 8601 datetime | When the record was created. |
| updatedAt | ISO 8601 datetime | Last modification timestamp. |
/api/v1/customersList customers for the shop, newest first. Query params: limit, cursor.
// Response 200
{
"items": [
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"firstName": "Jordan",
"lastName": "Rivera",
"email": "jordan@example.com",
"phone": "+15125550182",
"companyName": null,
"isFleet": false,
"address1": null,
"address2": null,
"city": null,
"state": null,
"zipCode": null,
"tags": [],
"marketingOptIn": true,
"createdAt": "2026-03-15T10:22:00.000Z",
"updatedAt": "2026-04-01T08:00:00.000Z"
}
],
"nextCursor": null
}Language: json/api/v1/customers/{id}Retrieve a single customer by ID. A customer belonging to another shop 404s, identically to an id that doesn't exist at all.
// Response 200
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"firstName": "Jordan",
"lastName": "Rivera",
"email": "jordan@example.com",
"phone": "+15125550182",
"companyName": null,
"isFleet": false,
"address1": null,
"address2": null,
"city": null,
"state": null,
"zipCode": null,
"tags": [],
"marketingOptIn": true,
"createdAt": "2026-03-15T10:22:00.000Z",
"updatedAt": "2026-04-01T08:00:00.000Z"
}
// Response 404
{ "error": "Customer not found" }Language: jsonAn Appointment is a scheduled service visit. status is one of PENDING_DEPOSIT, SCHEDULED, CONFIRMED, CHECKED_IN, IN_PROGRESS, READY, COMPLETED, NO_SHOW, or CANCELED. Requires the read:appointments scope.
| Field | Type | Description |
|---|---|---|
| id | string (UUID) | Unique appointment ID. |
| customerId | string (UUID) | Associated customer. |
| vehicleId | string (UUID) | null | Associated vehicle, if any. |
| serviceId | string (UUID) | null | Booked service, if any. |
| startAt | ISO 8601 datetime | Appointment start time. |
| endAt | ISO 8601 datetime | Appointment end time. |
| status | enum | See the status list above. |
| title | string | null | Free-text title/label. |
| notes | string | null | Internal notes for the technician. |
| source | string | null | manual | booking_page | import. |
| isMobileAppointment | boolean | Whether this is an at-customer mobile job. |
| createdAt | ISO 8601 datetime | Creation timestamp. |
| updatedAt | ISO 8601 datetime | Last updated timestamp. |
/api/v1/appointmentsList appointments for the shop, newest first. Query params: limit, cursor, startDate, endDate.
GET /api/v1/appointments?startDate=2026-06-01&endDate=2026-06-30&limit=10Language: http/api/v1/appointments/{id}Retrieve a single appointment by ID. An appointment belonging to another shop 404s, identically to an id that doesn't exist at all.
// Response 404
{ "error": "Appointment not found" }Language: jsonAn Invoice represents money owed by a customer. status is one of DRAFT, SENT, PARTIAL, PAID, VOID, or REFUNDED. Requires the read:invoices scope.
| Field | Type | Description |
|---|---|---|
| id | string (UUID) | Unique invoice ID. |
| customerId | string (UUID) | Associated customer. |
| vehicleId | string (UUID) | null | Associated vehicle, if any. |
| number | string | null | Shop-facing invoice number. |
| status | enum | See the status list above. |
| subtotalCents | integer | Sum of line items, in US cents. |
| taxCents | integer | Tax amount, in US cents. |
| discountCents | integer | Discount amount, in US cents. |
| feesCents | integer | Shop fees applied, in US cents. |
| totalCents | integer | Total due, in US cents. |
| depositAppliedCents | integer | Deposit credited toward this invoice, in US cents. |
| balanceCents | integer | Remaining balance, in US cents. |
| dueDate | date (YYYY-MM-DD) | null | Payment due date. |
| notes | string | null | Customer-facing notes. |
| sentAt | ISO 8601 datetime | null | When the invoice was sent. |
| paidAt | ISO 8601 datetime | null | When it was fully paid. |
| createdAt | ISO 8601 datetime | Creation timestamp. |
| updatedAt | ISO 8601 datetime | Last updated timestamp. |
/api/v1/invoicesList invoices for the shop, newest first. Query params: limit, cursor.
/api/v1/invoices/{id}Retrieve a single invoice by ID. An invoice belonging to another shop 404s, identically to an id that doesn't exist at all.
// Response 200
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"customerId": "5b1e4a2c-8f0d-4a3e-9c7b-1a2b3c4d5e6f",
"vehicleId": null,
"number": "INV-1042",
"status": "SENT",
"subtotalCents": 82900,
"taxCents": 5040,
"discountCents": 0,
"feesCents": 0,
"totalCents": 87940,
"depositAppliedCents": 0,
"balanceCents": 87940,
"dueDate": "2026-06-10",
"notes": null,
"sentAt": "2026-05-25T14:20:00.000Z",
"paidAt": null,
"createdAt": "2026-05-25T14:20:00.000Z",
"updatedAt": "2026-05-25T14:20:00.000Z"
}Language: jsonEmail info@roffik.com with the use case. We prioritize by real customer demand.
More developer docs