Get API keys
API v1

Delivery API

Send delivery orders from your website, POS or app to independent local drivers on the Terra Drive network. Get a price, book a driver, track the delivery in real time, and receive signed webhooks at every step. Pricing for restaurants is a flat $1.00 platform fee per completed delivery — nothing else is taken from your food.

Base URL: https://drive.aaterrainc.com · All requests and responses are JSON (UTF-8). Amounts are in US dollars as decimal numbers (e.g. 4.99). Timestamps are ISO 8601 UTC unless noted. Machine-readable spec: /openapi.json (OpenAPI 3.1).

Ohio pilot. We dispatch in live zones around Cincinnati. Live right now: see the zone map (from GET /v1/zones — the list is never hard-coded). See where we operate. Requests with a pickup outside a live zone return 422 outside_service_area.

Quickstart

1. In the restaurant portal, create a test key (td_test_…). 2. Ask for a quote. 3. Create the delivery. 4. Point a webhook at your server.

curl -X POST $BASE/v1/quotes \ -H "Authorization: Bearer td_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"dropoff":{"address":"8200 Montgomery Rd, Cincinnati, OH 45236"},"order_value":38.50}' # → 201 {"object":"quote","quote_id":"qt_…","fee":6.33,"distance_mi":4.49,"expires_at":"2026-11-06T18:15:00.000Z", …} curl -X POST $BASE/v1/deliveries \ -H "Authorization: Bearer td_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-10452" \ -d '{ "quote_id": "qt_…", "external_id": "10452", "pickup_notes": "Bag at the front counter", "dropoff": {"name":"Alex","phone":"+15135550123","address":"8200 Montgomery Rd, Cincinnati, OH 45236","notes":"Ring bell"}, "order_value": 38.50, "tip": 5.00, "items": [{"name":"Plov","quantity":2}], "payment": {"collect": false} }'

Authentication & keys

Authenticate every request with a secret key in the Authorization header:

Authorization: Bearer td_live_4f0c…
PrefixModeWhat happens
td_test_TestDeliveries are dispatched to simulated drivers. No card is charged and no money moves. Webhooks are sent normally so you can build your integration end to end.
td_live_LiveReal independent drivers are dispatched and real payments run through Stripe Connect.
  • Keys are shown once when created; we store only a hash. Lost a key? Revoke it and create a new one.
  • Keep keys on your server. Never put them in browser JavaScript or a mobile app.
  • Missing, revoked or unknown keys return 401 unauthorized. Objects created with a test key are never visible to a live key, and vice versa (they answer 404 not_found), and you never see another restaurant's objects.

Fees & money

One customer payment is split three ways. The math is exact and identical everywhere:

  • Restaurant platform fee: $1.00 per completed delivery.
  • Driver receives: delivery fee + 100% of the tip − $0.99 driver service fee ($0 for Founding Drivers).
  • Card processing (2.9% + 30¢ on the customer charge) is split pro-rata: the part on food + tax comes from the restaurant's share; the part on delivery fee + tip is paid by the platform out of the driver service fee.
  • Default delivery fee: $4.99 for the first 3 miles + $0.90 per mile after, max 15 miles (distance = straight line × 1.25). You can set a flat fee override in portal settings.
  • Cancellation: before pickup — no fees. After pickup — the driver is still paid the delivery fee − $0.99.

Live calculator: GET /api/fees/example?fee=6.33&tip=5&food=38.50 returns the full split. Example result for those numbers:

Loading example…

Create a quote

POST/v1/quotes

Prices a delivery from your restaurant's saved pickup address to the drop-off. Quotes are valid for 10 minutes (see expires_at); pass quote_id when creating the delivery to lock the fee. Returns 201 with a quote object.

FieldTypeDescription
dropoffobjectRequired. Either address (string, geocoded with OpenStreetMap) or lat + lng (both, valid numbers — otherwise 400 invalid_request).
pickupobjectReserved for multi-location accounts and ignored today: the pickup is always your saved restaurant address (portal → Settings).
order_valuenumberOptional. Food + tax in dollars. Used for the card-fee split.
{ "object": "quote", "quote_id": "qt_8mX2kQ0aB1cD3eF4", "fee": 6.33, "distance_mi": 4.49, "expires_at": "2026-11-06T18:15:00.000Z", "dropoff": {"lat": 39.2, "lng": -84.37, "address": "8200 Montgomery Rd, Cincinnati, OH 45236"}, "restaurant_fee": 1, "fee_rule": "$4.99 first 3 mi + $0.90/mi after (max 15 mi)" }

