Skip to content

Developers

Conventions and policies

The rules every endpoint follows, and the promises we make about changing them.

On this page

Times and time zones

Every time field (start_time, approved_at, occurred_at...) is an ISO 8601 instant in UTC, written with milliseconds and Z (2026-11-07T04:07:00.000Z). Parse them as instants rather than comparing strings. Each record also says where the wait happened: time_zone (an IANA name such as America/Phoenix, from the terminal), start_time_local / end_time_local (the same instants with that zone's offset, e.g. 2026-11-07T21:07:00.000-07:00) and local_start_date (the wait's day at the terminal). The terminal object, and GET /api/v1/terminals, carry the terminal's time_zone.

One wait, three ways
{
  "start_time": "2026-11-08T04:07:00.000Z",
  "time_zone": "America/Phoenix",
  "start_time_local": "2026-11-07T21:07:00.000-07:00",
  "local_start_date": "2026-11-07"
}

The wait above started at 9:07 PM on November 7 at a Phoenix terminal, which is 04:07 UTC on November 8. Bill and report it by local_start_date (the 7th), and show start_time_local to people. Times you send (such as start_time when reporting a wait) can carry any offset; send UTC with Z when you can.

Money and units

Amounts (amount, amount_approved, hourly_rate, proposed_amount) are US dollars as JSON numbers with up to two decimals (125.5 is $125.50), not cents; the one exception is a payment-rules problem, whose *_cents fields say so in their names. hourly_rate is dollars per hour. Minutes (total_minutes, free_time_minutes, billable_minutes) are whole minutes; billable time is charged in 15-minute steps, rounded up.

Example: a 195-minute wait with 120 minutes of free time leaves 75 billable minutes; at hourly_rate 100 that's 1.25 hours, so amount is 125 ($125.00). Read amounts as decimals (not floats you then round differently), and treat the API's amount as final rather than recomputing it: an accepted dispute offer or adjusted times change it.

IDs

You needWhere it comes from
record idA record's id (UUID), from GET /api/v1/records or the 201 of POST /api/v1/records. record_number (DMR-00001042) is what people quote; don't use it in paths.
customer_idThe fuel buyer: partner.id of an active connection from GET /api/v1/connections. Not the connection's own id. The record's customer.id is the same id.
driver_id / driver_numberFrom GET /api/v1/drivers: send the id as driver_id, or your own driver_number.
terminal_idAn id from GET /api/v1/terminals (terminals configured for your company).
report idAn id from GET /api/v1/reports, for GET /api/v1/reports/{id}/data.

All ids are UUIDs. Store them as strings; never build or guess them.

Idempotency

Send an Idempotency-Key header with a write: a unique string of 1 to 255 letters, digits, underscores, periods, colons or hyphens (a UUID is ideal; anything else is 400). A retry with the same key and body returns the first response, with Idempotent-Replayed: true, instead of acting twice. The body must be byte-identical: the same key with a different body (even different spacing or key order) gets 422 IDEMPOTENCY_KEY_REUSED; use a new key when you change the request. A retry while the first request is still running gets 409 IDEMPOTENCY_IN_PROGRESS with Retry-After. Keys last 24 hours. Creating a record requires one.

Only final answers are kept under a key and replayed: 2xx, and 4xx that the same request would get again (400 validation errors, 404, 409 INVALID_STATUS / CONFLICT / OVERLAPPING_WAIT, 413, 415). Answers a retry could change are not kept, and a retry with the same key runs again: 5xx, 401, 403 (for example the key's creator was made an admin again), 429, and the retryable 409s (RECORD_CHANGED, RECORD_LOCKED). So after a 400, fix the request and send it with a new key: the old key answers 422 for the changed body.

In practice:

  • Make a new key (a UUID) for each new action, and save it with the action before you send it.
  • On a timeout, a dropped connection, a 5xx, a 429 or a retryable 409, send the exact same bytes with the same key.
  • Serialize the body once and resend those bytes: re-serializing can change spacing or key order, which counts as a different request (422 IDEMPOTENCY_KEY_REUSED).
  • After a 400 or any change to the request, use a new key.

Requests and responses

Send JSON bodies with Content-Type: application/json (or a +json type); a non-empty body of any other type gets 415. A write with no body (approve, or send with no options) needs no Content-Type. Unknown query parameters are ignored; a known one with a bad value (limit, cursor, status, include, format) is 400 VALIDATION_ERROR naming it, never silently clamped. Text such as load and BOL numbers and notes is stored exactly as sent. An unknown path under /api/v1 is a JSON 404 NOT_FOUND; a method a path doesn't take is a JSON 405 METHOD_NOT_ALLOWED with an Allow header (and details.allow). A 401 carries WWW-Authenticate: Bearer realm="demurly".

Every response carries X-Request-Id, and every error body repeats it as error.request_id. Log it, and quote it when you write to support: it finds your request in our logs. You can send your own (8 to 64 letters, digits, periods, underscores, colons or hyphens) to tie our logs to yours; otherwise Demurly makes one.

Rate limits

Each API key has its own per-minute limit (default 60, configurable from 1 to 1000). A company's keys together are also capped at 3,000 requests a minute; when that ceiling is the tighter one, X-RateLimit-Limit reports it. Responses include X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (the Unix time, in seconds, when the window resets). Over the limit, the API returns 429 RATE_LIMITED with a Retry-After header (seconds); wait that long. Requests with a missing or invalid key are limited too: 60 a minute from one IP address.

Versioning and deprecation

The API's version is in the path (/api/v1). Within v1, changes are additive and can come at any time: new endpoints, new optional request fields, new response fields, new values in a field with a fixed set of values (such as status, source or a webhook's type) and new webhook event types. Write your client to ignore fields and values it doesn't know. A breaking change (removing or renaming a field, changing a type or a meaning, a new required field) only comes in a new version, announced at least 6 months ahead in the changelog and by email to every company with an active key; the old version keeps working for at least 12 months after the new one ships. A deprecated field or endpoint is marked in this file and the changelog first, and keeps working until its version ends.

