Public REST API
The /api/v1 facade lets merchants integrate programmatically instead of using the portal.
It's a thin REST wrapper around the same service functions the merchant tRPC router calls —
see DOMAIN.md § API Access for the business rules (key format, access
gating, request logging). This doc is the endpoint reference, kept up to date in the same PR
as any route change (per STANDARDS.md § Documentation).
Routes live under apps/web/src/app/api/v1/. There is no OpenAPI generation for this API yet —
this file is hand-maintained.
Authentication
Every request needs:
Authorization: Bearer dk_live_<key>
Keys are created and revoked by the merchant in the portal (Settings → API Keys), capped at
5 active keys per merchant. A key inherits its merchant's subscription: if the merchant's plan
doesn't include apiAccess, every request 403s regardless of key validity.
| Status | Meaning |
|---|---|
401 UNAUTHORIZED | Missing, malformed, invalid, or revoked key |
403 FORBIDDEN | Key is valid but the merchant's plan lacks apiAccess |
Errors
Non-2xx responses always return this envelope:
{ "error": { "code": "BAD_REQUEST", "message": "human-readable detail" } }
code is a stable machine-readable string (matches the HTTP status), suitable for switching on
in client code. message is for logs/debugging, not for parsing.
Endpoints
POST /api/v1/deliveries
Create a delivery. Identical validation and behavior (pricing, ETA, quota, dispatch) to the
merchant portal's create flow — both call the same createDeliveryForMerchant() function.
Body (all fields required unless marked optional):
| Field | Type | Notes |
|---|---|---|
customerName | string | |
customerPhone | string | |
pickupNeighborhoodId | string | |
pickupLat, pickupLng | number | |
pickupStreet, pickupBuilding, pickupFloor, pickupLandmark, pickupNotes | string | optional |
dropoffNeighborhoodId | string | |
dropoffLat, dropoffLng | number | |
dropoffStreet, dropoffBuilding, dropoffFloor, dropoffLandmark, dropoffNotes | string | optional |
packageDescription | string | |
packageSize | "SMALL" | "MEDIUM" | "LARGE" | optional |
priority | "STANDARD" | "URGENT" | optional, default STANDARD |
requiredVehicleType | "BICYCLE" | "MOTORCYCLE" | "CAR" | "KEI_TRUCK" | "VAN" | optional, default CAR |
prepTimeMinutes | integer | optional, default 5 |
merchantAmount | integer (minor units) | optional, default 0. Cash-on-delivery goods value |
currencyCode | string (ISO 4217) | optional, defaults to the merchant's account currency |
pricingType | "PAID" | "FREE" | optional, default PAID. FREE zeroes the customer fee and consumes the plan's free-delivery quota; the real distance-based driver cost is still computed either way |
201 — returns the created Delivery row (id, trackingCode, status, merchantId,
customerName, customerPhone, pickup/dropoff fields, packageDescription, packageSize,
priority, requiredVehicleType, prepTimeMinutes, estimatedDeliveryAt, driverId,
dispatchRound, createdAt, updatedAt). Pricing/financial fields are not on this row — fetch
GET /deliveries/:id for those.
Errors:
| Status | Code | Cause |
|---|---|---|
| 400 | BAD_REQUEST | Schema validation failed, or the neighborhood id is invalid |
| 403 | FORBIDDEN | Merchant is suspended, or has an overdue invoice |
| 429 | TOO_MANY_REQUESTS | Subscription's delivery quota, or free-delivery quota (when pricingType: "FREE"), is exhausted for this period |
| 500 | INTERNAL_SERVER_ERROR | Pricing/ETA misconfiguration, or dispatch failed to start |
GET /api/v1/deliveries
List the merchant's deliveries, newest first, cursor-paginated.
Query params:
| Param | Type | Notes |
|---|---|---|
status | DeliveryStatus | optional — one of CREATED, SEARCHING_DRIVER, DRIVER_ASSIGNED, PICKUP_STARTED, PICKED_UP, IN_TRANSIT, DELIVERED, COMPLETED, CANCELLED, FAILED, EXPIRED |
search | string | optional — matches tracking code or customer name |
cursor | string | optional — from a previous response's nextCursor |
limit | integer | optional, 1–100, default 20 |
200:
{
"items": [
{
"id": "...",
"trackingCode": "...",
"customerName": "...",
"status": "SEARCHING_DRIVER",
"createdAt": "2026-08-13T10:00:00.000Z",
"customerFee": 15000,
"currencyCode": "SYP",
"driverName": null
}
],
"nextCursor": "cly...xyz"
}
nextCursor is null on the last page.
GET /api/v1/deliveries/:id
Fetch one delivery, with financial snapshot, status history, assigned driver, and live dispatch offers — the same detail payload the merchant portal's delivery detail page uses.
200 — the delivery row plus financial, statusHistory (chronological), assignedDriver,
offers, city, pickupNeighborhood, dropoffNeighborhood.
404 — NOT_FOUND if the id doesn't exist or belongs to a different merchant.
GET /api/v1/pricing/quote
Calculate the price for a delivery before creating it — same pricing engine
POST /deliveries uses internally, so a quote never drifts from what creation actually charges.
No delivery is created; this is a pure calculation, safe to call as often as needed (e.g. to
reprice as the customer edits pickup/dropoff in your own UI).
Query params:
| Param | Type | Notes |
|---|---|---|
vehicleType | "BICYCLE" | "MOTORCYCLE" | "CAR" | "KEI_TRUCK" | "VAN" | required |
priority | "STANDARD" | "URGENT" | required |
packageSize | "SMALL" | "MEDIUM" | "LARGE" | optional |
pickupLat, pickupLng | number | required |
dropoffLat, dropoffLng | number | required |
currencyCode | string (ISO 4217) | optional, defaults to the merchant's account currency |
200:
{
"customerFee": 15000,
"driverAmount": 12000,
"platformAmount": 3000,
"currencyCode": "SYP",
"pricingRuleRef": "fee:cly...,commission:cly...",
"distanceKm": 4.21
}
Errors:
| Status | Code | Cause |
|---|---|---|
| 400 | BAD_REQUEST | Missing or invalid query params |
| 500 | INTERNAL_SERVER_ERROR | No active pricing/commission rule is configured for this merchant and currency |
Adding a New Endpoint
Follow this shape so new endpoints stay consistent and REST/tRPC can't drift apart:
- Reuse the service layer. If a tRPC procedure already does this, extract or reuse its
underlying service function (see
createDeliveryForMerchant()inpackages/api/src/domains/delivery/service.ts) instead of writing parallel logic in the route handler. Share the zod input schema too if one exists (seecreate-input.ts). - Wrap the route in
withApiAuth. Addroute.tsunderapps/web/src/app/api/v1/<resource>/, usingwithApiAuth(req, handler)from../_auth.tsfor auth + request logging — don't reimplement key checking. - Map service errors to HTTP status explicitly, one
if (error instanceof X)per typed error class, falling through to a rethrow for anything unexpected (seedeliveries/route.ts). - Add route tests mocking the service/db module (see
deliveries/route.test.ts) — auth failure cases (401/403) plus the endpoint's own success/error paths. - Document it here: one
###section per route, same shape as above (body/query table, response shape, error table). - Update DOMAIN.md if the change affects business rules, and mark the roadmap slice shipped in ROADMAP.md.
tRPC procedures used by the mobile driver app
These live on the tRPC surface (/api/trpc), not /api/v1 — the driver app authenticates with
Better Auth bearer sessions (ADR-0007), not merchant API keys. They are documented here because
they are API surface.
driver.reportLocation (authenticated — driverProcedure)
Body: { "points": [{ "lat", "lng", "accuracyMeters"?, "speedMps"?, "headingDeg"?, "recordedAt" }] } — max 20 points per batch. Server-gated on the driver being ONLINE/BUSY and
rate-limited per driver. Returns { "received": number }. Append-only writes to DriverLocation.
delivery.getDriverPosition (public — publicProcedure)
Input: { "trackingCode" }. Returns the delivery's assigned driver's latest position
{ "lat", "lng", "updatedAt" } or null. Redis rate-limited per ip+trackingCode. Position is
hidden when the delivery is CANCELLED/FAILED/EXPIRED or the latest fix is > 5 minutes old.
Never returns the trail or driver identity.