Create a delivery

POST/v1/deliveries

Creates a delivery. If payment.collect is false (you already charged the customer), dispatch starts immediately and the delivery fee + tip + $1.00 is charged to your saved payment method right away (fully refunded if you cancel before pickup or no driver is found; live mode without a saved payment method answers 402 no_payment_method). If true, the response includes a hosted pay_url; dispatch starts as soon as the customer pays.

FieldTypeDescription
quote_idstringOptional. Locks the quoted fee. Without it we price the delivery at creation. With it, dropoff address / coordinates may be left out (the quote's drop-off is used); if you send them they must match the quote.
external_idstringYour order number (max 100 chars). Shown to you, never to drivers. You can use it anywhere an id goes: GET /v1/deliveries/10452.
pickup_notesstringInstructions for the driver at your restaurant.
dropoff.namestringRequired. Customer name (drivers see first name only).
dropoff.phonestringRequired. Customer mobile, E.164 or US format. Returned only as phone_masked.
dropoff.addressstringRequired unless lat/lng or a quote_id is given.
dropoff.lat / lngnumberSkip geocoding by passing coordinates.
dropoff.notesstringApartment, gate code, "leave at door"…
order_valuenumberFood + tax in dollars. Required (> 0) when payment.collect is true; otherwise optional (default 0).
tipnumberDriver tip in dollars. 100% goes to the driver.
itemsarrayUp to 100: [{"name":"Plov","quantity":2,"price":12.5}]. quantity 1–99 (default 1), price optional.
payment.collectbooleantrue = we collect from the customer via pay_url; false = you already charged them.

Returns 201 with a delivery object. With collect: true its status stays created (and paid: false) until the customer pays on pay_url.

Retrieve a delivery

GET/v1/deliveries/:id
curl $BASE/v1/deliveries/dlv_Q2w3E4r5T6y7U8i9 \ -H "Authorization: Bearer td_test_YOUR_KEY"

:id is our id (dlv_…) or your external_id (the newest delivery with that external id wins). Unknown → 404 not_found.

List deliveries

GET/v1/deliveries?status=&external_id=&limit=&starting_after=

Newest first. status filters by state — one, a comma list (assigned,picked_up) or active (every unfinished state); an unknown status is 400 invalid_request. limit is a whole number 1–100 (default 20; anything else is 400). external_id filters by your order number. See Pagination for starting_after.

curl "$BASE/v1/deliveries?status=delivered&limit=50" \ -H "Authorization: Bearer td_test_YOUR_KEY" # → {"object":"list","data":[{…delivery…}, …],"count":50,"limit":50,"has_more":true,"next_starting_after":"dlv_…"}

Cancel a delivery

POST/v1/deliveries/:id/cancel

Cancels a delivery that is not yet delivered. Before pickup: no fees. After pickup: the driver is still paid the delivery fee − $0.99. Works for created, paid, dispatching, assigned, at_pickup, picked_up, at_dropoff and no_driver deliveries. Returns 200 with the updated delivery (cancel_reason becomes "api: <your reason>"); cancelling a delivered or already-cancelled order returns 409 invalid_state. :id may be your external_id. Body optional: {"reason": "…"} (max 200 chars). To retry a cancel safely after a network error, send an Idempotency-Key: the retry then replays the original 200 instead of answering 409.

curl -X POST $BASE/v1/deliveries/dlv_Q2w3E4r5T6y7U8i9/cancel \ -H "Authorization: Bearer td_test_YOUR_KEY" \ -H "Content-Type: application/json" -d '{"reason":"customer_request"}'

Send a test webhook

POST/v1/webhooks/test

Sends a signed webhook.test event to the URL saved in your portal, waits up to ~12 seconds for the first attempt and returns 200 with {"object":"webhook_test","ok":true|false,"result":{"status_code","attempts","error",…}}. A failed test event is retried on the normal schedule.

List service zones

GET/v1/zones

Returns every zone with its status: live (we dispatch there), coming_soon or waitlist. Each zone is a circle (center + radius_mi) or a GeoJSON polygon. Public, no-key version for your website: GET /api/zones.

curl $BASE/v1/zones -H "Authorization: Bearer td_test_YOUR_KEY" # → {"object":"list","data":[{"id":"loveland","name":"Loveland / Mason","city":"Loveland","state":"OH","status":"live","center":{"lat":39.305,"lng":-84.29},"radius_mi":7,"polygon":null}, …]}

The delivery object

{ "object": "delivery", "id": "dlv_Q2w3E4r5T6y7U8i9", "external_id": "10452", "status": "picked_up", "livemode": false, "restaurant": {"id": "rst_…", "name": "EurAsia", "address": "9559 Fields Ertel Rd, Loveland, OH 45140", "lat": 39.293, "lng": -84.2995}, "pickup_notes": "Bag at the front counter", "dropoff": {"name": "Alex", "phone_masked": "(•••) •••-0123", "address": "8200 Montgomery Rd, Cincinnati, OH 45236", "lat": 39.2, "lng": -84.37, "notes": "Ring bell"}, "items": [{"name": "Plov", "quantity": 2}], "order_value": 38.50, "fee": 6.33, "tip": 5.00, "driver_pay": 10.34, "restaurant_fee": 1.00, "driver_fee": 0.99, "distance_mi": 4.49, "driver": {"id": "drv_…", "first_name": "Sam", "vehicle": "Silver Toyota Camry", "lat": 39.25, "lng": -84.33}, "tracking_url": "https://drive.aaterrainc.com/track/…", "pay_url": null, "paid": false, "proof": null, "events": [{"status": "created", "at": "…"}, {"status": "dispatching", "at": "…"}, {"status": "assigned", "at": "…"}, {"status": "at_pickup", "at": "…"}, {"status": "picked_up", "at": "…"}], "created_at": "2026-11-06T18:02:11.000Z", "updated_at": "2026-11-06T18:14:40.000Z", "source": "api", "collect_payment": false, "payout_held": false, "cancel_reason": null, "split": {"customer_total": 12.33, "card_processing": 0.66, "restaurant_card_fee": 0, "restaurant_net": -12.33, "restaurant_charge": 12.33, "driver_pay": 10.34} }

driver_pay and driver_fee depend on who takes the order (Founding Drivers pay $0), so they are null until a driver is assigned — driver_pay_status is "pending_assignment", then "final" (the same applies to split.driver_pay). driver_fee is 0 when the driver is a Founding Driver; driver_pay is then fee + tip. proof becomes {"photo_url", "gps_ok"} on delivery. Driver identity is limited to first name and vehicle, and driver.lat/lng are null once the delivery is no longer active (privacy). split is the money breakdown for this delivery. Booking-only (collect: false, as above): restaurant_charge = fee + tip + $1.00 is what you pay. When we collect: customer_total = food + fee + tip, and restaurant_net = food − your card share − $1.00 is what you receive (e.g. $36.15 on a $38.50 order).

Delivery states

created→paid(pay link only)→dispatching→assigned→at_pickup→picked_up→at_dropoff→delivered

Side states: cancelled, no_driver (nobody accepted within 8 minutes — fall back to your own courier). Every transition is timestamped in events and sent as a webhook.

Webhooks

Set your endpoint and copy the signing secret (whsec_…) in Portal → Webhooks (the secret is shown once; "rotate" makes a new one). Live servers accept only public https:// URLs; the demo server also accepts http://localhost / 127.0.0.1 so you can test locally. One URL per restaurant receives both test and live events — check livemode. We POST JSON to it:

POST /your/webhook HTTP/1.1 Content-Type: application/json TerraDrive-Signature: t=1762452131,v1=5f2b0c…e81 TerraDrive-Event: delivery.picked_up TerraDrive-Event-Id: evt_… TerraDrive-Delivery-Attempt: 1 {"id":"evt_…","object":"event","type":"delivery.picked_up","livemode":false,"created":1762452131,"data":{"object":"delivery","id":"dlv_…","status":"picked_up",…}}

data is the same delivery object that GET /v1/deliveries/:id returns, as it was when the event fired.

EventWhen
delivery.createdThe delivery was created.
delivery.assignedA driver accepted.
delivery.picked_upThe driver has the food.
delivery.deliveredDropped off with photo + GPS proof.
delivery.cancelledCancelled by you or the platform.
delivery.no_driverNo driver within 8 minutes.
delivery.updatedAny other status change: paid, dispatching, at_pickup, at_dropoff (and a return to dispatching if a driver drops the order). Live driver positions are not sent as webhooks — use tracking_url or poll.
webhook.testSent by the "Send test event" button or POST /v1/webhooks/test.

Verify signatures

The TerraDrive-Signature header is t=<unix seconds>,v1=<hex> where v1 = HMAC_SHA256(secret, t + "." + raw_body). Always compute it over the raw request body (before JSON parsing), compare in constant time, and reject timestamps older than 5 minutes to block replays.

// Node 18+ — no dependencies import http from 'node:http'; import crypto from 'node:crypto'; const SECRET = process.env.TERRADRIVE_WEBHOOK_SECRET; // whsec_… function verify(rawBody, header, toleranceSec = 300) { if (!header) return false; const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('='))); const t = Number(parts.t); if (!Number.isFinite(t) || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false; const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(parts.v1, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } http.createServer((req, res) => { let raw = ''; req.setEncoding('utf8'); req.on('data', (c) => { raw += c; }); req.on('end', () => { if (!verify(raw, req.headers['terradrive-signature'])) { res.writeHead(400).end('bad signature'); return; } const event = JSON.parse(raw); // Acknowledge fast, then do your work (dedupe on event.id). res.writeHead(200).end('ok'); if (event.type === 'delivery.no_driver') { // e.g. call your own backup courier } }); }).listen(3000);
<?php // PHP 7.3+ $secret = getenv('TERRADRIVE_WEBHOOK_SECRET'); // whsec_… $raw = file_get_contents('php://input'); $header = $_SERVER['HTTP_TERRADRIVE_SIGNATURE'] ?? ''; function terradrive_verify(string $raw, string $header, string $secret, int $tolerance = 300): bool { $parts = []; foreach (explode(',', $header) as $kv) { $pair = explode('=', trim($kv), 2); if (count($pair) === 2) { $parts[$pair[0]] = $pair[1]; } } if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) { return false; } $t = (int) $parts['t']; if (abs(time() - $t) > $tolerance) { return false; } $expected = hash_hmac('sha256', $t . '.' . $raw, $secret); return hash_equals($expected, $parts['v1']); } if (!terradrive_verify($raw, $header, $secret)) { http_response_code(400); exit('bad signature'); } $event = json_decode($raw, true); http_response_code(200); echo 'ok'; // Dedupe on $event['id'], then handle $event['type'] / $event['data']. if ($event['type'] === 'delivery.delivered') { // mark the order delivered in your system }

Retries

Respond with any 2xx within 10 seconds. Otherwise we retry after 5 seconds, 30 seconds and 2 minutes (4 attempts total). Redirects are not followed. Every attempt is visible in Portal → Webhooks → Delivery log. The same event keeps its id on every attempt. Events can arrive more than once or out of order (a retried delivery.created can land after delivery.picked_up) — dedupe on id, and only apply an event whose data.updated_at is newer than the copy you already hold; data.status and data.events of the newest snapshot are the source of truth. When in doubt, GET the delivery.

Errors

Errors use standard HTTP status codes and a consistent body:

{"error": {"code": "missing_field", "message": "dropoff.name is required"}}

Branch on code; message is for humans and may change.

HTTPcodeMeaning
400invalid_jsonThe body is not a valid JSON object.
400invalid_requestAn invalid field or query value (bad number, coordinates, limit, status filter, cursor…).
400missing_fieldA required field is missing.
401unauthorizedMissing, invalid, or revoked API key.
402no_payment_method / payment_failedLive booking-only delivery (collect: false) without a working saved payment method.
404not_foundNo such object or route — also for another restaurant's delivery or a test object asked for with a live key (and vice versa).
405method_not_allowedThe path exists but not for this HTTP method.
409invalid_stateThe action is not allowed in the delivery's current state (e.g. cancelling a delivered order).
409idempotency_in_progressA request with the same Idempotency-Key is still running — retry shortly.
422outside_service_areaThe pickup is not inside a live zone. See /zones.
422out_of_rangeThe drop-off is more than 15 miles (route estimate) from the pickup.
422geocode_failedWe could not place the address on the map — check it, or pass lat/lng.
422no_pickup_locationYour restaurant has no saved address yet (portal → Settings).
422invalid_quote / quote_expired / quote_mismatchThe quote_id is unknown, expired, or was for a different drop-off — request a new quote.
413too_largeRequest body over 1 MB.
422idempotency_mismatchThe same Idempotency-Key was reused for a different method, path or request body.
422no_webhookPOST /v1/webhooks/test without a saved webhook URL.
429rate_limitedToo many requests — wait the number of seconds in Retry-After.
5xxserver_errorSomething went wrong on our side. Safe to retry with the same Idempotency-Key.

Idempotency

Send an Idempotency-Key header (any unique string up to 255 chars — your order number works well) on every POST. If a network error leaves you unsure whether a request succeeded, retry with the same key: we return the original response instead of creating a second delivery. Keys are stored for 24 hours and are scoped to your API key. A replayed response carries the header Idempotent-Replayed: true. Reusing a key for a different method, path or body (any changed value, e.g. a new tip) returns 422 idempotency_mismatch — key order and whitespace don't matter; a retry that arrives while the first request is still running gets 409 idempotency_in_progress. Error responses below 500 (except 409 and 429) are also replayed, so a fixed request needs a new key. The key is checked after authentication, and replays count toward your rate limit.

curl -X POST $BASE/v1/deliveries -H "Idempotency-Key: order-10452" …

Rate limits

120 requests per minute per API key. It is a sliding 60-second window. Every authenticated response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix seconds when the oldest request in the window expires and one more slot frees up). Over the limit you get 429 rate_limited with a Retry-After header (seconds). Back off and retry; for status, prefer webhooks over polling.