In short: build your client to tolerate additions, and you won't need to change it within v1. Every API change, additive or not, is posted in the changelog.

Testing your integration

Build and test your integration without touching real charges. Create a test key under Settings > API Keys > Test: test mode is free for every company (carriers don't need Pro for it), and creating a test key needs 2FA like any key. A test key works with every endpoint, on your company's sandbox, made on its first call: your test company (<your name> (test mode), of your kind) connected to a test counterparty (a test fuel buyer for a carrier, a test carrier for a fuel buyer), with sample drivers (T-101 to T-103) and terminals. Live and test data never mix: a test key can't see your real records, and a live key can't see the sandbox. Every JSON body an API key gets carries livemode (true for live keys, false for test keys), errors included.

The sandbox sends no email or text to anyone, takes no payment, records no platform fee, syncs nothing to accounting systems and sends no EDI. Webhooks do fire: test-mode events go only to endpoints you add as test endpoints (Settings > Webhooks), with livemode: false in the body; a live endpoint never gets a test event, and a test endpoint never gets a live one.

To see the other side's moves, test keys can call the simulation endpoints under /api/v1/test (tag Test mode): the sandbox's fuel buyer approves, disputes, pays in Demurly or marks a charge paid outside Demurly; its carrier creates a sample charge (how a fuel buyer's test key receives one) and confirms an outside payment. Each step sends the same webhooks a real one would. POST /api/v1/test/sandbox/reset (or Reset sandbox in Settings > API Keys > Test) deletes the sandbox's records; its drivers, terminals and connection stay. Live keys get 403 from /api/v1/test.

  1. Make a test key. Under Settings > API Keys, switch to Test and create a key with the scopes you'll use. It starts dmrl_test_; keep it out of your production config.
  2. Add a test endpoint under Settings > Webhooks (choose Test), pointed at a tunnel to your machine (ngrok, Cloudflare Tunnel) or your staging server. It gets only test-mode events.
  3. Run the lifecycle. A carrier reports a wait with POST /api/v1/records (driver T-101, the test fuel buyer from GET /api/v1/connections) and plays the buyer with the simulations; a fuel buyer gets a charge from POST /api/v1/test/records and approves or disputes it with its own key.
  4. Start over with POST /api/v1/test/sandbox/reset or Reset sandbox in Settings. Then swap in a live key: the same code works against your real data.
A carrier's test run (curl)
# Report a wait as your sandbox's carrier (test keys start dmrl_test_)
curl -s https://demurly.com/api/v1/test/records \
  -X POST -H "Authorization: Bearer $DEMURLY_TEST_KEY" \
  -H "Content-Type: application/json" -d '{"wait_minutes": 180}'
# => { "data": { "id": "3f6c...", "status": "sent_to_customer", ... }, "livemode": false }

# Play the fuel buyer: approve, then pay (no money moves)
curl -s -X POST -H "Authorization: Bearer $DEMURLY_TEST_KEY" \
  https://demurly.com/api/v1/test/records/3f6c.../approve
curl -s -X POST -H "Authorization: Bearer $DEMURLY_TEST_KEY" \
  https://demurly.com/api/v1/test/records/3f6c.../pay
# Your test endpoint gets record.sent, record.approved and record.paid, each with "livemode": false

Browsers, mobile apps and CORS

The API is for servers. It sends no CORS headers, so a browser refuses a call from a web page, on purpose: an API key is a secret that can read and write your company's charges, and anything shipped to a browser or a phone can be read by its user. Call the API from your server, and have your web or mobile app talk to your server.

If a key ever leaks, revoke it under Settings > API Keys (it stops working at once) and create a new one.

NextGlossaryDemurrage, detention, free time, BOL, rack and the other terms the API uses.
API conventions and policies