Machine API for accounting, TMS / GPS and spreadsheet systems. Authenticate with a scoped API key created by a planner at /integrations: send `Authorization: Bearer ymk_<id>_<secret>` or `X-API-Key: ymk_<id>_<secret>`. Keys are shown once and stored hashed. 300 requests/min/key. Outgoing events are delivered as signed webhooks (see x-webhooks) or can be pulled from GET /events. Accounting column layouts are NOT yet verified against Yoongmei's real templates and tax codes are blank until configured (wiki/integrations.md).
Machine-readable: /api/v1/openapi.json (OpenAPI 3.1).
Authentication
Authorization: Bearer ymk_<id>_<secret> # or X-API-Key: ymk_<id>_<secret>
| Scope | Allows |
|---|---|
orders:read | read orders and the event feed |
orders:write | create / update orders |
billing:read | preview and download invoice exports, read payment status |
billing:write | mark batches exported / unmarked, import payments |
tms:read | read lorry positions and trips |
tms:write | push GPS positions / trip events from a tracking system |
master:read | read customers, lorries, drivers, charges |
master:write | import / update customers, debtor codes, lorries |
webhooks:manage | manage own webhook subscriptions |
Meta
GET /api/v1/openapi.json
This OpenAPI 3.1 document
Public: no key needed
| Status | Meaning |
|---|---|
| 200 | the spec |
GET /api/v1/docs
Human-readable rendering of this spec (HTML)
Public: no key needed
| Status | Meaning |
|---|---|
| 200 | HTML page |
Core
GET /api/v1/ping
Check a key: returns its id, name, scopes and the server time
Any valid API key
| Status | Meaning |
|---|---|
| 200 | ok |
| 401 | missing, malformed, unknown or revoked API key |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
Events
GET /api/v1/events
Pull feed of integration events (for systems that cannot receive webhooks)
Scope (any of): orders:read
Ordered by id. Keep the largest returned id and pass it back as since_id. Same event types as the webhooks.
| Parameter | In | Type | Notes |
|---|---|---|---|
since_id | query | integer | return events with id greater than this |
limit | query | integer | default 100, max 500 |
type | query | string | exact event type, e.g. order.assigned |
| Status | Meaning |
|---|---|
| 200 | {events:[{id,type,created_at,tempdo_no,data}], next_since_id} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: orders:read |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
Orders
GET /api/v1/orders
List orders (whitelisted fields only)
Scope (any of): orders:read
| Parameter | In | Type | Notes |
|---|---|---|---|
status | query | string | exact order status |
updated_since | query | string | ISO timestamp |
offset | query | integer | default 0 |
limit | query | integer | default 100, max 500 |
| Status | Meaning |
|---|---|
| 200 | {total, offset, orders:[...]} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: orders:read |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
GET /api/v1/orders/{no}
One order
Scope (any of): orders:read
| Parameter | In | Type | Notes |
|---|---|---|---|
no * | path | string | Temp DO number, e.g. TEMPDO-0001 |
| Status | Meaning |
|---|---|
| 200 | the order |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: orders:read |
| 404 | no such Temp DO |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
Webhooks
GET /api/v1/webhooks
List the webhook subscriptions THIS key created (never other keys' or the planner's)
Scope (any of): webhooks:manage
| Status | Meaning |
|---|---|
| 200 | {webhooks:[...]} (secret hint only) |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: webhooks:manage |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
POST /api/v1/webhooks
Subscribe a URL to events; the signing secret is returned ONCE
Scope (any of): webhooks:manage
The URL must be https (or http) to a public host; private / loopback / link-local addresses are refused (SSRF guard) and re-checked at delivery time.
Body (JSON, required)
{
"name": "Accounting bridge",
"url": "https://example.com/hooks/ymorder",
"events": [
"invoice.exported",
"order.status_changed"
]
}| Status | Meaning |
|---|---|
| 201 | subscription + secret ("whsec_...") |
| 400 | bad name / url / event type |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: webhooks:manage |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
DELETE /api/v1/webhooks/{id}
Delete a subscription this key created
Scope (any of): webhooks:manage
| Parameter | In | Type | Notes |
|---|---|---|---|
id * | path | string | numeric id (webhooks) or batch id such as ACB-202610121530 |
| Status | Meaning |
|---|---|
| 200 | {ok:true} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: webhooks:manage |
| 404 | not found (or not yours) |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
Billing
GET /api/v1/billing/profiles
Accounting layouts available (columns, formats, unverified flag)
Scope (any of): billing:read
| Status | Meaning |
|---|---|
| 200 | {profiles:[...]} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: billing:read |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
GET /api/v1/billing/invoices
PREVIEW an invoice export. Read-only: never marks anything exported
Scope (any of): billing:read
| Parameter | In | Type | Notes |
|---|---|---|---|
profile | query | autocount | sqlaccount | xero | quickbooks | generic | json | |
format | query | csv | xlsx | json | |
group | query | order | customer | |
include_draft | query | boolean | also export orders whose rate is still a draft |
from | query | string | delivered on/after, YYYY-MM-DD (Malaysia time) |
to | query | string | delivered on/before, YYYY-MM-DD |
tempdo_nos | query | string | comma separated Temp DO numbers (up to 500) to restrict the export |
| Status | Meaning |
|---|---|
| 200 | The export file (text/csv or xlsx attachment) or, for format=json, the canonical JSON. Headers: X-Billing-Skipped. |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: billing:read |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
POST /api/v1/billing/batches
Build an export AND mark its orders exported (never exported twice); returns the file
Scope (any of): billing:write
Only Delivered orders that classify as ready are exported. Response headers X-Billing-Batch (batch id) and X-Billing-Skipped. Emits invoice.exported (one per order from the audit bridge, plus one batch-level event carrying batch_id).
Body (JSON)
{
"profile": "xero",
"format": "csv",
"group": "customer"
}| Status | Meaning |
|---|---|
| 200 | file or JSON, with X-Billing-Batch |
| 400 | bad parameter |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: billing:write |
| 422 | nothing billable (lists skipped orders and why) |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
POST /api/v1/billing/batches/{id}/ack
Store the accounting system's own document number per DO
Scope (any of): billing:write
Idempotent: the same number again = "unchanged". A different number than the one already stored is refused.
| Parameter | In | Type | Notes |
|---|---|---|---|
id * | path | string | numeric id (webhooks) or batch id such as ACB-202610121530 |
Body (JSON, required)
{
"documents": [
{
"tempdo_no": "TEMPDO-0001",
"external_doc_no": "IV-000123"
}
]
}| Status | Meaning |
|---|---|
| 200 | {batch_id, acked, rejected, results[]} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: billing:write |
| 404 | no exported orders in that batch |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
POST /api/v1/billing/batches/{id}/unmark
Re-open a batch the accounting system rejected (so its DOs can be exported again)
Scope (any of): billing:write
DOs that already have a payment recorded are skipped and listed under "blocked". Emits invoice.unmarked per order.
| Parameter | In | Type | Notes |
|---|---|---|---|
id * | path | string | numeric id (webhooks) or batch id such as ACB-202610121530 |
Body (JSON)
{
"type": "object",
"properties": {
"tempdo_nos": {
"type": "array",
"items": {
"type": "string",
"description": "Temp DO no"
},
"description": "only these (default: whole batch)"
}
}
}| Status | Meaning |
|---|---|
| 200 | {ok, batch_id, cleared[], blocked[]} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: billing:write |
| 404 | no exported orders in that batch |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
POST /api/v1/billing/payments
Import payments received (send dry_run:true first). Idempotent on (order, reference)
Scope (any of): billing:write
A payment can only be applied to a DO that was exported. Same reference + same amount = "duplicate", same reference + different amount = "conflict". external_doc_no works only when it maps to exactly one DO; otherwise send tempdo_no.
Body (JSON, required)
{
"dry_run": true,
"payments": [
{
"tempdo_no": "TEMPDO-0001",
"reference": "BANK-8841",
"amount": 1200.5,
"paid_at": "2026-10-10"
}
]
}| Status | Meaning |
|---|---|
| 200 | {dry_run, summary:{rows,applied,would_apply,duplicate,conflict,rejected}, results[]} |
| 400 | bad payload |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: billing:write |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
GET /api/v1/billing/payments
Exported DOs with their payment status
Scope (any of): billing:read
| Parameter | In | Type | Notes |
|---|---|---|---|
status | query | paid | partial | overpaid | unpaid | |
limit | query | integer | default 100, max 500 |
offset | query | integer | default 0 |
| Status | Meaning |
|---|---|
| 200 | {total, offset, payments[]} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: billing:read |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
GPS / TMS
POST /api/v1/tms/positions
PUSH GPS positions (one object, an array, {positions:[]}, a vendor payload via the field map, or Traccar forwarding)
Scope (any of): tms:write
Batch max 500 per request. Only plates that already exist are accepted. (plate, ts) is the dedupe key. HTTP 200 if anything was accepted or was a duplicate, 422 if every record was rejected, 413 over 500, 400 for an empty/malformed body. Per-record reasons are in rejected[].
| Parameter | In | Type | Notes |
|---|---|---|---|
format | query | traccar | canonical | |
map | query | string | JSON field map for this request only, merged over the key's stored map |
tz_offset_minutes | query | integer | minutes east of UTC for timestamps without a zone; default 0 |
Body (JSON, required)
{
"positions": [
{
"plate": "JKL 9999",
"lat": 1.4927,
"lng": 103.7414,
"speed_kmh": 62,
"heading_deg": 90,
"ts": "2026-10-12T08:00:00Z"
}
]
}| Status | Meaning |
|---|---|
| 200 | {ok, accepted_count, rejected_count, duplicate_count, accepted[], rejected[]} |
| 400 | bad body |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: tms:write |
| 413 | too many records |
| 422 | all rejected |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
GET /api/v1/tms/positions
Latest position per lorry from the configured TMS provider
Scope (any of): tms:read
| Parameter | In | Type | Notes |
|---|---|---|---|
source | query | push | raw stored pushes only |
| Status | Meaning |
|---|---|
| 200 | {ok, provider, demo, as_of, last_push_at, positions[]} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: tms:read |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
| 502 | provider error |
GET /api/v1/tms/osmand
OsmAnd / Traccar-client style position push by query string (key may be sent as ?key= on THIS route only)
Scope (any of): tms:write
| Parameter | In | Type | Notes |
|---|---|---|---|
id * | query | string | plate |
lat | query | number | |
lon | query | number | |
timestamp | query | string | epoch s/ms or ISO |
speed | query | number | KNOTS (converted x1.852) |
bearing | query | number | |
ignition | query | string | |
key | query | string | API key, only on this route |
tz_offset_minutes | query | integer |
| Status | Meaning |
|---|---|
| 200 | {ok, plate, ts} (a duplicate also answers 200) |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: tms:write |
| 404 | unknown plate |
| 422 | rejected, with reason |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
POST /api/v1/tms/events
Trip event from the tracking system: arrived / departed / geofence
Scope (any of): tms:write
Audited and emitted as order.checkpoint when it resolves to an order. It does NOT change order status (see wiki/integrations.md, TMS_AUTO_STATUS).
Body (JSON, required)
{
"type": "object",
"properties": {
"plate": {
"type": "string",
"description": ""
},
"type": {
"type": "string",
"enum": [
"arrived",
"departed",
"geofence"
]
},
"ts": {
"type": "string",
"description": "ISO 8601"
},
"tempdo_no": {
"type": "string",
"description": "optional; must be assigned to that plate"
},
"note": {
"type": "string",
"description": "max 300 chars"
}
},
"required": [
"plate",
"type",
"ts"
]
}| Status | Meaning |
|---|---|
| 200 | {ok, plate, type, ts, tempdo_no, duplicate, emitted, auto_status} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: tms:write |
| 404 | order not found |
| 422 | unknown plate / order not on that plate / ts in the future |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
PUT /api/v1/tms/field-map
Store THIS key's vendor-field -> canonical-field map (dotted paths)
Scope (any of): tms:write
Body (JSON, required)
{
"map": {
"plate": "vehicleNo",
"lat": "gps.latitude",
"lng": "gps.longitude",
"ts": "gpsTime"
}
}| Status | Meaning |
|---|---|
| 200 | {ok, key_id, map} |
| 400 | unknown field / bad path |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: tms:write |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
GET /api/v1/tms/field-map
Read this key's field map
Scope (any of): tms:read tms:write
| Status | Meaning |
|---|---|
| 200 | {key_id, map, canonical_fields} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: tms:read or tms:write |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
DELETE /api/v1/tms/field-map
Delete this key's field map
Scope (any of): tms:write
| Status | Meaning |
|---|---|
| 200 | {ok:true} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: tms:write |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
GET /api/v1/tms/lorries/{plate}/track
Track points for one lorry
Scope (any of): tms:read
| Parameter | In | Type | Notes |
|---|---|---|---|
plate * | path | string | lorry plate; case, spaces and punctuation are ignored (JKL9999 = JKL 9999) |
from | query | string | ISO date |
to | query | string | ISO date |
| Status | Meaning |
|---|---|
| 200 | {ok, plate, provider, count, track[]} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: tms:read |
| 404 | unknown plate |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
| 502 | provider error |
GET /api/v1/tms/jobs
Active assigned trips for the TMS to poll (plate, driver, from/to with coordinates, status)
Scope (any of): tms:read
| Parameter | In | Type | Notes |
|---|---|---|---|
status | query | string | comma list; default Approved,Picked-Up,Departed,In-Transit |
plate | query | string |
| Status | Meaning |
|---|---|
| 200 | {as_of, count, jobs[]} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: tms:read |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
Import / Export
GET /api/v1/datasets
Datasets, columns, aliases, limits
Scope (any of): master:read orders:read
| Status | Meaning |
|---|---|
| 200 | {datasets[], limits, formats, modes, dry_run_default} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: master:read or orders:read |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
GET /api/v1/export/{dataset}
Export a dataset as csv / xlsx / json
Scope (any of): orders:read master:read
The orders dataset needs orders:read; customers, lorries, drivers and charges need master:read.
| Parameter | In | Type | Notes |
|---|---|---|---|
dataset * | path | orders | customers | lorries | drivers | charges | dataset name |
format | query | csv | xlsx | json | |
status | query | string | only datasets with a status column |
from | query | string | YYYY-MM-DD, datasets with a date column |
to | query | string | YYYY-MM-DD |
| Status | Meaning |
|---|---|
| 200 | file or JSON |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: orders:read or master:read |
| 404 | unknown dataset |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
POST /api/v1/import/{dataset}
Import customers or lorries. Dry run unless dry_run is exactly false; atomic unless partial
Scope (any of): master:write
Body is JSON {format, content, dry_run, mode, partial, strict} (JSON is capped at 1 MB by the server) OR a RAW body (Content-Type text/csv for CSV, application/octet-stream for .xlsx bytes) with the options as query parameters, up to 5 MB. Max 5000 rows. Blank cells never clear a field.
| Parameter | In | Type | Notes |
|---|---|---|---|
dataset * | path | orders | customers | lorries | drivers | charges | dataset name |
format | query | csv | xlsx | json | |
dry_run | query | string | anything but "false" is a dry run |
mode | query | upsert | create_only | update_only | |
partial | query | string | "true" = skip bad rows and write the rest |
strict | query | string | "true" = unknown columns are an error |
Body (JSON, required)
{
"type": "object",
"properties": {
"format": {
"type": "string",
"enum": [
"csv",
"xlsx",
"json"
]
},
"content": {
"type": "string",
"description": "CSV text, base64 for xlsx, or JSON rows"
},
"dry_run": {
"type": "boolean",
"description": "default true"
},
"mode": {
"type": "string",
"enum": [
"upsert",
"create_only",
"update_only"
]
},
"partial": {
"type": "boolean",
"description": ""
},
"strict": {
"type": "boolean",
"description": ""
}
},
"required": [
"content"
]
}| Status | Meaning |
|---|---|
| 200 | {dataset, mode, dry_run, applied, summary, unknown_columns, ignored_columns, rows[]} |
| 401 | missing, malformed, unknown or revoked API key |
| 403 | key lacks scope: master:write |
| 405 | export-only dataset (orders, drivers, charges) |
| 413 | too big |
| 422 | row errors and not partial: nothing written |
| 429 | rate limit (300 requests/min/key) or too many bad credentials from this IP |
Webhooks
Subscribe with POST /webhooks (or a planner at /integrations). Each event is POSTed to your URL as JSON.
{
"headers": {
"X-Ymorder-Event": "event type",
"X-Ymorder-Delivery": "delivery id",
"X-Ymorder-Timestamp": "unix seconds",
"X-Ymorder-Signature": "v1=<hex HMAC-SHA256(secret, timestamp + \".\" + rawBody)>"
},
"body": {
"id": "evt_<n> (stable across retries: dedupe on it)",
"type": "event type",
"created_at": "ISO time",
"data": "event payload (order events carry an order snapshot)"
}
}
Signature: v1=<lowercase hex> where the digest is HMAC-SHA256 of `${X-Ymorder-Timestamp}.${raw request body}` with your subscription secret. Reject timestamps older than 300 seconds. Success = any 2xx within 8 s; retries after 1, 5, 30, 120, 720 minutes, then dead after 6 attempts. Dedupe on the envelope id.
| Event | Meaning |
|---|---|
order.created | a new order exists (WhatsApp, portal, planner or debtor API) |
order.status_changed | status moved (Approved, Picked-Up, Departed, In-Transit, Delivered, Rejected ...) |
order.assigned | a lorry / driver was assigned to an order (carries plate + driver: the TMS dispatch trigger) |
order.updated | planner or customer edited an order |
order.checkpoint | driver reported a checkpoint / POD |
invoice.exported | orders were exported to an accounting batch (batch id + totals) |
invoice.unmarked | an exported batch was reopened |
payment.imported | payments were imported / reconciled from the accounting system |
vehicle.position | a GPS position was received (high volume: subscribe on purpose) |
vehicle.near_destination | an in-transit lorry came within 5 km of its destination |
webhook.test | manual test ping from the admin page |