Pagination

GET /v1/deliveries uses a cursor. Each page has has_more and next_starting_after; pass that id back as starting_after until has_more is false. Cursors stay stable while new deliveries are created (new ones appear on page one, never twice). A starting_after that isn't one of your deliveries is 400 invalid_request.

curl "$BASE/v1/deliveries?limit=100&starting_after=dlv_Q2w3E4r5T6y7U8i9" -H "Authorization: Bearer td_test_YOUR_KEY"

Test mode

  • Test keys dispatch to simulated drivers who drive the route on the map in real time, so you can watch the tracking_url page and receive every webhook. On the demo server, your first test delivery brings simulated drivers online automatically; a full test delivery takes a few minutes. Test orders are never offered to real drivers.
  • With payment.collect: true, open the pay_url and use card 4242 4242 4242 4242, any future expiry, any CVC (4000 0000 0000 0002 is declined). No real charge is made. The customer may change the tip on that page, so read tip from the delivery afterwards.
  • Ledger entries in test mode use mock_ references and the exact same fee math as live mode.

Checkout on your own site

Two patterns:

  1. You charge the customer (your existing checkout) → create the delivery with payment.collect: false. We charge you the delivery fee + tip + $1.00 when it completes.
  2. We charge the customer → create the delivery with payment.collect: true and redirect your customer to pay_url. On payment, your share (food + tax − your part of card processing − $1.00) is transferred to your Stripe Connect account.

OpenAPI 3.1 spec

Every endpoint, schema, error, header and the webhook payload are described in /openapi.json (OpenAPI 3.1). Import it into Postman, Insomnia or a code generator.

Example integration

The project ships examples/integration-demo.js (Node 18+, no dependencies): a tiny ordering site that gets a quote, creates a collect: true delivery with an Idempotency-Key, sends the customer to pay_url, receives and verifies signed webhooks on a local port (answering 500 once to show the retry), and tracks the order to delivered. Run it against a demo server: TD_BASE=http://localhost:4340 node examples/integration-demo.js.

All money moves through Stripe Connect. Questions: [email protected].