Suds API
Connect a hotel system, a fleet tool or your own software to a car wash on Suds. Read every job as it changes, check cars in and record payments, under the same rules as the counter.
Every car is a job. A job moves through these stages, and each move is one operation you send:
- job.createchecked_inThe car arrives
- job.transitionin_progressBeing washed
- job.transitionreadyWaiting for the customer
- payment.cashpaidMoney taken
- job.transitionreleasedKeys handed over
A job can also be booked (given a time to come), cancelled or voided. Money is in cents: KES 800 is 80000. Times are ISO 8601. Requests and answers are JSON.
What is in these docs
Keys and auth
The business owner makes keys in Settings › API in the Suds app. A key is shown once and kept only as a hash: a lost key is removed and replaced, never shown again.
Send it as a bearer token on every request. Keys start sk_live_ or sk_test_.
- Read only by default. Writing is switched on when the key is made.
- Can be tied to your servers' addresses. From anywhere else it is refused.
- Live keys work once Suds switches live access on for the business. Test keys always work.
curl https://app.suds.co.ke/api/v1/setup \
-H "Authorization: Bearer sk_test_..."Sandbox
Every business has a sandbox. Test keys work on a copy of its branches, vehicle classes, services and prices, with one made-up washer. Nothing a test key does reaches the real jobs, money or customers, and no WhatsApp or SMS is sent, so use any phone number.
Build against the sandbox, then swap in a live key. The ids differ between the two, so fetch them again from Setup with the live key.
Good to know: the sandbox menu is copied when the first test key is made. Later price changes in the real business are not copied over.
{
"business": { "name": "Sparkle Auto Spa (sandbox)" },
"staff": [{ "name": "Test Washer", "role": "attendant" }]
}Quickstart
Check a car in and read it back, in three calls.
1. Get the ids
Call Setup and keep a branch id, a vehicle class id and a service id.
2. Check the car in
Send job.create, then job.addService. You make the job id yourself, as a UUID, and use it from then on.
3. Read it back
Fetch the job. Its totalCents comes from the business's own price list.
JOB=$(uuidgen)
curl https://app.suds.co.ke/api/v1/ops \
-H "Authorization: Bearer $SUDS_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"checkin-412","kind":"job.create","payload":{
"jobId":"'$JOB'","branchId":"...","type":"walk_in",
"plateRaw":"KDA 412X","vehicleClassId":"...",
"customerName":"Room 412"}}'
curl https://app.suds.co.ke/api/v1/jobs/$JOB \
-H "Authorization: Bearer $SUDS_KEY"
const base = 'https://app.suds.co.ke/api/v1';
const headers = { authorization: `Bearer ${process.env.SUDS_KEY}`, 'content-type': 'application/json' };
const jobId = crypto.randomUUID();
await fetch(`${base}/ops`, { method: 'POST', headers, body: JSON.stringify({
id: 'checkin-412', kind: 'job.create',
payload: { jobId, branchId, type: 'walk_in', plateRaw: 'KDA 412X', vehicleClassId, customerName: 'Room 412' },
}) });
const job = await (await fetch(`${base}/jobs/${jobId}`, { headers })).json();
import os, uuid, requests
base = "https://app.suds.co.ke/api/v1"
headers = {"Authorization": f"Bearer {os.environ['SUDS_KEY']}"}
job_id = str(uuid.uuid4())
requests.post(f"{base}/ops", headers=headers, json={
"id": "checkin-412", "kind": "job.create",
"payload": {"jobId": job_id, "branchId": branch_id, "type": "walk_in",
"plateRaw": "KDA 412X", "vehicleClassId": class_id, "customerName": "Room 412"},
}).raise_for_status()
job = requests.get(f"{base}/jobs/{job_id}", headers=headers).json()Setup
Everything an id in a write refers to: the business, its branches, vehicle classes, services, current prices and active staff. Call it once, and again when the owner changes the menu.
| Field | What it holds |
|---|---|
branches | id, name, address |
vehicleClasses | id, name: saloon, SUV, pickup and so on |
services | id, name, category, pricing |
prices | service_id, vehicle_class_id (null for goods), amount_cents |
staff | id, name, role, for job.assign |
{
"business": { "id": "0190c2...", "name": "Sparkle Auto Spa",
"business_kind": "carwash", "timezone": "Africa/Nairobi" },
"branches": [{ "id": "0190c2...", "name": "Main", "address": "Ngong Road" }],
"vehicleClasses": [{ "id": "0190c3...", "name": "Saloon" }],
"services": [{ "id": "0190c4...", "name": "Full Wash",
"category": "wash", "pricing": "fixed" }],
"prices": [{ "service_id": "0190c4...", "vehicle_class_id": "0190c3...",
"amount_cents": 80000 }],
"staff": [{ "id": "0190c5...", "name": "Brian Kiprop", "role": "attendant" }]
}Changes
Every job, shift and expense, as it is now, in the order it changed. Keep the cursor from each answer and send it back as after. When more is true, ask again straight away; otherwise poll every few seconds, or use webhooks.
| Parameter | Meaning |
|---|---|
after | The cursor from your last call. 0 for everything. |
limit | 1 to 500. Default 200. |
{
"changes": [{
"seq": 1042, "entity": "job", "id": "0190d4a2...",
"op": "upsert", "at": "2026-11-14T09:12:03Z",
"data": { /* the job, as in GET /jobs/{id} */ }
}],
"cursor": 1042,
"more": false
}A job
One job as it is now: the car, the customer, every service line with who did it, every payment, and the totals.
| Field | Meaning |
|---|---|
status | booked, checked_in, in_progress, ready, paid, released, cancelled or voided |
totalCents | After discount. paidCents is what has been taken so far. |
services | Lines: name, quantity, unitPriceCents, lineTotalCents, workerName |
payments | method, status, amountCents, settledAt, reference |
{
"id": "0190d4a2-6b1e-7c3a-9f10-2a3b4c5d6e7f",
"jobNumber": "A-0142",
"type": "walk_in",
"status": "ready",
"plateRaw": "KDA 412X",
"vehicleClass": "Saloon",
"customerName": "Room 412",
"subtotalCents": 80000,
"discountCents": 0,
"totalCents": 80000,
"paidCents": 0,
"services": [{ "name": "Full Wash", "quantity": 1,
"unitPriceCents": 80000, "lineTotalCents": 80000,
"workerName": "Brian Kiprop" }],
"payments": []
}Operations
Every write is one operation, the same thing the Suds tablet sends, so the business's own rules apply: prices come from its list, and a car cannot leave unpaid unless it is on an account. The key needs write access.
| Field | Meaning |
|---|---|
idrequired | Yours, up to 100 characters, unique for this write. Send the same id again and nothing happens twice. |
kindrequired | One of the operations below. |
payloadrequired | The operation's fields. New things get ids you make yourself, as UUIDs. |
Answers
200with"status": "applied", or"duplicate"for anidalready done.422with areasonwhen the business's rules refuse it. A refusedidstays refused: fix the request and send a newid.400when the request is malformed;detailsays where.
{
"id": "opera-folio-88121-paid",
"kind": "payment.cash",
"payload": {
"paymentId": "0190d4b0-...",
"jobId": "0190d4a2-...",
"amountCents": 80000
}
}{ "status": "applied" }{ "status": "rejected",
"reason": "cannot take payment on a voided job" }job.create
A car arrives, or is booked for later with scheduledFor. The customer is found or created from the name and phone.
| Field | Meaning |
|---|---|
jobIdrequired | UUID you make. |
branchIdrequired | From Setup. |
typerequired | walk_in, home_service or parking. |
plateRawrequired | As written, 2 to 16 characters. |
vehicleClassIdrequired | From Setup. |
customerName | Optional, up to 120 characters. |
customerPhone | Optional Kenyan mobile number. |
make, model | Optional, kept on the car. |
scheduledFor | Optional time to come; the job starts as booked. |
washerIds | Optional staff ids washing it. |
damageNote | Optional, up to 2000 characters. |
{
"jobId": "0190d4a2-6b1e-7c3a-9f10-2a3b4c5d6e7f",
"branchId": "0190c2...",
"type": "walk_in",
"plateRaw": "KDA 412X",
"vehicleClassId": "0190c3...",
"customerName": "Room 412",
"customerPhone": "0712345678"
}job.addService
Adds a line from the menu. The price comes from the business's list for the car's vehicle class; you never send it.
| Field | Meaning |
|---|---|
jobIdrequired | The job. |
lineIdrequired | UUID you make for this line. |
serviceIdrequired | From Setup. |
quantity | 1 to 20. Default 1. |
userId | Optional: who did this line, for their commission. |
measure | For services priced by size, like a carpet: 5x10. |
{
"jobId": "0190d4a2-...",
"lineId": "0190d4a3-...",
"serviceId": "0190c4..."
}job.transition
Moves a job to another stage. Only the moves the counter allows are accepted: a car goes in_progress, then ready, and is released once paid or on an account.
| Field | Meaning |
|---|---|
jobIdrequired | The job. |
torequired | The stage to move to. |
reason | Optional, up to 200 characters; kept with a cancellation. |
{ "jobId": "0190d4a2-...", "to": "ready" }payment.cash
Money taken for a job. It goes into the open drawer of the job's branch, if one is open.
| Field | Meaning |
|---|---|
paymentIdrequired | UUID you make. |
jobIdrequired | The job it pays for. |
amountCentsrequired | More than 0. |
sendReceipt | Optional: send the customer their receipt once the sale settles. |
{
"paymentId": "0190d4b0-...",
"jobId": "0190d4a2-...",
"amountCents": 80000
}payment.settleDebt
A customer pays off what they owe across all their cars, oldest first. Use it when a hotel or fleet settles at month end.
| Field | Meaning |
|---|---|
customerIdrequired | From a job's customerId. |
amountCentsrequired | More than 0. |
method | cash or bank. Default cash. |
reference | Optional transfer reference, up to 80 characters. |
{
"customerId": "0190c9...",
"amountCents": 2400000,
"method": "bank",
"reference": "FT26318KX2"
}More operations
| Kind | What it does | Payload |
|---|---|---|
job.createGoods | Work with no car: carpets, curtains | jobId, branchId, description; optional customer |
job.removeService | Take a line off | jobId, lineId |
job.amend | Fix a plate, class or customer | jobId and any of plateRaw, vehicleClassId, customerName, customerPhone, reason |
job.assign | Who washed it | jobId, workers: [{ userId, shareBps }] adding to 10000 |
customer.update | Change a customer's details | customerId and any of fullName, phone, altPhone, notes, notifyChannel |
Need another operation? Ask us on WhatsApp, +254 759 179794.
Webhooks
Instead of polling Changes, the owner adds a webhook in Settings › API: an https address, and which of jobs, shifts and expenses to send. Each change is posted within a minute.
| Header | Meaning |
|---|---|
X-Suds-Event | job.updated, shift.updated, expense.updated, or ping from Send a test |
X-Suds-Delivery | Unique per delivery. Skip one you have already seen. |
X-Suds-Signature | t=<unix time>,v1=<hex>. Verify it. |
Answer with any 2xx within 10 seconds. A change can arrive twice, and out of order on retries; data is always the whole record as it was then.
POST https://pms.hotel.example/suds
X-Suds-Event: job.updated
X-Suds-Delivery: 0190d4b7-...
X-Suds-Signature: t=1794651625,v1=5f2b...
{
"id": "0190d4b7-...",
"event": "job.updated",
"createdAt": "2026-11-14T09:12:03Z",
"data": { /* the job */ }
}Verify a signature
Each webhook has a signing secret, whsec_..., shown when it is added and in its menu. The signature is HMAC-SHA256 of t, a dot, and the raw request body, in hex. Reject anything older than five minutes, and compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function fromSuds(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const want = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
return want.length === v1.length && timingSafeEqual(Buffer.from(want), Buffer.from(v1));
}
import hmac, hashlib, time
def from_suds(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
if abs(time.time() - int(parts["t"])) > 300:
return False
want = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, parts["v1"])
function from_suds(string $raw, string $header, string $secret): bool {
parse_str(str_replace(',', '&', $header), $p);
if (abs(time() - (int) $p['t']) > 300) return false;
$want = hash_hmac('sha256', $p['t'] . '.' . $raw, $secret);
return hash_equals($want, $p['v1']);
}Security
- https only. Plain http is refused with
https_required, never redirected. Keep keys on your server: never in a URL, a browser or a phone app. - Least access. Keys read only unless writing is switched on, and can be tied to your servers' addresses or ranges, like
203.0.113.0/24. - Guessing is stopped. Ten invalid keys from one address lock it out for five minutes.
- The owner sees everything. Every owner is told when a live key or webhook is added. Each key shows when and from where it was last used, and every change it makes is in the business's Activity, under the key's name.
- Webhooks go only to https addresses on port 443 that resolve to the public internet. They are signed and never follow redirects.
- Answers carry
Cache-Control: no-store.
Limits and retries
| Limit | Value |
|---|---|
| Requests per key | 120 a minute. Over it: 429 with Retry-After: 60. |
| Write body | 16 KB. |
| Changes page | 500 records. |
| Webhook answer | 10 seconds. |
| Webhook retries | After 1, 5, 15, 60 and 240 minutes, then given up. The owner sees failures in Settings › API. |
Errors
| Status | Error | Meaning |
|---|---|---|
| 400 | bad_request, bad_payload | Malformed; detail says where. |
| 401 | api_key_required | No key sent. |
| 401 | api_key_invalid | Unknown or removed key. |
| 403 | https_required | Sent over plain http. |
| 403 | api_not_enabled | A live key, and live access is not on for this business. |
| 403 | read_only_key | The key cannot write. |
| 403 | address_not_allowed | The key is tied to other addresses. |
| 404 | not_found | No such job. |
| 422 | status rejected | The business's rules refused it; reason says why. |
| 429 | too_many_requests | Over 120 requests a minute. |
| 429 | too_many_bad_keys | Too many invalid keys from your address. |