Suds API https://app.suds.co.ke

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:

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.
GET/api/v1/setup
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.

200application/json
{
  "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.

POST/api/v1/ops
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"

Setup

GET/api/v1/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.

FieldWhat it holds
branchesid, name, address
vehicleClassesid, name: saloon, SUV, pickup and so on
servicesid, name, category, pricing
pricesservice_id, vehicle_class_id (null for goods), amount_cents
staffid, name, role, for job.assign
200application/json
{
  "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

GET/api/v1/changes?after=0&limit=200

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.

ParameterMeaning
afterThe cursor from your last call. 0 for everything.
limit1 to 500. Default 200.
200application/json
{
  "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

GET/api/v1/jobs/{id}

One job as it is now: the car, the customer, every service line with who did it, every payment, and the totals.

FieldMeaning
statusbooked, checked_in, in_progress, ready, paid, released, cancelled or voided
totalCentsAfter discount. paidCents is what has been taken so far.
servicesLines: name, quantity, unitPriceCents, lineTotalCents, workerName
paymentsmethod, status, amountCents, settledAt, reference
200application/json
{
  "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

POST/api/v1/ops

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.

FieldMeaning
idrequiredYours, up to 100 characters, unique for this write. Send the same id again and nothing happens twice.
kindrequiredOne of the operations below.
payloadrequiredThe operation's fields. New things get ids you make yourself, as UUIDs.

Answers

  • 200 with "status": "applied", or "duplicate" for an id already done.
  • 422 with a reason when the business's rules refuse it. A refused id stays refused: fix the request and send a new id.
  • 400 when the request is malformed; detail says where.
POST/api/v1/ops
{
  "id": "opera-folio-88121-paid",
  "kind": "payment.cash",
  "payload": {
    "paymentId": "0190d4b0-...",
    "jobId": "0190d4a2-...",
    "amountCents": 80000
  }
}
200application/json
{ "status": "applied" }
422application/json
{ "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.

FieldMeaning
jobIdrequiredUUID you make.
branchIdrequiredFrom Setup.
typerequiredwalk_in, home_service or parking.
plateRawrequiredAs written, 2 to 16 characters.
vehicleClassIdrequiredFrom Setup.
customerNameOptional, up to 120 characters.
customerPhoneOptional Kenyan mobile number.
make, modelOptional, kept on the car.
scheduledForOptional time to come; the job starts as booked.
washerIdsOptional staff ids washing it.
damageNoteOptional, up to 2000 characters.
kind job.create
{
  "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.

FieldMeaning
jobIdrequiredThe job.
lineIdrequiredUUID you make for this line.
serviceIdrequiredFrom Setup.
quantity1 to 20. Default 1.
userIdOptional: who did this line, for their commission.
measureFor services priced by size, like a carpet: 5x10.
kind job.addService
{
  "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.

FieldMeaning
jobIdrequiredThe job.
torequiredThe stage to move to.
reasonOptional, up to 200 characters; kept with a cancellation.
kind job.transition
{ "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.

FieldMeaning
paymentIdrequiredUUID you make.
jobIdrequiredThe job it pays for.
amountCentsrequiredMore than 0.
sendReceiptOptional: send the customer their receipt once the sale settles.
kind payment.cash
{
  "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.

FieldMeaning
customerIdrequiredFrom a job's customerId.
amountCentsrequiredMore than 0.
methodcash or bank. Default cash.
referenceOptional transfer reference, up to 80 characters.
kind payment.settleDebt
{
  "customerId": "0190c9...",
  "amountCents": 2400000,
  "method": "bank",
  "reference": "FT26318KX2"
}

More operations

KindWhat it doesPayload
job.createGoodsWork with no car: carpets, curtainsjobId, branchId, description; optional customer
job.removeServiceTake a line offjobId, lineId
job.amendFix a plate, class or customerjobId and any of plateRaw, vehicleClassId, customerName, customerPhone, reason
job.assignWho washed itjobId, workers: [{ userId, shareBps }] adding to 10000
customer.updateChange a customer's detailscustomerId 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.

HeaderMeaning
X-Suds-Eventjob.updated, shift.updated, expense.updated, or ping from Send a test
X-Suds-DeliveryUnique per delivery. Skip one you have already seen.
X-Suds-Signaturet=<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.

POSTyour webhook URL
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.

verify signature
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));
}

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

LimitValue
Requests per key120 a minute. Over it: 429 with Retry-After: 60.
Write body16 KB.
Changes page500 records.
Webhook answer10 seconds.
Webhook retriesAfter 1, 5, 15, 60 and 240 minutes, then given up. The owner sees failures in Settings › API.

Errors

StatusErrorMeaning
400bad_request, bad_payloadMalformed; detail says where.
401api_key_requiredNo key sent.
401api_key_invalidUnknown or removed key.
403https_requiredSent over plain http.
403api_not_enabledA live key, and live access is not on for this business.
403read_only_keyThe key cannot write.
403address_not_allowedThe key is tied to other addresses.
404not_foundNo such job.
422status rejectedThe business's rules refused it; reason says why.
429too_many_requestsOver 120 requests a minute.
429too_many_bad_keysToo many invalid keys from your address.