Skip to content

Developer docs · REST API

REST API reference

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.

API key auth300 req/minCursor paginationScoped keys

Overview

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 creation and status-change events, and are available on all plans today.

Authentication

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: http

A 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.

A missing, malformed, revoked, or expired key — or a key whose shop has been deactivated — all return the identical 401 { "error": "Missing or invalid API key" } — deliberately non-specific, so a failed request never tells a caller which of those is true. Reactivating a deactivated shop makes its existing keys work again.
Never expose API keys in client-side JavaScript. All requests must originate from your backend. Revoke a key immediately from Settings if you suspect it has been compromised — revoking takes effect immediately and breaks anything still using it.

Base URL & versioning

https://app.salesthumb.com/api/v1Language: text

There 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.

Pagination

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: http

Every list response is a flat object with two fields:

{
  "items": [ /* array of objects */ ],
  "nextCursor": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}Language: json

nextCursor 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, compared against the appointment's start time; both ends inclusive). A date-only value means midnight UTC at the start of that day, so a date-only endDate excludes appointments later that day — pass a full timestamp such as 2026-06-30T23:59:59Z to include the whole final day.

Rate limiting

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: json

When 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.

Scopes

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
ScopeGrants
read:customersGET /api/v1/customers and GET /api/v1/customers/{id}
read:appointmentsGET /api/v1/appointments and GET /api/v1/appointments/{id}
read:invoicesGET /api/v1/invoices and GET /api/v1/invoices/{id}
write:customers / write:appointments / write:invoicesReserved 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. Only on keys SalesThumb support issues for you; a key a team member creates can't carry it. A team member's key can only reach what that person can see in the app, and stops working if they're removed or lose access to integrations.

Error codes

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
Statuserror messageMeaning
400Invalid cursorThe cursor query param isn't a UUID, or doesn't match a row this key can see.
401Missing or invalid API keyThe Authorization header is absent, malformed, or doesn't match an active key — including a key whose shop has been deactivated. Deliberately the same message whether the key is wrong, revoked, expired, or belongs to a deactivated shop — it never says which, so a failed request can't be used to narrow a guess.
403This API key does not have the "read:customers" scopeThe 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.
404Customer not foundNo 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.
429Rate limit exceededMore 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.
500Internal server errorSomething went wrong on our end. Safe to retry.

Customers

A Customer represents a person or business the shop has scheduled, quoted, or invoiced. Requires the read:customers scope.

FieldTypeDescription
idstring (UUID)Unique customer identifier.
firstNamestringFirst name.
lastNamestringLast name.
emailstring | nullEmail address.
phonestring | nullPhone number.
companyNamestring | nullBusiness name, for fleet/B2B customers.
isFleetbooleanWhether this is a fleet/business account.
address1string | nullStreet address line 1.
address2string | nullStreet address line 2.
citystring | nullCity.
statestring | nullState/province.
zipCodestring | nullPostal code.
tagsstring[]Shop-defined tags on the customer.
marketingOptInbooleanWhether the customer opted into marketing messages.
createdAtISO 8601 datetimeWhen the record was created.
updatedAtISO 8601 datetimeLast modification timestamp.
GET/api/v1/customers

List 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
GET/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: json

Appointments

An 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.

FieldTypeDescription
idstring (UUID)Unique appointment ID.
customerIdstring (UUID)Associated customer.
vehicleIdstring (UUID) | nullAssociated vehicle, if any.
serviceIdstring (UUID) | nullBooked service, if any.
startAtISO 8601 datetimeAppointment start time.
endAtISO 8601 datetimeAppointment end time.
statusenumSee the status list above.
titlestring | nullFree-text title/label.
notesstring | nullInternal notes for the technician.
sourcestring | nullmanual | booking_page | hq_central | import.
isMobileAppointmentbooleanWhether this is an at-customer mobile job.
createdAtISO 8601 datetimeCreation timestamp.
updatedAtISO 8601 datetimeLast updated timestamp.
GET/api/v1/appointments

List appointments for the shop, newest first. Query params: limit, cursor, startDate, endDate.

GET /api/v1/appointments?startDate=2026-06-01&endDate=2026-06-30T23:59:59Z&limit=10Language: http
GET/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: json

Invoices

An Invoice represents money owed by a customer. status is one of DRAFT, SENT, PARTIAL, PAID, VOID, or REFUNDED. Requires the read:invoices scope.

FieldTypeDescription
idstring (UUID)Unique invoice ID.
customerIdstring (UUID)Associated customer.
vehicleIdstring (UUID) | nullAssociated vehicle, if any.
numberstring | nullShop-facing invoice number.
statusenumSee the status list above.
subtotalCentsintegerSum of line items, in US cents.
taxCentsintegerTax amount, in US cents.
discountCentsintegerDiscount amount, in US cents.
feesCentsintegerShop fees applied, in US cents.
totalCentsintegerTotal due, in US cents.
depositAppliedCentsintegerDeposit credited toward this invoice, in US cents.
balanceCentsintegerRemaining balance, in US cents.
dueDatedate (YYYY-MM-DD) | nullPayment due date.
notesstring | nullCustomer-facing notes.
sentAtISO 8601 datetime | nullWhen the invoice was sent.
paidAtISO 8601 datetime | nullWhen it was fully paid.
createdAtISO 8601 datetimeCreation timestamp.
updatedAtISO 8601 datetimeLast updated timestamp.
GET/api/v1/invoices

List invoices for the shop, newest first. Query params: limit, cursor.

GET/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: json

Need an endpoint we don't document yet?

Email hello@salesthumb.com with the use case. We prioritize by real customer demand.