Developer resources
Integrate with Laundrix through its REST API and webhooks, or extend the source code. API access is a plan feature (Premium and Enterprise by default) and is configured per company.
API keys
- An owner or admin opens Settings → Integrations and creates an API key with a name and one or more scopes.
- The key looks like
lk_abc123.secret. It is shown once; only a SHA-256 hash is stored. Revoke and recreate it if lost. - Send it as a Bearer token on every request.
Authorization: Bearer lk_abc123.yourSecretHere
| Scope | Allows |
|---|---|
orders:read | List and read orders (including garment stages and the customer-visible timeline) |
orders:write | Create orders |
customers:read | List customers |
invoices:read | List invoices |
Endpoints
Base URL: your site address, for example https://your-domain/api/v1. Requests and responses are JSON.
| Method and path | Scope | Notes |
|---|---|---|
GET /api/v1/orders | orders:read | Filters: status, customerId. Paging: page, pageSize (max 100). |
GET /api/v1/orders/:id | orders:read | Adds garments (code, description, stage, state) and the visible timeline. |
POST /api/v1/orders | orders:write | Creates an order priced by the server. Returns 201. |
GET /api/v1/customers | customers:read | Filter: q. Paging as above. |
GET /api/v1/invoices | invoices:read | Filters: status, customerId. |
Examples
# Orders that are ready
curl -H "Authorization: Bearer $KEY" "https://your-domain/api/v1/orders?status=READY"
# Create an order for pickup and delivery
curl -X POST https://your-domain/api/v1/orders \
-H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{
"customerId": "CUSTOMER_ID",
"items": [{ "serviceId": "SERVICE_ID", "garmentCategoryId": "CATEGORY_ID", "quantity": 3 }],
"fulfillment": "PICKUP_DELIVERY",
"pickup": { "address": "12 Allen Avenue, Ikeja" },
"delivery": { "address": "12 Allen Avenue, Ikeja" },
"priorityCode": "NORMAL",
"notes": "Ring the bell twice"
}'Service and category identifiers are the ones shown for your company in the app (and returned by the public booking catalogue). fulfillment is one of IN_STORE, PICKUP, DELIVERY, PICKUP_DELIVERY.
An order in the response
{
"id": "…", "orderNumber": "LD-10077", "status": "RECEIVED", "paymentStatus": "UNPAID",
"priority": "NORMAL", "fulfillment": "PICKUP_DELIVERY", "customerId": "…",
"receivedAt": "…", "expectedAt": "…", "completedAt": null,
"totals": { "subtotal": 0, "discount": 0, "tax": 0, "fees": 0, "total": 0, "paid": 0, "balance": 0 },
"trackingUrl": "https://your-domain/track/ABCD-EFGH-JKLM"
}Money amounts are integers in the smallest currency unit (kobo for NGN), never floats.
Errors and limits
{ "error": { "code": "FORBIDDEN", "message": "API key lacks scope orders:write" } }| Status | Code | Meaning |
|---|---|---|
| 401 | UNAUTHENTICATED | Missing, malformed, revoked or unknown key |
| 402 | FEATURE_LOCKED, PLAN_LIMIT, SUBSCRIPTION_INACTIVE | Plan does not include the API, a limit was reached, or writes are blocked because the subscription lapsed |
| 403 | FORBIDDEN | The key lacks the scope or customer |
| 404 | NOT_FOUND | No such record for this company |
| 422 | VALIDATION | The body failed validation; the message says why |
| 429 | RATE_LIMITED | Too many requests. Limits are 300 a minute per IP address and 600 a minute per key. |
Webhooks
Create an endpoint under Settings → Integrations with a public https URL and the events you want. A signing secret is generated for it.
| Event | Sent when |
|---|---|
order.created | An order is created |
order.ready | All garments of an order are ready |
order.completed | An order is handed over or delivered |
order.cancelled | An order is cancelled |
payment.received | A payment is confirmed |
dispatch.failed | A pickup or delivery attempt fails |
Delivery
- A
POSTwith headersx-webhook-timestampandx-webhook-signatureand a JSON body{ "type": "...", "createdAt": "...", "data": { ... } }. - Respond with any 2xx within 8 seconds. Redirects are not followed.
- Failures are retried with growing delays (minutes up to six hours), up to six attempts, then marked failed.
- Deliveries are sent by the scheduled job, so keep the jobs endpoint scheduled.
Verify the signature
The signature is the hex HMAC-SHA256, keyed with your endpoint secret, of timestamp + "." + rawBody. Verify it against the raw body, compare in constant time, and reject old timestamps.
import { createHmac, timingSafeEqual } from "crypto";
export function verify(secret, headers, rawBody) {
const ts = headers["x-webhook-timestamp"], sig = headers["x-webhook-signature"] ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // older than 5 minutes
const expected = createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Treat events as at-least-once: use the order or payment identifier in data to ignore duplicates.
Payment provider webhook
Paystack calls /api/webhooks/paystack. The signature (HMAC-SHA512 of the raw body) is verified, the event is stored under a unique constraint so repeats are ignored, and a payment becomes successful only after the server confirms the amount and currency with Paystack. Set this URL in the Paystack dashboard.
Working with the source
Layout
| Folder | Contents |
|---|---|
prisma/ | schema.prisma, migrations and the demo seed |
src/app/ | Pages (staff app, portal, platform admin, public site, docs), server actions and API routes |
src/server/services/ | Business logic, one module per domain (orders, garments, payments, dispatch, inventory…) |
src/server/platform/ | Cross-company code: provisioning, billing, public lookups, API auth, jobs |
src/lib/ | Permissions, entitlements, tenant-scoped client, settings schema, auth, helpers |
tests/ | Vitest suites, including cross-company isolation and permission tests |
scripts/ | Seed helpers, job runner, platform admin creator, browser tests |
Conventions that keep it safe
- Services take a context. Each takes
(ctx, input)and starts with aguard(ctx, permission, …)call that checks the permission, a writable subscription and the plan feature. - Use the scoped client.
ctx.dbadds the company filter and rejects foreign identifiers. Only the platform-level modules listed in the isolation test may use the unscoped database client. - Permissions are central. Add new ones to
src/lib/permissions.tsand to the role templates; never check role names in code. - Money is an integer in minor units. Prices, plans and workflows are data, not constants.
- Unconfigured providers must report “Integration not configured” rather than fake a result.
Common tasks
| Task | Command |
|---|---|
| Type-check, lint and test | npm run check |
| Create a migration while developing | npm run db:migrate |
| Apply migrations in production | npm run db:deploy |
| Run background jobs once | npm run jobs |
| Create the platform admin | npm run admin:create -- you@company.com "Your Name" |
| Browser checks (app running and seeded) | npm run smoke && npm run e2e |
TEST_DATABASE_URL. Point it at a dedicated test database, never at your development or production data.Rebranding
Name, tagline, logo, icon and colours are read from src/lib/brand.ts and can be overridden with environment variables. Each company uploads its own logo in the app. The company behind the product is configured separately with PARENT_COMPANY_* and appears in footers, About and the legal pages.
