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/usage returns your balanceMicros and a ledger of 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.

PlanPriceRate LimitMax AddressesMax API KeysConcurrent
Free$060 req/60s2511
Pro$49/mo120 req/60s10052

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.

AddressesPrice
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.

AddressesPrice
per address$0.005
1000 addresses$5.00

Machine Payments

Preview

Machine 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

  1. A billable request cannot be covered by the balance and returns 402 INSUFFICIENT_FUNDS. When machine payments are enabled the body carries a topup hint { endpoint, minCents, grantMicros }.
  2. The agent calls POST /api/v1/credits/topup and receives an MPP challenge describing what to pay.
  3. The agent pays and retries with a payment credential.
  4. 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.

ParameterFreePro
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 UTC8 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 webhookUrl and 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 — createOptimizationAndWaitForRoutes submits and returns the finished plan in one call, or waitForRoutes waits on an id you already have. The SDK runs the loop, backing off and honouring Retry-After for you — and treats both 429s as "busy", waiting them out (or asking your onBusy callback) 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 is completed or failed. 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 queue
processingOptimization engine is computing the optimal route
completedResult is ready to retrieve
failedProcessing 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-secret or 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:3000

Pass 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

HTTPCodeDescription
400VALIDATION_ERRORRequest body failed validation
400ADDRESS_LIMIT_EXCEEDEDAddress count exceeds plan limit
400PLAN_LIMIT_EXCEEDEDPlan limit exceeded
401UNAUTHORIZEDMissing or invalid API key
401API_KEY_REVOKEDAPI key has been revoked
402INSUFFICIENT_FUNDSThe prepaid balance cannot cover this request
403FORBIDDENAccess denied
404NOT_FOUNDResource not found
409OPTIMIZATION_IN_PROGRESSThe optimization is still pending or processing; it can be deleted once it has finished
422GEOCODING_UNRESOLVEDAn 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
429RATE_LIMITEDToo many requests per second
429CONCURRENCY_LIMIT_EXCEEDEDAs many optimizations as the plan allows are already in flight; wait for one to finish, then retry
500INTERNAL_ERRORInternal server error
501NOT_CONFIGUREDFeature is not enabled on this deployment
503SERVICE_UNAVAILABLETemporarily refusing traffic to protect the service