Developers
Quickstart
From an API key to your first record in five minutes: one call to read, one to write, and what to do when a call fails.
On this page
1. Get an API key
An admin of your company creates keys in Demurly under Settings > API Keys > Create API Key, choosing a name, the scopes (what the key may do) and a rate limit. Three things to know first:
- Two-step sign-in (2FA) is required. Turn it on under Settings > Security before you create the key; sign in with your authenticator code. Without it the key can't be created.
- Plan. Carriers need Pro ($199 a month) to set this up; upgrade on Settings > Plan & Billing. Fuel buyers get it free. Test keys are free for everyone.
- Start with a test key. Switch the API Keys screen to Test to make one (it starts
dmrl_test_). It works on a sandbox of your own with sample drivers, terminals and a test counterparty: nothing reaches a real company, no money moves, and the simulation endpoints play the other side. Swap in a live key when you're done. - The key acts as you. Whatever it writes is recorded as the admin who created it, marked as coming from the API. If that person stops being an admin, the key can still read but no longer write.
The key starts with dmrl_ and is shown once. Store it as a secret on your server, for example in an environment variable called DEMURLY_API_KEY. Never put it in a web page or a mobile app: the API is server-to-server and browsers are refused on purpose (no CORS).
export DEMURLY_API_KEY="dmrl_..."2. Make your first call
List your records with GET /api/v1/records. Send the key as a Bearer token. The base URL is https://demurly.com.
curl -i "https://demurly.com/api/v1/records?limit=2" \
-H "Authorization: Bearer $DEMURLY_API_KEY"A 200 with { "data": [...], "has_more": ..., "next_cursor": ... } means you're in. A new company gets an empty data list. Every body also says livemode: true for a live key, false for a test key. Every response carries an X-Request-Id header (log it: support uses it to find the call) and X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
A 401?
Authorization: Bearer dmrl_..., with no quotes or spaces in the key, and that the key hasn't been revoked. A 403 means the key lacks the scope, or your company isn't on the plan the API needs.3. Page through records
The list is newest first, up to limit records a page. Send the next_cursor you got back as cursor to get the next page; on the last page has_more is false and next_cursor is null. Cursors are opaque: don't build or change them.
// Every record, page by page (newest first)
const records = [];
let cursor = null;
do {
const query = new URLSearchParams({ limit: "100", ...(cursor ? { cursor } : {}) });
const response = await fetch(`https://demurly.com/api/v1/records?${query}`, {
headers: { Authorization: `Bearer ${process.env.DEMURLY_API_KEY}` },
});
const page = await response.json();
if (!response.ok) throw new Error(`${page.error.code}: ${page.error.message}`);
records.push(...page.data);
cursor = page.next_cursor; // null on the last page
} while (cursor);
console.log(records.length, "records");To keep a copy of your records up to date, don't re-read everything: poll for changes with updated_since and let webhooks tell you when to look. Keep your system in sync shows how.
4. Create a record
Carriers report a finished wait from their own system (TMS, ELD, gate log) with POST /api/v1/records. You need three ids:
- the driver:
driver_number, or anidfromGET /api/v1/driversasdriver_id - the terminal: an
idfromGET /api/v1/terminalsasterminal_id - the fuel buyer:
customer_idispartner.idof an active connection fromGET /api/v1/connections(not the connection's ownid)
Send an Idempotency-Key header with every write: a new UUID for each new request, and the same one when you retry that request. A retry with the same key and body returns the first answer (with Idempotent-Replayed: true) instead of creating a second record. Creating a record requires one.
curl -X POST "https://demurly.com/api/v1/records" \
-H "Authorization: Bearer $DEMURLY_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"driver_number": "1001",
"start_time": "2026-01-15T14:30:00Z",
"end_time": "2026-01-15T17:45:00Z",
"terminal_id": "a1b2c3d4-0000-4000-8000-000000000003",
"customer_id": "a1b2c3d4-0000-4000-8000-000000000002",
"load_number": "L-55821",
"send": true
}'The answer is 201 with the new record. With send: true it goes straight to the buyer; if it can't (for example the wait was inside free time, so nothing is billable) it's still created, in pending_review, and send_error says why. Times are UTC instants; the record adds the terminal's time_zone and local times (times and time zones).
Fuel buyers write too: approve a charge with POST /api/v1/records/{id}/approve (no body), or dispute it with POST /api/v1/records/{id}/dispute.
curl -X POST "https://demurly.com/api/v1/records/3f6c1d2e-8a4b-4c1e-9f0a-1b2c3d4e5f60/approve" \
-H "Authorization: Bearer $DEMURLY_API_KEY" \
-H "Idempotency-Key: $(uuidgen)"5. Handle an error
Every error has the same body. Branch on code (stable), show message to people if you like (its wording can change), and log request_id. Some codes add details; a VALIDATION_ERROR names each bad field:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Some fields are missing or invalid",
"request_id": "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f",
"details": {
"fields": {
"end_time": [
"end_time can't be in the future"
]
}
}
}
}Retry only what can work later: 429 and 503 (wait Retry-After seconds), 500, and the retryable 409s. Everything else needs a change on your side first, and a changed request needs a new Idempotency-Key. The error catalogue lists every code with its fix.
import { randomUUID } from "node:crypto";
/** POST with one Idempotency-Key for every attempt; retries only what can work later */
async function post(url, body) {
const key = randomUUID();
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.DEMURLY_API_KEY}`,
"Idempotency-Key": key,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const result = await response.json();
if (response.ok) return result.data;
const { code, message, request_id } = result.error;
// 429, 503, 500 and the retryable 409s (RECORD_CHANGED, RECORD_LOCKED, IDEMPOTENCY_IN_PROGRESS)
const retryable = response.status === 429 || response.status >= 500 ||
["RECORD_CHANGED", "RECORD_LOCKED", "IDEMPOTENCY_IN_PROGRESS"].includes(code);
if (!retryable || attempt === 4) {
throw new Error(`${response.status} ${code}: ${message} (request ${request_id})`);
}
const wait = Number(response.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}