Concepts
Core concepts behind the r4m.ai API: authentication, pricing, async processing, and webhook delivery.
Authentication
All API requests require a Bearer token in the Authorization header. API keys are prefixed with acc_ and can be created in the Developer Console or via the API.
The SDKs add the header for you: you pass the key once, when you build the client, and never name the header again.
import { createClient } from "@r4mai/sdk";
// The key is the only credential; the client sends it as a Bearer token.
const r4m = createClient({ apiKey: process.env.R4M_API_KEY! });For MCP, the key goes in the server entry your client reads — "Authorization": "Bearer acc_…" — and nothing is passed per call after that. Per-environment configs are in the MCP quick start. Keys, balance and rate limits are the same across REST, the SDKs and MCP; the surface does not change what a key is allowed to do.
API Keys (machine-to-machine)
Keys are SHA-256 hashed before storage. The raw key is shown only once when created -- store it securely. Keys can be revoked instantly via the API or Developer Console.
Pricing & Plans
Each optimization and geocoding request is paid for from a single prepaid balance in US dollars, priced by the number of addresses. The workflow is simple: top up, spend over time, top up again when you run low. There is no monthly allowance and no reset — the balance only changes when you top up or spend, and it never expires.
- Every new account starts with $1.00 free.
- Larger top-ups earn a volume bonus (+10% at $50, +15% at $100, +20% at $200).
- When the balance can't cover a request it fails with
402 INSUFFICIENT_FUNDS. Top up and retry. GET /api/v1/usagereturns yourbalanceMicrosand aledgerof recent movements. Every amount in the API is an integer in micro-dollars (1 USD = 1 000 000 micros, so a half-cent geocode is exactly 5 000) in a field named…Micros— no float, no rounding, and the ledger always sums to the balance. There is no allowance and no billing period.
Plans
A plan buys capacity — more addresses per request, more API keys, and a more persistent webhook. Anything beyond Pro, including dedicated support, is agreed individually. Plans do not include or discount usage; requests are priced the same on every plan and paid from the balance. The rate limit in the table below is a safety limit protecting the backend, not something a plan sells — every tier sits far above any normal workload, so you should never meet one.
| Plan | Price | Rate Limit | Max Addresses | Max API Keys | Concurrent |
|---|---|---|---|---|---|
| Free | $0 | 60 req/60s | 25 | 1 | 1 |
| Pro | $49/mo | 120 req/60s | 100 | 5 | 2 |
Your current plan and its limits are on the billing page in the console, along with the button to subscribe to Pro. Once you have a subscription, changing or cancelling it happens in Stripe's billing portal, and the new limits apply here as soon as Stripe tells us — usually within seconds. Anything beyond Pro has no list price: it starts with a conversation, and we set the agreed limits on your account.
Optimization Prices
Flat tiers up to 250 stops. Above that the cost is the distance matrix rather than the address list — pairs grow as the square of the stop count, so 500 stops is four times the work of 250, not twice — and the price follows it: roughly stops² / 2500 cents, rounded up.
| Addresses | Price |
|---|---|
| 1 - 10 | $0.01 |
| 11 - 25 | $0.02 |
| 26 - 50 | $0.05 |
| 51 - 100 | $0.10 |
| 101 - 250 | $0.25 |
| 251 - 1000 | $0.26 - $4.00 |
Geocoding Prices
One lookup per address, so the price is linear: $0.005 per address, exactly — no rounding.
| Addresses | Price |
|---|---|
| per address | $0.005 |
| 1000 addresses | $5.00 |
Machine Payments
PreviewMachine payments let an AI agent top up the balance on its own, without a human in the loop, using the 402 Payment Required pattern (Stripe's Machine Payments Protocol). This is a preview: the endpoint and MCP tool exist, but the payment rail is enabled per deployment — until then requests return 501 NOT_CONFIGURED.
The flow
- A billable request cannot be covered by the balance and returns
402 INSUFFICIENT_FUNDS. When machine payments are enabled the body carries atopuphint{ endpoint, minCents, grantMicros }. - The agent calls
POST /api/v1/credits/topupand receives an MPP challenge describing what to pay. - The agent pays and retries with a payment credential.
- We add the amount to the prepaid balance and return a receipt; the agent retries the original request.
Agents using the MCP server can drive this with the request_topup tool. Fulfillment feeds the same single balance as a card top-up — machine payments only change how the balance is funded, never how it is spent.
Optimization Parameters
Optimization requests accept parameters that control routing behavior. The fleet constraints are a plan limit: each plan either does not offer a parameter or caps the value it accepts.
Plan-Gated Parameters
optimizeMode, travelMode, roundTrip, routeStartAt, timezone and every per-address field (time windows, priority, service time) are available on all plans, including Free. The VRP (Vehicle Routing Problem) constraints below are not: a request that sends one the plan does not offer, or a value above the plan's ceiling, receives a 400 PLAN_LIMIT_EXCEEDED error naming the parameter. Ceilings beyond Pro are part of an individual agreement.
| Parameter | Free | Pro |
|---|---|---|
maxRoutes | — | 50 |
maxRouteDurationSeconds | — | 86400 |
maxRouteDistanceMeters | — | 2000000 |
Units
Distances are in meters and durations in seconds, in requests and responses alike, and every field says so in its own name — maxRouteDistanceMeters, serviceTimeSeconds, totalDistanceMeters. There is no unit switch to set and none to read back: a number in this API means one thing wherever you meet it, and converting for display is your side's decision, not a mode you have to remember having chosen.
Timezones
09:00 is a reading on a clock, not a moment in time. timezone says whose clock.
It bites when a window lands past a daylight-saving change. Leave at 22:00 on 7 March with a window at 06:00:
| Real gap | |
|---|---|
timezone: "America/New_York" | 7 hours — the clocks go forward overnight |
| assumed UTC | 8 hours — the stop is planned an hour late |
It also stamps the answer: arrivalAt comes back as 09:00-04:00, a real instant. A wrong zone shifts every timestamp — not twice a year, every day.
Required, along with routeStartAt, whenever any stop carries a window; not needed otherwise. Working in UTC is fine — say "UTC" explicitly rather than leaving it out.
Async Flow
Route optimizations are processed asynchronously. The POST returns a 202 immediately — it only queues the job, so your request thread is never blocked behind a solver. The optimization itself takes roughly 2–60 seconds, depending on how many stops and constraints you sent, which is the gap between the pending you get back and the completed result.
Choosing how to collect it
Two questions decide it. Can we reach you over the network — and if not, do you want to run the waiting loop yourself? None of these are exclusive: you can set a webhook and still poll, as a belt-and-braces check that a delivery did not go missing.
We notify you
Nothing to run on your side, but we need somewhere to deliver.
- Webhook — send a
webhookUrland we POST the finished plan to it, signed with HMAC-SHA256 and retried on failure. Needs a publicly reachable HTTPS endpoint. See Webhooks.
You ask us
Works from anywhere, including behind NAT, with no inbound endpoint.
- We wait for you —
createOptimizationAndWaitForRoutessubmits and returns the finished plan in one call, orwaitForRouteswaits on an id you already have. The SDK runs the loop, backing off and honouringRetry-Afterfor you — and treats both 429s as "busy", waiting them out (or asking youronBusycallback) rather than failing; see When the API says busy. Best for scripts, CLIs and agents. - You poll yourself — call
GET /api/v1/optimizations/{id}on your own schedule until the status iscompletedorfailed. Right when submission and collection happen in different places — a batch of jobs written to your own queue, picked up later by a worker. See the polling pattern. A poll every two seconds is 30 requests a minute, inside even the Free limit of 60.
POST
Submit
202
Accepted
Queue
Enqueued
Optimize
Engine
Result
Webhook / Poll
Status Lifecycle
pendingRequest accepted, waiting in queueprocessingOptimization engine is computing the optimal routecompletedResult is ready to retrievefailedProcessing error (check errorMessage)An optimization stays in your account until you delete it with DELETE /api/v1/optimizations/{id}, which removes the request and the result permanently.
Webhooks
When you include a webhookUrl in your optimization request, we'll POST the result to that URL when processing completes.
Delivery
- Delivered asynchronously (not inline with processing)
- Sent on both outcomes — a finished plan and a failed optimization
- Retried with backoff — how many times and how long we wait on your endpoint depends on your plan: Free 2 attempts / 5s timeout, Pro 3 / 10s, beyond Pro by agreement
- Signed with HMAC-SHA256 using your webhook secret. Every account has one from sign-up: read it with
GET /api/v1/webhook-secretor reveal it in the console settings, where it is also rotated. Any API key can read it, so if a key leaks, revoke the key and rotate the secret. - Sent only to a public https URL with a host name: plain http, IP addresses and localhost are refused when the optimization is created, a name that resolves to a private-network address is refused at delivery, and redirects are not followed
Testing locally
We deliver from our servers, so the URL has to be reachable from the internet — a localhost address never receives anything. The quickest way to see real deliveries hit your machine is a tunnel. With ngrok installed and your handler listening on port 3000:
ngrok http 3000
# Forwarding https://a1b2c3d4.ngrok-free.app -> http://localhost:3000Pass the https address as webhookUrl and every delivery, including the retries, lands in your local handler, with ngrok's inspector showing the raw request. The address changes each time ngrok restarts on the free plan, so re-send the optimization rather than wondering why nothing arrived.
Payload
Two events, told apart by type. A completed optimization carries result and no error; a failed one carries error and no result. Switch on the event rather than checking which field turned up.
{
"type": "optimization.completed",
"optimizationId": "550e8400-e29b-41d4-a716-446655440000",
"result": {
"routes": [
{
"routeStartAt": "2026-04-14T08:00:00-04:00",
"totalDistanceMeters": 19473,
"totalTimeSeconds": 8285,
"polyline": "ulpwFpplbMl@qA@CLOYYIIIK…",
"waypoints": [
{
"index": 0,
"arrivalAt": "2026-04-14T08:00:00-04:00",
"departureAt": "2026-04-14T08:15:00-04:00",
"waitTimeSeconds": 0,
"cumulativeDistanceMeters": 0,
"cumulativeTimeSeconds": 0,
"polylineToNext": "ulpwFpplbMl@qA@CLOYYIIIK…"
},
{
"index": 1,
"arrivalAt": "2026-04-14T09:00:00-04:00",
"departureAt": "2026-04-14T09:10:00-04:00",
"waitTimeSeconds": 2543,
"cumulativeDistanceMeters": 1416,
"cumulativeTimeSeconds": 3600,
"polylineToNext": "}brwFhckbMI{A?IGkAIqBMkC…"
}
]
}
],
"unroutedStops": []
}
}A failure. It is delivered like any other event, so a subscriber never has to poll to find out that nothing is coming. The charge for a job that threw is refunded before this is sent.
{
"type": "optimization.failed",
"optimizationId": "550e8400-e29b-41d4-a716-446655440000",
"error": {
"code": "NO_ROUTES",
"message": "Route4Me returned no routes"
}
}NO_ROUTES— the engine finished but produced no routes; the charge was refunded.PROCESSING_FAILED— the job threw while being processed; the charge was refunded.TIMED_OUT— the optimization did not finish within 15 minutes; the charge was refunded and its concurrency slot was released. Sent when the stale optimization is next read, not at the deadline itself.
Signature Verification
Every delivery carries an X-Webhook-Signature header: the HMAC-SHA256 of the raw body, hex-encoded, keyed with your webhook secret. Hand the SDK the request and your secret — it finds the header, reads the body unparsed, checks the digest in constant time, and returns the typed event. It throws rather than returning a boolean, because a boolean is easy to forget to check.
On a runtime with no request object to pass, give it the raw body instead — exactly the bytes you received. Re-serialising a parsed object changes key order and whitespace, so the signature will never match.
import { verifyWebhook, WebhookSignatureError } from "@r4mai/sdk";
export async function POST(request: Request) {
try {
const event = await verifyWebhook(request, process.env.R4M_WEBHOOK_SECRET!);
if (event.type === "optimization.failed") {
console.error(event.optimizationId, event.error.code, event.error.message);
return new Response(null, { status: 204 });
}
console.log(event.optimizationId, event.result.routes.length);
return new Response(null, { status: 204 });
} catch (err) {
if (err instanceof WebhookSignatureError) {
return new Response("Invalid signature", { status: 401 });
}
throw err;
}
}Response Format
All endpoints return JSON with a consistent envelope:
Success
{
"success": true,
"data": { ... }
}Error
{
"success": false,
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "Insufficient balance: $0.01 needed."
}
}Error Codes
| HTTP | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Request body failed validation |
| 400 | ADDRESS_LIMIT_EXCEEDED | Address count exceeds plan limit |
| 400 | PLAN_LIMIT_EXCEEDED | Plan limit exceeded |
| 401 | UNAUTHORIZED | Missing or invalid API key |
| 401 | API_KEY_REVOKED | API key has been revoked |
| 402 | INSUFFICIENT_FUNDS | The prepaid balance cannot cover this request |
| 403 | FORBIDDEN | Access denied |
| 404 | NOT_FOUND | Resource not found |
| 409 | OPTIMIZATION_IN_PROGRESS | The optimization is still pending or processing; it can be deleted once it has finished |
| 422 | GEOCODING_UNRESOLVED | An address sent without coordinates could not be pinned to exactly one precise place; `details.unresolved` lists each with its candidates, `details.resolved` the coordinates already found |
| 429 | RATE_LIMITED | Too many requests per second |
| 429 | CONCURRENCY_LIMIT_EXCEEDED | As many optimizations as the plan allows are already in flight; wait for one to finish, then retry |
| 500 | INTERNAL_ERROR | Internal server error |
| 501 | NOT_CONFIGURED | Feature is not enabled on this deployment |
| 503 | SERVICE_UNAVAILABLE | Temporarily refusing traffic to protect the service |