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).
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.
Authentication & keys
Authenticate every request with a secret key in the Authorization header:
| Prefix | Mode | What happens |
|---|---|---|
td_test_ | Test | Deliveries 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_ | Live | Real 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 answer404 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:
Create a quote
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.
| Field | Type | Description |
|---|---|---|
| dropoff | object | Required. Either address (string, geocoded with OpenStreetMap) or lat + lng (both, valid numbers — otherwise 400 invalid_request). |
| pickup | object | Reserved for multi-location accounts and ignored today: the pickup is always your saved restaurant address (portal → Settings). |
| order_value | number | Optional. Food + tax in dollars. Used for the card-fee split. |
Create a delivery
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.
| Field | Type | Description |
|---|---|---|
| quote_id | string | Optional. 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_id | string | Your order number (max 100 chars). Shown to you, never to drivers. You can use it anywhere an id goes: GET /v1/deliveries/10452. |
| pickup_notes | string | Instructions for the driver at your restaurant. |
| dropoff.name | string | Required. Customer name (drivers see first name only). |
| dropoff.phone | string | Required. Customer mobile, E.164 or US format. Returned only as phone_masked. |
| dropoff.address | string | Required unless lat/lng or a quote_id is given. |
| dropoff.lat / lng | number | Skip geocoding by passing coordinates. |
| dropoff.notes | string | Apartment, gate code, "leave at door"… |
| order_value | number | Food + tax in dollars. Required (> 0) when payment.collect is true; otherwise optional (default 0). |
| tip | number | Driver tip in dollars. 100% goes to the driver. |
| items | array | Up to 100: [{"name":"Plov","quantity":2,"price":12.5}]. quantity 1–99 (default 1), price optional. |
| payment.collect | boolean | true = 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
:id is our id (dlv_…) or your external_id (the newest delivery with that external id wins). Unknown → 404 not_found.
List deliveries
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.
Cancel a delivery
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.
Send a test webhook
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
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.
The delivery object
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
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:
data is the same delivery object that GET /v1/deliveries/:id returns, as it was when the event fired.
| Event | When |
|---|---|
delivery.created | The delivery was created. |
delivery.assigned | A driver accepted. |
delivery.picked_up | The driver has the food. |
delivery.delivered | Dropped off with photo + GPS proof. |
delivery.cancelled | Cancelled by you or the platform. |
delivery.no_driver | No driver within 8 minutes. |
delivery.updated | Any 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.test | Sent 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.
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:
Branch on code; message is for humans and may change.
| HTTP | code | Meaning |
|---|---|---|
| 400 | invalid_json | The body is not a valid JSON object. |
| 400 | invalid_request | An invalid field or query value (bad number, coordinates, limit, status filter, cursor…). |
| 400 | missing_field | A required field is missing. |
| 401 | unauthorized | Missing, invalid, or revoked API key. |
| 402 | no_payment_method / payment_failed | Live booking-only delivery (collect: false) without a working saved payment method. |
| 404 | not_found | No such object or route — also for another restaurant's delivery or a test object asked for with a live key (and vice versa). |
| 405 | method_not_allowed | The path exists but not for this HTTP method. |
| 409 | invalid_state | The action is not allowed in the delivery's current state (e.g. cancelling a delivered order). |
| 409 | idempotency_in_progress | A request with the same Idempotency-Key is still running — retry shortly. |
| 422 | outside_service_area | The pickup is not inside a live zone. See /zones. |
| 422 | out_of_range | The drop-off is more than 15 miles (route estimate) from the pickup. |
| 422 | geocode_failed | We could not place the address on the map — check it, or pass lat/lng. |
| 422 | no_pickup_location | Your restaurant has no saved address yet (portal → Settings). |
| 422 | invalid_quote / quote_expired / quote_mismatch | The quote_id is unknown, expired, or was for a different drop-off — request a new quote. |
| 413 | too_large | Request body over 1 MB. |
| 422 | idempotency_mismatch | The same Idempotency-Key was reused for a different method, path or request body. |
| 422 | no_webhook | POST /v1/webhooks/test without a saved webhook URL. |
| 429 | rate_limited | Too many requests — wait the number of seconds in Retry-After. |
| 5xx | server_error | Something 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.
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.
Test mode
- Test keys dispatch to simulated drivers who drive the route on the map in real time, so you can watch the
tracking_urlpage 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 thepay_urland use card4242 4242 4242 4242, any future expiry, any CVC (4000 0000 0000 0002is declined). No real charge is made. The customer may change the tip on that page, so readtipfrom 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:
- 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. - We charge the customer → create the delivery with
payment.collect: trueand redirect your customer topay_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].