What you can build
One API for rides, deliveries and car rental — plugged into your own website, app or back-office. A few of the ways companies use Areye Business:
Hotels & lodges
Offer guests airport transfers, city rides and self-drive car rental — booked from your own website or front desk, without owning a single vehicle.
POST /business/bookPOST /business/hire/{vehicle}/bookGET /business/airport/terminals Airlines & travel
Issue airport pickup / drop-off vouchers and bundle them with tickets. Your passenger redeems the ride; you settle centrally.
POST /business/couponsPOST /business/book Insurers
Move policyholders to and from hospital on demand or on a recurring schedule, and stand up a fleet for a claim — all from your own CMS or staff tools.
POST /business/bookPOST /business/subscriptionsPOST /business/hire/{vehicle}/book Corporate transport desks
Let your booking or calendar system order staff rides with expense codes, and reconcile spend from monthly statements.
POST /business/bookGET /business/tripsGET /business/billing/statements Retail & wholesale
Trigger Areye the moment an order lands — instant pickup for same-hour delivery, or queue a next-morning batch of up to 50 stops.
POST /business/bookPOST /business/batches Restaurants & takeaways
Hand delivery to Areye the moment an order is ready — dispatched to the nearest driver, with a public tracking link and a webhook when it is done.
POST /business/bookGET /trip-track/{reference}trip.completed Grocers & pharmacies
Dispatch every order to a geo-available, crowd-sourced driver pool — one-off for instant delivery or batched for a scheduled run. COD + age-check supported.
POST /business/bookPOST /business/batchescod.collected Authentication
The Areye Business API uses API key authentication. Include your key in the X-Business-API-Key header on every request. You can find and rotate your key in the Business Portal under Settings & API.
X-Business-API-Key: your_api_key_here
Keep your API key secret. Never expose it in client-side code or public repositories. Use environment variables. Rotate your key immediately from the Business Portal if you suspect it has been compromised.
Keys are per environment and say so: live keys start sk_live_ (keys issued before 11 October 2026 have no prefix and keep working), sandbox keys start sk_sandbox_. Live refuses a sandbox key (401 sandbox_key_on_live) and the sandbox refuses anything else (401 live_key_on_sandbox). See Sandbox.
Sandbox
The sandbox is a separate Areye for building and testing: the same code as live, deployed from the same release, with its own address, database, keys, queue and scheduler. It speaks the same API and sends the same signed webhooks with the same payloads. Nothing in it can reach a real driver, phone or payment: push notifications, SMS, WhatsApp and calls are refused at the sender, outbound traffic is deny-by-default, and payments run in the gateways' test modes. Our own apps (Dijo, Storefront) test here too, with sandbox keys, exactly as you do.
| Live | Sandbox | |
|---|---|---|
| Base URL | https://api.areye.app/api | https://sandbox.areye.app/api |
| API key | sk_live_… | sk_sandbox_… |
| Wrong key | 401 sandbox_key_on_live | 401 live_key_on_sandbox |
| Every response | header X-Areye-Environment: live | header X-Areye-Environment: sandbox and "environment": "sandbox" in the body |
| Drivers | Real | None: you drive each trip with the simulation endpoints, or let the simulated driver do it |
| Money | Real | Test wallets of 100,000 BWP and 100,000 ZAR, topped up to 100,000 when one drops below 10,000 and you create your sandbox credentials again. Set any balance with PUT /business/sandbox/wallet {"currency": "BWP", "balance": 150} to try the reserve and insufficient_balance refusals |
| Payment gateways | Real cards, mobile money and EFT | None: wallet top-ups and card payments cannot be completed in the sandbox |
| Drivers and timing | Real drivers, real delays | The sandbox driver; you step each trip (or turn the autopilot on). Webhooks to the built-in inbox arrive instantly |
| One-time codes | Sent by SMS / WhatsApp | Never sent; always 123456 |
Test names (__TEST…) | Refused: 422 test_booking_refused | Welcome |
Older live keys have no prefix and keep working on live. The sandbox accepts only sk_sandbox_ keys (not live keys and not portal sign-ins). "test": true on live is refused with test_mode_moved_to_sandbox; in the sandbox it is ignored, because everything there is a test.
1. Get sandbox keys
Already an Areye business? Business portal → Settings & API → Sandbox → Create sandbox credentials (portal admins). You get a sandbox twin of your account (same name and contact, COD and proof-of-delivery settings, never a real phone) with its own key, webhook secret and an enabled Storefront grant. Pressing it again rotates them. The same through the API, with your live key:
curl -s -X POST https://api.areye.app/api/business/sandbox/credentials -H "X-Business-API-Key: $LIVE_KEY"
# 201 {"data": {"base_url": "https://sandbox.areye.app/api", "api_key": "sk_sandbox_…",
# "webhook_secret": "whsec_…", "business_id": 12, "storefront": {…}}}New to Areye? Sign up on the sandbox itself with your email; no live account is needed. You get a 6-digit code by email (valid 15 minutes, 5 tries). Signing up again with the same email rotates your key — that is also how a lost key is recovered. Or use the form at sandbox.areye.app.
curl -s -X POST https://sandbox.areye.app/api/sandbox/signup -H "Content-Type: application/json" \
-d '{"first_names": "Lesego", "surname": "Dube", "email": "you@company.com", "company": "Your Co"}'
# 202 {"message": "We emailed a 6-digit code to you@company.com…"}
curl -s -X POST https://sandbox.areye.app/api/sandbox/signup/verify -H "Content-Type: application/json" \
-d '{"email": "you@company.com", "code": "482913"}'
# 201 {"data": {"api_key": "sk_sandbox_…", "webhook_secret": "whsec_…", "base_url": "https://sandbox.areye.app/api", …}}Why email and a code rather than an approval queue: the sandbox cannot reach anyone or move money, so the only thing to protect is that the key belongs to whoever can read that mailbox. The key is shown once; store it as a secret like a live key.
2. Test data
- Where: the sandbox serves the same zones as live (Gaborone, Francistown, Johannesburg, Pretoria, …). Use real coordinates, e.g. pickup
-24.6541, 25.9087and drop-off-24.6600, 25.9100in Gaborone;GET /business/zoneslists them. - What:
GET /business/vehicle-typesandGET /business/parcel-sizesare the live lists. Fares are computed the live way. - Who: any recipient name and phone. Nothing is ever sent to it. Use your own order ids as
external_reference; they live only in the sandbox and can never block a live order. - Codes: a
delivery_codeyou set is entered by the simulated driver at the door. Customer and driver one-time codes in the sandbox are always123456.
3. Drive a trip: the simulation endpoints
A sandbox booking waits for a driver, as on live, until you move it. Each step below is the real driver action, taken by your account's simulated driver through the same code the driver app uses, so every webhook it causes is the one live sends, with the same payload, signature and retries. A step the real rules would refuse is refused the same way (for example, collecting before the store marked an order with handover_required ready gives 422 awaiting_handover: call POST /business/trip/{reference}/ready first, as on live).
POST /business/sandbox/trips/{reference}/{step} (sandbox only; your sk_sandbox_ key)
{reference} = the ARY-… reference, the trip id, or your external_reference| step | What the driver does | Webhooks you receive |
|---|---|---|
| assign_driver | Accepts the trip (status accepted). | trip.accepted |
| arrive_pickup | Arrives at the pickup. For a ride, marks arrived; for a delivery, only moves the driver. | none for a delivery (live sends none either) |
| pick_up | Collects the order (status active). A bag label scan is overridden and recorded, as the app allows. | trip.picked_up |
| arrive_dropoff | Arrives at the customer (status arrived). | none |
| deliver | Enters the recipient's delivery_code, passes the ID check if age-restricted, takes the proof photo and signature, collects COD (the declared amount, or cod_collected_amount in the body), completes. | trip.age_verified (if age-restricted), cod.collected (if COD), trip.completed with delivery_outcome "delivered" |
| fail_delivery | Cannot deliver; starts the return. Body: {"reason": "Nobody home"}. | trip.delivery_failed {reason, return_reason, returning: true, return_due_at} |
| id_refused | Age-restricted order: the buyer cannot prove 18+; nothing is handed over and the return starts. Body: {"reason": "…"}. | trip.age_verification_failed {age_verification_outcome: refused, reason} |
| complete_return | Brings the goods back to the store and closes the trip. | trip.completed with delivery_outcome "returned", returned: true, return_reason (the reason given, or age_verification_refused) |
| cancel_by_driver | The driver cancels before collection. Body: {"reason": "…"}. What happens next is what live does (the trip is cancelled or put back for another driver). | trip.cancelled {cancelled_by: driver, reason} when the trip ends |
Each answer carries the trip's new status and the webhooks the step queued:
curl -s -X POST $SBX/business/sandbox/trips/ARY-8F3A21/deliver -H "X-Business-API-Key: $KEY"
# 200 {"success": true, "environment": "sandbox", "step": "deliver",
# "trip": {"reference": "ARY-8F3A21", "status": "completed", "returning": false, …},
# "webhooks": [{"event": "trip.completed", "event_id": "7d1c…", "status": "delivered"}]}Out of order is refused with a code: driver_not_assigned (409) before assign_driver, no_return_in_progress (409) for complete_return without a failed delivery, and the driver app's own answers otherwise (422, e.g. "Cannot complete this trip.").
4. Or let the simulated driver run it
Turn on your account's autopilot and every new booking runs a scenario on its own, one step every step_seconds (1–60, default 5). Or ask for one booking only, with "sandbox_scenario" on POST /business/book (refused on live).
PUT /business/sandbox/autopilot {"scenario": "delivered", "step_seconds": 3} ("off" to stop)
GET /business/sandbox/autopilot
scenarios:
delivered assign_driver, arrive_pickup, pick_up, arrive_dropoff, deliver
failed_delivery … arrive_dropoff, fail_delivery, complete_return
id_refused … arrive_dropoff, id_refused, complete_return (age-restricted orders)
cancel_by_driver assign_driver, arrive_pickup, cancel_by_driverA step the rules refuse (the store has not marked the order ready) is retried a few times, then the autopilot leaves the trip for you to move.
5. Receive and verify the webhooks
Set your own public HTTPS webhook URL (PATCH /business/profile {"webhook_url": "https://…"}), or use the sandbox's built-in inbox if your receiver is not public yet. The inbox keeps each delivery's exact bytes and headers, so you verify the signature with your own code, as your server will on live.
curl -s -X POST $SBX/business/sandbox/inbox -H "X-Business-API-Key: $KEY"
# {"data": {"webhook_url": "https://sandbox.areye.app/api/sandbox/inbox/9f2c…"}} (your webhook URL now points here)
curl -s "$SBX/business/sandbox/inbox?limit=20" -H "X-Business-API-Key: $KEY"
# {"data": [{"id": 41, "event": "trip.completed", "event_id": "7d1c…",
# "headers": {"X-Areye-Signature": "sha256=…", "X-Areye-Signature-V2": "t=…,sha256=…", …},
# "body": "{\"id\":\"7d1c…\",\"event\":\"trip.completed\",…}"}, …]}
# verify: HMAC-SHA256 of "body" exactly as given, with your webhook secret
python3 -c 'import hmac,hashlib,sys; print("sha256="+hmac.new(sys.argv[1].encode(), sys.argv[2].encode(), hashlib.sha256).hexdigest())' "$SECRET" "$BODY"Retries, backoff, ordering and secret rotation behave exactly as on live (see Webhooks). POST /business/webhook-test sends a signed sample to whatever URL is set.
6. Channels and partners in the sandbox
- Storefront channel: sandbox tokens carry
aud: "areye-sandbox"and are signed with Storefront's sandbox key; the sandbox grant comes with the owner's sandbox credentials. See Storefront channel. - Partner sales channel (Areye selling a partner's goods): the sandbox calls the partner's own sandbox (Dijo:
https://lab.dijo.app/api/v1), and the partner posts events tohttps://sandbox.areye.app/api/channel/{source}/events. See Partner sales channel contract and Areye Shop API. - In-store shopping fulfilment:
POST /business/shopping-ordersworks in the sandbox the same way. See In-store shopping fulfilment.
7. Going live checklist
- In the sandbox you have completed, from your own system: a delivery (
deliver), a failed delivery and its return, an ID refusal (if you sell age-restricted goods), a driver cancellation and your own cancellation. - Your receiver verified every signature, deduplicated a repeated
id, ignored an event older than the order's state, and treateddelivery_outcome: "returned"as goods back in stock. - A retried booking with the same
external_referencecame back200 replayed: true, and you handle every refusal by itscode. - Swap two settings only: base URL to
https://api.areye.app/apiand the key to your live key (Settings & API). Set your live webhook URL and secret. No code path may depend on which environment it is in, other than these. - Nothing that reaches live carries a test marker:
__TEST,[TEST],TEST ORDER,DO NOT DELIVERat the start of any name, item, address or note is refused (test_booking_refused), and Areye's operations team is alerted. - Your first live order is a real staff order, watched end to end.
Endpoints
All paths are relative to the base URL https://api.areye.app/api. Every request is authenticated with your X-Business-API-Key unless marked public.
Bookings & Trips
/business/book
Book a ride, parcel, cargo or airport transfer on behalf of an employee or guest — supports scheduling and up to 3 stops
/business/trips
List trips, newest first. Filters: month (YYYY-MM), reference, search (reference, passenger, address or your external_reference), test (0/1), per_page
/business/trips/active
List trips currently in progress
/business/trips/{id}
Retrieve a single trip with receipt and route details
/business/trips/{id}/cancel
Cancel any time before pickup (pending, scheduled, queued, accepted, enroute, arrived). No cancellation charge to a business; anything charged to your wallet for the trip is refunded
/business/trip/fare-estimate
Price a trip before booking it (same calculator the booking uses)
/business/trips/{id}/waybill
Download the printable waybill for a delivery
/trip-track/{reference}
Public shareable tracking link — no authentication required
Merchant Orders & Delivery
/business/book
Same booking endpoint — add order_payload, prep_minutes, cod_amount or shop_deliver to turn a delivery into a merchant order
/business/trip/{reference}/ready
Signal the order is packed and ready for collection — releases the waiting driver immediately
/business/trips/{id}/pick-list
Pick list PDF for the picker or shopper: items by store then aisle, qty, barcode / SKU, substitution preference, tick boxes, order number, store and pickup time. No prices
/business/trips/{id}/packing-slip
Packing slip PDF for the box: sender, recipient (first names and surname), address, ordered vs packed, substitutions, missing (refunded), piece n of N (?pieces=N), your returns note, signature line. Shop orders: once the basket is collected
/business/packing-slip-settings
Your packing slip settings: show_prices (default off) and returns_note
/business/packing-slip-settings
Change show_prices and / or returns_note (max 1000 characters)
/business/book
Age-restricted goods: add age_verification_required to force a photo-ID check at the door before handover
/trip-track/{reference}
Customer-facing live tracking — no auth, safe to embed in your own app or SMS
/trip-chat/{reference}?token={chat_token}
Read the order chat. Public: authenticated by the chat_token from the booking response, sent as a query parameter
/trip-chat/{reference}
Post as the customer. JSON body: {"token": chat_token, "message": "…" (max 1000)}. Closed after cancellation, or 30 minutes after completion (422)
Batches & Subscriptions
/business/batches
Submit a bulk delivery batch (segment: parcel or cargo, up to 50 stops). Send your own external_reference: a retry with the same reference and body returns the first batch (replayed: true), never a second charge or run; different details return 409 reference_conflict
/business/batches
List batches with per-stop status
/business/batches/{id}
Retrieve a single batch with all stops
/business/batches/{id}/packing-slips
Every stop's packing slip in one PDF; ?with_labels=1 ends each slip with a cut-off label (scanning add-on)
/business/subscriptions
Create a recurring delivery (parcel/cargo, days of week, pickup time)
/business/subscriptions
List recurring delivery subscriptions
/business/subscriptions/{id}/{action}
Pause, resume or cancel a subscription
Car Rental & Fleet
/business/rentals/vehicles
List your rental fleet with rates, deposits and availability
/business/rentals/vehicles
Add a vehicle to your rental fleet (photos, daily rate, km limits)
/business/rentals/vehicles/bulk
Bulk-add vehicles to your fleet
/business/rentals/vehicles/mode
Bulk-set listing mode (rental / ride-hailing / both)
/business/rentals/vehicles/{id}
Update a vehicle — rates, deposit, marketplace publishing
/business/rentals/vehicles/{id}
Remove a vehicle from your fleet
/business/rentals/bookings
List rental bookings against your fleet
/business/rentals/bookings
Create a rental booking (self-drive or chauffeured)
/business/rentals/bookings/{id}/{action}
Confirm, complete or cancel a rental booking
Rent-to-Own & Driver Applications
/business/rto
List rent-to-own agreements against your vehicles
/business/rto
Create a rent-to-own agreement (earnings-skim or direct-pay, chosen cadence)
/business/rto/{id}/payments
Record an instalment payment toward an agreement
/business/fleet-applications
List driver applications for your marketplace-published vehicles
/business/fleet-applications/{id}/{action}
Approve or reject a driver application (assigns the vehicle / opens the agreement)
Wallet, Payouts & Billing
/business/wallet
Get current wallet balance and billing type
/business/wallet/transactions
List wallet transactions (debits, credits, top-ups)
/business/wallet/topup
Initiate a wallet top-up via a payment gateway
/business/wallet/topup/{ref}/status
Poll the status of a wallet top-up
/business/payment-gateways
List available payment gateways for your zone
/business/payouts
List earnings payout requests
/business/payouts
Request a payout of positive earnings (owner or portal admin signed in; not an integration key)
/business/payouts/{id}
Cancel a pending payout request
/business/billing/statements
List monthly billing statements
People, Groups & Coupons
/business/people
List all employees and their spending limits
/business/people
Invite an employee to your business account
/business/people/{id}
Update employee details, group or spending limit
/business/people/{id}
Remove an employee from your account
/business/groups
List groups for organising employees
/business/groups
Create a group
/business/coupons
List employee discount coupons
/business/coupons
Issue a new coupon (fixed or percent)
/business/coupons/{id}/deactivate
Revoke a coupon
Reference Data
/business/vehicle-types
List vehicle types with live pricing and availability
/business/parcel-sizes
List parcel size tiers with rates
/business/zones
List active service zones
Account
/business/profile
Retrieve your business account profile and settings (includes portal_role and principal)
/business/trips/export
Every trip matching from, to, type and status, as CSV
/business/change-password
A signed-in person changes their password; their other devices are signed out
/business/profile
Update company name, billing contact, webhook URL or low-balance threshold
/business/api-key
Retrieve API key metadata
/business/api-key/regenerate
Rotate your API key (previous key invalidated immediately)
/business/dashboard
Aggregate account metrics — trips, spend, active employees
Example: Book a ride for an employee
curl -X POST https://api.areye.app/api/business/book \
-H "X-Business-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"type": "ride",
"passenger_name": "Jane Smith",
"passenger_phone": "+267XXXXXXXX",
"pickup_address": "Plot 64271, Block 3 Industrial, Gaborone",
"pickup_lat": -24.6282,
"pickup_lng": 25.9231,
"dropoff_address": "Molapo Crossing Mall, Gaborone",
"dropoff_lat": -24.6540,
"dropoff_lng": 25.9110,
"vehicle_type_id": 1, // optional override — prefer declaring the goods (see below)
"expense_code": "TRAVEL-Q3"
}'
Choosing the vehicle
Describe what you are sending and Areye dispatches a vehicle that can carry it. You do not
need to know our fleet, and you should not hard-code vehicle_type_id
— a refrigerator booked as a sedan job is exactly the failure this prevents.
Call GET /business/parcel-sizes first. It returns
the live size ladder — size_key, the kilogram range,
the eligible vehicle types and the rates for each band. It is the authoritative answer to
"which size is my package?" and it changes without notice to this page, so read it rather
than hard-coding the bands below.
All four fields are optional. Send whichever you have. Send none and the
booking behaves exactly as it always did. They combine freely: a weight and a body type
together are both honoured. If parcel_weight_kg and
parcel_size disagree — 2 kg declared as
oversized, say — the larger of the two wins,
so a consignment is never under-served because two declarations contradicted each other.
| Field | Values | Use it when |
|---|---|---|
| parcel_weight_kg | any number | You know the weight. Simplest option. |
| parcel_size | small · medium · large · oversized | Shoebox · shopping bag · car boot · TV, bicycle, appliance. |
| cargo_body_type | closed · open · refrigerated | The load needs a particular body — an open bakkie for building materials, a closed body for anything weatherproof. Refrigerated is commercial cold-chain for bulk consignments; a retail order with frozen items does not need it and should not declare it. |
| cargo_capacity_class | half_ton · one_ton · three_ton · eight_ton | A minimum capacity, not an exact match. |
You are billed for the vehicle your goods require. Areye always dispatches the smallest vehicle that can do the job, so declaring accurately never costs more than the job needs — but over-declaring dispatches and bills a larger vehicle. On a typical route the oversized band costs around four times the small band. Under-declaring causes a failed collection; over-declaring is simply expensive. Declare what you are actually sending.
vehicle_type_id remains an override. When the goods
need more vehicle than the class you named, your account decides what happens: by default Areye
upgrades and bills the larger class. Accounts can instead cap upgrades at one size, or refuse the
order and return 422 with the reason. Declare nothing and nothing
changes — existing integrations are unaffected.
Example — a refrigerator
curl -X POST https://api.areye.app/api/business/book \
-H "X-Business-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "parcel",
"passenger_name": "Recipient Name",
"passenger_phone": "+267XXXXXXXX",
"pickup_address": "Store address",
"pickup_lat": -24.6282, "pickup_lng": 25.9231,
"dest_address": "Delivery address",
"dest_lat": -24.6531, "dest_lng": 25.9089,
"parcel_weight_kg": 85,
"cargo_body_type": "closed"
}'
No vehicle_type_id is sent. Areye resolves 85 kg to the
oversized band, sees that a closed body is required, and dispatches a cargo van or bakkie
— never a sedan or a motorbike.
Batches — one driver, one vehicle
A batch submitted to POST /business/batches is collected by a
single driver in a single vehicle, so Areye fits that vehicle to the
largest stop in the run. One oversized item among twenty small ones sends a
bakkie, not a motorbike.
Set each stop's size to
small, medium,
large or oversized
(s / m /
l / xl are accepted too).
Each stop is priced on its own size; the vehicle is chosen for the whole run.
cargo_body_type and
cargo_capacity_class may be set at the top level to apply to
the entire batch — a closed-body run, for example.
The per-stop field is size (not
parcel_size — that name belongs to
/business/book). A stop may also carry
weight_kg, and where a stop gives both, the larger of the two
decides that stop — for its own price and for the batch's vehicle.
curl -X POST https://api.areye.app/api/business/batches \
-H "X-Business-API-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"segment": "parcel",
"stops": [
{
"recipient_name": "First Recipient",
"recipient_phone": "+267XXXXXXXX",
"pickup_address": "Store", "pickup_lat": -24.6282, "pickup_lng": 25.9231,
"dest_address": "Drop 1", "dest_lat": -24.6531, "dest_lng": 25.9089,
"size": "small"
},
{
"recipient_name": "Second Recipient",
"recipient_phone": "+267XXXXXXXX",
"pickup_address": "Store", "pickup_lat": -24.6282, "pickup_lng": 25.9231,
"dest_address": "Drop 2", "dest_lat": -24.6402, "dest_lng": 25.9155,
"weight_kg": 85
}
]
}'
The first stop is priced as a small parcel and the second as oversized, but the whole run is collected by a vehicle able to carry the 85 kg item.
If no vehicle can satisfy the batch, the request is rejected with
422 and nothing is created — a batch fails whole, never
part-way through.
Verifying what Areye decided
The 201 response, GET /business/trips
and GET /business/trips/{reference} all echo what you declared
and what we resolved, so you never have to infer it from the fare:
{
"parcel_size": "oversized",
"parcel_size_label": "Oversized",
"parcel_weight_min_kg": 30.0,
"parcel_weight_max_kg": 500.0,
"cargo_body_type": "closed",
"cargo_capacity_class": null,
"volumetric_weight_kg": null,
"vehicle_class": {
"id": 5, "name": "Cargo Van",
"body_type": "closed", "capacity_class": "one_ton"
},
"vehicle_upgraded": true,
"vehicle_upgrade_reason": "Budget cannot carry this consignment (Oversized) — dispatched Cargo Van instead.",
"test": false
}
vehicle_class is the class the order will actually be sent.
vehicle_upgraded tells you whether we sent something larger
than your request implied, and why.
Custody scanning — proving which bag
A collection photo and a driver signature prove that a handover happened. They do not prove which bag moved — so an unlabelled bag handed to the wrong driver still produces a perfect-looking record. Custody scanning closes that gap, and it is a premium add-on: ask Areye to enable it on your account.
When it is enabled, every merchant order comes back with a scan_code
and a scan_label_url. Nothing else about your integration changes.
{
"reference": "BIZ-1A7DED",
"scan_code": "4EY2-Y6GD",
"scanned_at": null,
"scan_label_url": "https://api.areye.app/api/business/trips/BIZ-1A7DED/label"
}
What your picker app does
- Finish picking and packing the order as you do today.
- Fetch
scan_label_url— an A6 PDF label carrying the QR (encodingscan_code) with your order number printed underneath it. Print it and stick it on the bag. - Show the same code on your handover screen, so the person releasing the bag can match the physical label to the electronic one before the driver takes it.
The QR is Areye's custody token; the order number is for your staff and the driver reading it
by eye. Two audiences, one sticker. If you would rather generate the label yourself, encode
scan_code verbatim as a QR — the driver's scanner accepts it
with or without the hyphen, and in any case.
Pick lists and packing slips
Two more papers come from the same order items, with or without the scanning add-on.
GET /business/trips/{id}/pick-list is for the picker before packing: items grouped by store,
then by aisle (or category) when you send one, with quantity,
barcode / sku, the substitution preference and a tick box per line. It never shows prices.
GET /business/trips/{id}/packing-slip goes in the box: who it is from and for (first names and surname),
the delivery address, each item ordered against packed, substitutions made, what is missing (refunded), piece
n of N (?pieces=N or order_payload.pieces), your returns note and a signature line.
Send items.*.qty_packed and items.*.substitute once packed, or leave them out and the packed
column is left to fill in by hand. Prices print only when you switch show_prices on
(PUT /business/packing-slip-settings). Neither paper ever shows a delivery code or a pickup code.
For a shopping order the slip shows each line's final outcome and is ready once the basket is collected; the
shopping.basket_collected event and the trip carry a signed packing_slip_url you can give the customer.
A courier client prints GET /client/shipments/{waybill}/packing-slip (one page per piece, from
parcels.*.contents); the courier prints GET /business/shipments/{waybill}/packing-slip.
What the driver does
The driver scans the label (or types the code beneath it) before collecting. They cannot mark the order collected until that scan matches. Scanning a label from a different order is rejected with “that label belongs to a different order” — which is the whole point: the mismatch surfaces while the driver is still standing in your store, not at a customer's door.
Labels do get torn and printers do jam, so a driver may force collection through. That is never
silent: the override and its reason are recorded on the order, and
scanned_at stays null so a forced
collection is always distinguishable from a real scan.
| Field | Meaning |
|---|---|
| scan_code | The custody token to encode as a QR. Absent when the add-on is off. |
| scan_label_url | A ready-made A6 PDF label. Same auth header as any other call. |
| scanned_at | When the driver matched the bag. Null until then — and stays null on a forced collection. |
Sandbox bookings
In the sandbox every booking is a test booking (a "test" flag is ignored there);
on live, "test": true is refused with 422 test_mode_moved_to_sandbox.
Every trip representation carries "test": true|false, and
GET /business/trips?test=0 hides sandbox trips from your live
list (?test=1 shows only them; omit it for everything).
{
"success": true,
"data": {
"id": 51234,
"reference": "ARY-8F3A21",
"tracking_url": "https://areye.app/track/Xq7…tokenised…kP2",
"status": "pending",
"type": "ride",
"billing_amount": 85.00,
"billing_currency": "BWP",
"eta_minutes": 6,
"chat_token": "…",
"is_test": false,
…
}
}
Booking a delivery instead? Set "type": "parcel" and add recipient details. For cargo, send "type": "parcel", "segment": "cargo" (there is no "type": "cargo"; it returns 422). Track any booking with the returned tracking_url (see Live tracking).
Quickstart: your first delivery
Ten minutes, all in the sandbox: no driver, phone or money is ever reached. The machine-readable contract is at /api-docs/openapi.json (OpenAPI 3.1), and a ready-made Postman collection at /api-docs/postman.json — import it, set the apiKey variable, and every request below is ready to send.
1. Set up
Get a sandbox key (how) and export it with the sandbox base URL. Every call below is real code on a separate Areye; going live later means changing only these two values.
export AREYE=https://sandbox.areye.app/api export KEY=sk_sandbox_your_key2. Read the reference data
Check which vehicle types and parcel sizes you may send, and that your pickup point is inside a zone.
curl -s $AREYE/business/vehicle-types -H "X-Business-API-Key: $KEY" curl -s $AREYE/business/parcel-sizes -H "X-Business-API-Key: $KEY"3. Estimate the fare
Same calculator the booking uses.
curl -s "$AREYE/business/trip/fare-estimate?pickup_lat=-24.6541&pickup_lng=25.9087&dest_lat=-24.6600&dest_lng=25.9100&vehicle_type_id=1" \ -H "X-Business-API-Key: $KEY"4. Book a delivery
Use your own order id as
external_reference. Keep it: you will reuse it on retries. Expect 201.curl -s -X POST $AREYE/business/book -H "X-Business-API-Key: $KEY" -H "Content-Type: application/json" -d '{ "type": "parcel", "external_reference": "SHOP-1001", "passenger_name": "Neo", "passenger_phone": "+26771234567", "pickup_address": "Your store", "pickup_lat": -24.6541, "pickup_lng": 25.9087, "dest_address": "Customer", "dest_lat": -24.6600, "dest_lng": 25.9100, "prep_minutes": 15, "handover_required": true, "order_payload": {"store_name": "Your store", "items": [{"name": "Chicken wrap", "qty": 2}]} }'5. Prove your retry is safe
Send exactly the same request again. Expect 200 with
"replayed": trueand the samereference. Your code must treat this as success. Change any detail and you get 409external_reference_conflict.# same command as step 46. Fetch it
By id or reference; or search by your own reference.
curl -s $AREYE/business/trips/ARY-8F3A21 -H "X-Business-API-Key: $KEY" curl -s "$AREYE/business/trips?search=SHOP-1001" -H "X-Business-API-Key: $KEY"7. Mark it ready
When the order is packed. Calling it twice is harmless.
curl -s -X POST $AREYE/business/trip/ARY-8F3A21/ready -H "X-Business-API-Key: $KEY"8. Drive it and receive the webhooks
Move the delivery through its life with the simulation endpoints (or turn on the simulated driver). Each step sends the real signed webhook:
trip.accepted,trip.picked_up,trip.completed. Verify the signature (see Webhooks), dedupe onid, and confirm each changes your order exactly once. Then tryfail_deliveryandcomplete_returnon a second order.curl -s -X POST $AREYE/business/sandbox/inbox -H "X-Business-API-Key: $KEY" # optional: built-in webhook inbox for s in assign_driver arrive_pickup pick_up arrive_dropoff deliver; do curl -s -X POST $AREYE/business/sandbox/trips/SHOP-1001/$s -H "X-Business-API-Key: $KEY"; done curl -s $AREYE/business/sandbox/inbox -H "X-Business-API-Key: $KEY" # X-Areye-Signature: sha256=HMAC-SHA256(body, secret)9. Cancel
Book a second test order and cancel it before pickup. Expect 200; cancelling after pickup returns 422
not_cancellable.curl -s -X POST $AREYE/business/trips/ARY-…/cancel -H "X-Business-API-Key: $KEY" \ -H "Content-Type: application/json" -d '{"reason": "Customer changed their mind"}'
Before you go live
The full list is the going-live checklist. In short:
- Every booking carries a stable
external_reference, reused on retries. - A 200
replayed: trueis handled as success. - Webhook signatures are verified on the raw body, and duplicate deliveries change nothing.
- Your order never moves backwards when an older event arrives late.
- Your first live order is an internal staff order, watched end to end, before customers.
Contract essentials
Five rules an integration must follow. Each is how the API actually behaves today.
1. Retries: one order, one delivery
Every order you book must carry a stable, unique external_reference (top level of the POST /business/book body, max 80 characters; it is copied into order_payload.external_reference and returned on every webhook). When a request times out, retry with the same reference. Never generate a new reference for a retry.
| 201 | New delivery created. |
| 200 · replayed: true | Same reference, same details: the original delivery is returned. No second trip, charge or driver. Treat it as success. |
| 409 · external_reference_conflict | Same reference, different details. The original is untouched; reference names it. |
| 409 · booking_in_progress | The first attempt is still being processed. Wait for Retry-After (5 seconds) and retry with the same reference. |
A booking that was refused (for example 422, insufficient balance) leaves the reference free. A reference belongs to its first delivery for good, even after that delivery is cancelled: a late retry returns the cancelled delivery and dispatches nothing. To deliver the same order again, book it with a new reference (for example DIJO-1001-R2). Without an external_reference there is no protection: a retry is a second delivery.
2. Trip status lifecycle
pending / scheduled ─▶ accepted ─▶ arrived ─▶ active ─▶ completed
│ │ │
└────────────────┴────────────┴─▶ cancelled (any time before active)| status | Meaning | Cancellable | Final |
|---|---|---|---|
| pending | Looking for a driver (a merchant order with prep_minutes is offered when the food is nearly ready) | yes | no |
| scheduled | Booked for later; offered to drivers shortly before scheduled_at | yes | no |
| queued | A driver accepted it to start straight after their current trip | yes | no |
| accepted | A driver is on the way to pickup | yes | no |
| enroute | Driver heading to pickup (older clients); treat as accepted | yes | no |
| arrived | Driver is at pickup | yes | no |
| active | Collected; on the way to the customer | no | no |
| completed | Delivered / finished | no | yes |
| cancelled | Ended without completing. An unanswered booking expires as cancelled with cancelled_by: system | no | yes |
Map unknown statuses to "in progress"; new statuses may be added. A business is never charged for cancelling; anything charged to your wallet for the trip is refunded.
3. Scheduling and time zones
scheduled_at is ISO-8601. Always include an offset, e.g. 2026-10-05T08:30:00+02:00 or 2026-10-05T06:30:00Z; both mean the same moment. A time without an offset is read as Africa/Gaborone (UTC+2). A time within the next 10 minutes is treated as "now" and dispatched immediately; a time in the past is refused (422).
When drivers are offered a merchant order
With prep_minutes, an immediate order is offered to drivers about 12 minutes before the food is estimated ready (booking time + prep_minutes − 12; Areye can tune the 12), so a driver arrives as it is packed rather than waiting at the counter. It stays pending meanwhile and does not expire. Calling POST /business/trip/{reference}/ready earlier releases it at once; calling it twice is harmless. Without prep_minutes, it is offered immediately.
4. Testing happens in the sandbox
Test against the sandbox (https://sandbox.areye.app/api, sk_sandbox_ key), never against live. Since 11 October 2026 live refuses "test": true / "dry_run": true (or the X-Areye-Test: true header) with 422 test_mode_moved_to_sandbox, and refuses any booking whose names, items, address or notes start with a test marker (__TEST, [TEST], TEST ORDER, DO NOT DELIVER) with 422 test_booking_refused: a test on live sends a real driver, and its order ids can block your real orders. Older test trips still carry is_test; GET /business/trips?test=0 hides them.
5. Partner keys
A delivery platform integrating on a merchant's behalf should use a partner key, not the business's own key. Partner keys are issued by Areye on the merchant's request (ask Areye support; the merchant names the partner and the scopes) and are shown once. A key carries one or more scopes, and any endpoint outside them returns 403 scope_denied. It is sent in the same X-Business-API-Key header and can be revoked without affecting the business.
| Scope | What it may do | Endpoints |
|---|---|---|
| deliveries | Deliveries: book, read, cancel, mark ready, tracking, proof of delivery, pick lists and packing slips | POST /business/book POST /business/bulky/quote GET /business/bulky/schema POST /business/checkouts GET /business/checkouts GET /business/checkouts/{reference} POST /business/checkouts/{reference}/cancel POST /business/trips/{trip}/bulky/accept-quote POST /business/trips/{trip}/bulky/authorise-disposal POST /business/trips/{trip}/bulky/redeliver POST /business/trips/{trip}/bulky/decline-quote POST /business/trip/{reference}/ready GET /business/trips GET /business/trips/active GET /business/trips/{trip} POST /business/trips/{trip}/cancel POST /business/trips/{trip}/tracking-link/rotate POST /business/trips/{trip}/tracking-link/revoke GET /business/trips/{trip}/label GET /business/trips/{trip}/waybill GET /business/trips/{trip}/receipt GET /business/trips/{trip}/pick-list GET /business/trips/{trip}/packing-slip GET /business/trip/fare-estimate GET /business/vehicle-types GET /business/parcel-sizes GET /business/zones POST /business/geocode |
| rewards | Rewards: validate, redeem and reverse coupons; gift card balance | POST /business/coupons/validate POST /business/coupons/redeem POST /business/coupons/reverse POST /business/gift-cards/balance |
A courier network's operational keys carry courier scopes instead (runs, customs, shipments, claims); see the courier platform guide.
Who can do what
Your integration API key is a server's credential. It books, tracks, cancels and reads, manages your webhook (PATCH /business/profile, /webhook-secret/regenerate) and can rotate itself. Actions on people and money leaving the account — portal users, payouts, payment cards and automatic top-up — need the account owner or a portal admin signed in to the business portal; an integration key gets 403 signed_in_admin_only. A portal coordinator books and runs operations; money, credentials, people, coupons, spending groups and the company record are for an admin (403 portal_admin_only). GET /business/profile returns portal_role and principal (owner, admin, coordinator or api_key) so an app can show only what the caller may do. A changed API key, webhook secret or webhook address is recorded and emailed to the account owner.
Retrying safely: Idempotency-Key
Send an Idempotency-Key header (up to 64 characters, one value per attempt of the same request, kept until it succeeds) on POST /business/book, /batches, /coupons/issue, /subscriptions, /hire/{vehicle}/book and /payouts. A retry with the same key and body returns the first result with replayed: true and charges, books or mints nothing again; the same key with a different body is 409 reference_conflict; while the first is still running, 409 request_in_progress with Retry-After. A body external_reference (or batch_reference) does the same and wins over the header. On /book the header is only a retry key: it never becomes your order's external_reference. A replay of a delivery that has since been cancelled still answers 200, with original_status: "cancelled" and replay_terminal: true at the top: book a new delivery with a new reference. A second cancel of a trip you already cancelled answers 200 with already_cancelled: true.
6. Live tracking
Every booking returns tracking_url. For your deliveries it carries a trip-scoped token: open it, send it to your customer, or poll GET /trip-track/{token} (no key) and draw your own map. The token is the last part of the URL. It expires some days after the delivery is due and closes shortly after the trip ends; POST /business/trips/{trip}/tracking-link/rotate issues a new one and …/revoke turns it off (holders then get 410, the reference no longer opens it, and your reads return tracking_url: null with tracking_link_status: "revoked" until you rotate). Your own reads (GET /business/trips/{trip}, trips/active) return the same link.
- Webhooks are the truth for delivery state. They may arrive more than once (dedupe on
id) or out of order (comparetimestamp; never move an order backwards). Tracking polls give position and ETA only; never settle or change an order from them. - Poll every
poll_after_seconds(15) whileis_active. Slow down or pause when your screen is in the background; stop when it isnull. - Freshness:
location_is_freshuses the same age limit as Areye's dispatch. An old position is still returned so the marker does not jump away, but show it as “last known position, N minutes ago”, never as live. - Route: a token sees its own trip's pickup, ordered
stopsand destination ("scope": "token"). Draw the route once from those points, then move only the driver marker.route_polylineis null until stored route geometry exists. - Reference links (
/track/ARY-…) keep working for now with a reduced view ("scope": "reference": no pickup, no stops). Areye will switch them off once integrators have moved totracking_url; build nothing from the reference. - Privacy: do not cache or keep driver coordinates beyond the delivery's operational need, and do not forward a tracking link beyond the recipient.
Waiting for a driver
{"success": true, "data": {
"reference": "ARY-8F3A21", "type": "parcel", "status": "pending", "is_active": true, "is_complete": false,
"dest_address": "Plot 123, Phakalane", "dest_lat": -24.5612, "dest_lng": 25.9701,
"driver_name": null, "driver_phone": null, "driver_plate": null, "driver_color": null,
"driver_lat": null, "driver_lng": null, "eta_minutes": null, "accepted_at": null, "completed_at": null,
"location_observed_at": null, "location_age_seconds": null, "location_is_fresh": false,
"scope": "token", "poll_after_seconds": 15,
"pickup_address": "Dijo, Riverwalk", "pickup_lat": -24.6662, "pickup_lng": 25.9312,
"stops": [], "route_polyline": null, "link_expires_at": "2026-10-11T09:00:00+02:00"
}}Assigned, fresh location
{"success": true, "data": {
"reference": "ARY-8F3A21", "status": "accepted", "is_active": true, "is_complete": false,
"driver_name": "Kabo Molefe", "driver_phone": null, "driver_plate": "B 123 ABC", "driver_color": "Toyota Corolla",
"driver_lat": -24.6601, "driver_lng": 25.9255, "eta_minutes": 6, "accepted_at": "2026-10-04T09:02:11+02:00",
"location_observed_at": "2026-10-04T09:04:40+02:00", "location_age_seconds": 12, "location_is_fresh": true,
"scope": "token", "poll_after_seconds": 15, "pickup_lat": -24.6662, "pickup_lng": 25.9312, "stops": [], …
}}Assigned, stale location: show “last known position, 22 minutes ago”, never “live”
{"success": true, "data": {
"reference": "ARY-8F3A21", "status": "accepted", "is_active": true,
"driver_lat": -24.6601, "driver_lng": 25.9255, "eta_minutes": 6,
"location_observed_at": "2026-10-04T08:42:40+02:00", "location_age_seconds": 1332, "location_is_fresh": false,
"scope": "token", "poll_after_seconds": 15, …
}}Picked up and travelling (multi-stop: stops in route order)
{"success": true, "data": {
"reference": "ARY-8F3A21", "status": "active", "is_active": true,
"driver_lat": -24.6123, "driver_lng": 25.9488, "eta_minutes": 9,
"location_observed_at": "2026-10-04T09:21:02+02:00", "location_age_seconds": 8, "location_is_fresh": true,
"pickup_lat": -24.6662, "pickup_lng": 25.9312,
"stops": [{"address": "Game City", "lat": -24.6905, "lng": 25.8810, "reached": true}],
"dest_lat": -24.5612, "dest_lng": 25.9701, "scope": "token", "poll_after_seconds": 15, …
}}Completed: polling stops, the driver is no longer followed
{"success": true, "data": {
"reference": "ARY-8F3A21", "status": "completed", "is_active": false, "is_complete": true,
"driver_lat": null, "driver_lng": null, "completed_at": "2026-10-04T09:34:50+02:00",
"location_observed_at": null, "location_age_seconds": null, "location_is_fresh": false,
"scope": "token", "poll_after_seconds": null, …
}}Revoked or expired link (HTTP 410): stop polling
{"success": false, "error": "tracking_link_revoked", "message": "This tracking link has been turned off."}
{"success": false, "error": "tracking_link_expired", "message": "This tracking link has expired."}Furniture, appliances and bulky goods
Add a goods manifest to a booking: each line with its weight and size per item. Areye works out the real and volumetric weight, picks the smallest vehicle whose door, load bay, payload and equipment actually fit every item (turning items where they may be turned), decides the crew, and prices every line in the market's currency. The same body sent to POST /business/bulky/quote returns exactly what the booking will freeze. Totals you send are ignored. A booking without goods is exactly as before.
- Service levels:
curbside,doorstep,room_of_choice. Assembly, installation and removal only where Areye has authorised your business; otherwise the order is held for review. An ordinary driver is never assumed to install anything. - Access: for room of choice give the floor, and on an upper floor whether there is a working lift. Missing access is never guessed: the order is held for review.
- Outcomes: 201 booked; 202 booked and held for review; 422
bulky_refusedwith the evidence, nothing created. - Proof: what the driver must capture is frozen on the order: condition photos, serials, the items checked against your order, who handed over, where they were placed, the recipient's first names, surname and signature. The driver cannot leave or finish without it. Read it back on
GET /business/trips/{trip}underbulky. - Problems:
trip.exceptionreports one of 14 structured problems. Before collection, an unsafe or wrong load goes back to operations, not to another driver blind. - Returns: a return to your store is charged at the percentage shown in the quote's terms, of the route fare, once.
- Delivery window: send
delivery_windowand, for estates or offices,access.restricted_hours(e.g.08:00-17:00). The order is not offered before it can arrive on time, cannot be taken once the window can no longer be met, and a window that cannot be met is refused at booking. - Several items: Areye approves a multi-item load automatically only when it can show how the items stand together in the vehicle (
fit.packing_confidence: "proven"). Otherwise the order is held for a person to check, never approved on volume alone. - When it does not go to plan: the driver records each line as delivered, damaged, refused or short (
trip.delivery_outcome); what was not delivered comes back with proof. You can then authorise disposal of damaged or refused lines, or order one redelivery. Waiting, cancelling after a driver is committed, redelivery and disposal are charged as frozen in the order'srules, once each. - Shop connectors:
GET /business/bulky/schemais the versioned contract. A WooCommerce, Shopify or OpenCart connector maps each product's packed weight and size onto a goods line and sends the order; it never picks a vehicle or computes a price. A product with no weight or size is sent without guessing and the order is held for review.
The figures below are from Areye's automated test market (BWP), shown so you can see the shape and the decisions. Your market's vehicles and prices apply to your orders.
1. A fridge
An upright fridge needs a tall door, a closed body and two people.
POST /business/book
{ ...route and recipient...,
"goods": [{"description": "Fridge", "category": "refrigerator", "weight_kg": 85,
"length_cm": 70, "width_cm": 75, "height_cm": 185, "upright": true, "serial": "FR-2026-0001"}],
"needs": {"body": "closed"} }
201 "bulky": { "status": "ok", "fit": {"vehicle_name": "One-ton closed with tail lift"},
"crew": {"required": 2},
"quote": {"lines": [{"code": "route_fare", "amount": 287}, {"code": "bulky_surcharge", "amount": 60},
{"code": "crew", "label": "1 additional crew", "amount": 150}],
"merchant_total": 497, "currency": "BWP"} }2. A king bed to the bedroom
Base and mattress are 183 cm wide: they do not pass a small van's door, so a larger vehicle is chosen; room of choice is priced as its own line.
"goods": [{"description": "King bed base", "category": "bed", "weight_kg": 55, "length_cm": 200, "width_cm": 183, "height_cm": 40},
{"description": "King mattress", "category": "mattress", "weight_kg": 45, "length_cm": 200, "width_cm": 183, "height_cm": 30}],
"service_level": "room_of_choice", "access": {"floor": 0}
201 status "ok" · vehicle "One-ton closed with tail lift" · crew 2
evidence for "Cargo Van": "line 1 (King bed base) does not fit through the door or in the bay"
lines: route fare 287 · handling 60 · 1 additional crew 150 · room-of-choice 80 · total 5773. A sofa for two people
Any item of 40 kg or more is a two-person lift; the smallest vehicle that takes it is used.
"goods": [{"description": "3-seater sofa", "category": "sofa", "weight_kg": 60, "length_cm": 210, "width_cm": 90, "height_cm": 85}]
201 status "ok" · vehicle "Cargo Van" · crew 2 · total 4694. An appliance batch
Three stops, each a fridge and a washer, one driver. The vehicle is chosen for the combined load (465 kg aboard at once); the crew is charged once, on the first stop.
POST /business/batches
{ "stops": [ {...route..., "goods": [fridge, washer]}, {...}, {...} ] }
201 "bulky": { "status": "ok", "run": { "vehicle": "One-ton closed with tail lift", "crew": 2,
"totals": {"actual_weight_kg": 465}, "load_order": [3, 2, 1], "unload_order": [1, 2, 3] } },
"stop_quotes": [ {"merchant_total": 497}, {"merchant_total": 347}, {"merchant_total": 347} ]5. Too large for any vehicle
Refused with the reason and the evidence for every vehicle. Nothing is created and nothing is charged; for a batch, no stop is created.
"goods": [{"description": "Generator", "weight_kg": 3500, "length_cm": 200, "width_cm": 120, "height_cm": 150}]
422 { "code": "bulky_refused",
"message": "No vehicle in this market can carry this load safely. Line 1 weighs 3500 kg; ...",
"bulky": { "status": "refused", "fit": {"evidence": { "...": {"qualifies": false, "why_not": ["payload 3000 kg < 3500 kg"]} } } } }6. Room of choice on an upper floor, lift unknown
The order is booked and held: no driver is sent until operations has checked it. If the checked price is higher you get trip.quote_changed and accept or decline it.
"goods": [washer], "service_level": "room_of_choice", "access": {"floor": 3}
202 "review_state": "manual_review_required",
"bulky": { "status": "manual_review_required",
"review": {"reasons": ["An upper floor needs to say whether there is a working lift."]} }
→ webhook trip.review_required, then trip.review_approved (or trip.quote_changed → POST .../bulky/accept-quote)Webhooks
Set your Webhook URL and reveal your webhook secret in Business Portal → Settings & API. Every POST carries X-Areye-Event (event name), X-Areye-Signature (sha256=HMAC-SHA256(raw_body, secret) — verify constant-time before trusting it), X-Areye-Signature-V2 (t={timestamp},sha256=HMAC-SHA256(timestamp + "." + raw_body, secret): recommended, refuse a timestamp more than 5 minutes from your clock so a captured request cannot be replayed), X-Areye-Timestamp and X-Areye-Delivery (a UUID, also id/event_id in the body). Delivery is at-least-once with retries: a 2xx (5s timeout) succeeds; otherwise Areye retries with exponential backoff — 6 attempts over ~8.5h, then exhausted. Build your receiver idempotent (dedupe on id), tolerant of out-of-order arrival. Match events to your order on data.reference or your own data.external_reference — not the top-level id.
| Event | Description |
|---|---|
trip.accepted |
A driver accepted the job. data: {trip_id, reference, external_reference, status: accepted, driver_name} |
trip.picked_up |
Collected from your store, en route to the customer (data.status: active) |
trip.completed |
Delivered. Full manifest: amount (final settled delivery fare; never includes shop & deliver goods), picked_up_at, completed_at, pod_photo_url, pod_signature_url, pod_at, age_verification_outcome, delivery_outcome (delivered | returned), returned (true when the goods came back to the store), return_reason (e.g. age_verification_refused), cod_status, cod_collected_amount |
trip.delivery_failed |
The driver could not deliver and is bringing the goods back. data: {reason, return_reason, returning: true, return_due_at}. The trip then ends with trip.completed, delivery_outcome "returned" |
trip.return_overdue |
A failed delivery coming back to you is past its return deadline and Areye operations are chasing the driver. Sent once. data: {return_due_at, return_reason} |
trip.cancelled |
Cancelled. data: {trip_id, reference, external_reference, status: cancelled, cancelled_by (customer|business|driver|admin|system), reason} |
trip.driver_released |
The driver dropped the job and Areye is finding another one. Not terminal: keep the order open; trip.accepted follows. data: {trip_id, reference, external_reference, status: pending, cancelled_by: driver, requeued: true, reason} |
trip.age_verified |
Age-restricted: buyer passed the ID check (age_verification_outcome: verified) |
trip.age_verification_failed |
Age-restricted: buyer failed. Goods return to store, no COD. data: {reason, age_verification_outcome: refused} |
cod.collected |
COD cash collected at the door. data: {declared_amount, collected_amount} |
cod.refused |
COD order: recipient did not pay (collected_amount: 0) |
cod.settlement_ready |
A batch of your collected cash is prepared for payout |
cod.settled |
Areye has paid your collected COD cash out to you |
coupon.redeemed |
An employee redeemed a discount coupon on a trip |
wallet.low_balance |
Wallet balance fell below your configured threshold |
wallet.auto_topup |
Your wallet was auto-topped-up from a saved card |
payout.paid |
Areye paid a payout you requested; the amount left your wallet. data: {payout_id, reference, amount, currency, status: paid, admin_note (bank reference), processed_at} |
payout.rejected |
A payout you requested was rejected; nothing left your wallet. data: {payout_id, reference, amount, currency, status: rejected, admin_note (the reason), processed_at} |
fleet.application.received |
A driver applied for one of your marketplace-published vehicles |
Payload
Every event has the same envelope: id, event_id, event, business_id, timestamp and data. What is inside data depends on the event. For the trip.* events it always includes trip_id (integer), reference (string), external_reference (string or null) and status (the trip status after the event), plus the fields listed above. Other events (cod.*, wallet.*, coupon.*, order.*) have their own shapes. Fields may be added at any time; ignore ones you do not know, and treat a field that is absent the same as null.
POST your-webhook-url
X-Areye-Event: trip.completed
X-Areye-Delivery: 7d1c…-uuid
X-Areye-Signature: sha256=5f2a…
X-Areye-Signature-V2: t=1791621174,sha256=9b0c…
X-Areye-Timestamp: 1791621174
{
"id": "7d1c…-uuid",
"event_id": "7d1c…-uuid",
"event": "trip.completed",
"business_id": 42,
"timestamp": "2026-10-03T18:04:11+02:00",
"data": {
"trip_id": 51234,
"reference": "ARY-8F3A21",
"external_reference": "Dijo #10423",
"status": "completed",
"amount": 38.50,
"picked_up_at": "2026-10-03T17:51:02+02:00",
"completed_at": "2026-10-03T18:04:10+02:00",
"pod_photo_url": "https://areye.app/documents/pod/….jpg?expires=…&signature=…",
"pod_signature_url": null,
"pod_at": "2026-10-03T18:04:08+02:00",
"age_verification_outcome": null,
"cod_status": null,
"cod_collected_amount": null
}
}Verifying the signature
Sign the raw request body, exactly as received, before parsing it.
// PHP
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('AREYE_WEBHOOK_SECRET'));
if (! hash_equals($expected, $_SERVER['HTTP_X_AREYE_SIGNATURE'] ?? '')) { http_response_code(401); exit; }
// Node (Express: app.use(express.raw({ type: 'application/json' })))
const sig = 'sha256=' + crypto.createHmac('sha256', process.env.AREYE_WEBHOOK_SECRET).update(req.body).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(req.get('X-Areye-Signature') || ''));- Ordering is not guaranteed. A retry can arrive after a later event. Use
data.statusand never move an order backwards (for example from completed to accepted). - Secret rotation: each attempt, including retries of older events, is signed with your current secret. After rotating, verify with the new secret only.
- After an outage on your side: undelivered events keep retrying for about 8.5 hours. For anything older, reconcile with
GET /business/trips(newest first,searchby your reference) rather than waiting for webhooks. - Events are sent only after Areye has saved the change they report.
Rate Limits
Each business account may make 120 requests per minute (each partner key has its own allowance), wherever the calls come from; above that you receive 429 too_many_requests with a Retry-After header (seconds) and X-RateLimit-Limit / X-RateLimit-Remaining. Wait the Retry-After before retrying. If you are planning high-volume or bulk automation, contact our team so we can provision the right limits for your account. Endpoints that trigger external payment gateways may apply stricter per-minute limits.
Integrating a merchant platform
If you run a marketplace, restaurant group, grocery chain or online store and want Areye drivers to
deliver your orders, you do not need a separate product. You use the same
/business/book endpoint with extra
fields. Your customers stay in your app; Areye handles dispatch, the driver relationship and
proof of delivery.
What the driver actually sees
When you send order_payload, the driver
gets a full order card — your store, your item list with photos, and the money to handle — instead of
a bare pickup/drop-off pair. Every field below maps to something on that screen, so send what you
want the driver to know.
| You send | Driver sees |
|---|---|
| order_payload.store_name | Store name as the card heading |
| order_payload.external_reference | Your order number, so staff and driver can talk about the same order |
| order_payload.items[] | Itemised list — name, qty, price, options (e.g. "no onions", "sliced") |
| order_payload.items[].image | Product photo beside each line. Must be a publicly reachable URL; a broken one degrades to a placeholder rather than failing the booking |
| order_payload.order_note | Highlighted note ("leave at the gate") |
| order_payload.order_amount | "Order P348.50" chip — the value of goods carried |
| cod_amount | "Collect P348.50 cash" chip |
| driver_tip | A tip your customer left for the driver in your own app (0–500, in the trip's currency). Tips belong to the driver: when the delivery completes, Areye charges it to your account and pays it in full to the driver who delivered — no commission. Nothing is charged if the delivery does not complete. Never add it to the fare. |
| delivery_speed | standard (default) or priority (express). A priority delivery is offered to drivers at once at the widest search radius, and is never batched or combined with another order on the same driver. A priority fee is charged with the delivery (returned as priority_fee); part of it goes to the driver when the delivery completes, and it is refunded with the delivery if it is cancelled. Deliveries only (type: parcel), not scheduled or bulky orders. When priority cannot apply, the order is still booked, as standard, and the response says so: delivery_speed_applied: "standard" with delivery_speed_reason. Every trip response and trip.* webhook carries delivery_speed. Not accepted on /business/batches (priority_not_batchable). Works the same in the sandbox. |
| bring_change_amount | "Bring change for P500" chip — send it when your customer says what note they will pay with |
| prep_minutes | Countdown so the driver is timed to your kitchen or picking floor |
The flow
- Customer orders and pays in your app, however you already collect payment.
- Your backend calls
POST /business/bookwith pickup (your store), drop-off (your customer) and the order payload. - Areye dispatches a nearby driver and starts firing webhooks.
- You call
POST /business/trip/{reference}/readywhen the order is packed. - Your customer follows the live map at the booking's
tracking_url(or your own map, polling it; see Live tracking) and can message the driver. - Driver hands over against a delivery code, plus a proof-of-delivery photo if your account requires one. Delivery fees are debited from your Areye wallet.
Important — the handover gate. If you send
handover_required: true, the driver is
blocked from collecting until you call
/ready. Use it when nothing may leave
the counter early (restaurants, prescription collection, high-value e-commerce) — but you must wire the
/ready call, or orders will sit
uncollected. Leave it false (the default)
and the driver may collect as soon as they arrive;
prep_minutes still paces them.
Creating a merchant order
POST /api/business/book
X-Business-API-Key: your_api_key_here
Content-Type: application/json
{
"type": "parcel",
"pickup_address": "Choppies Riverwalk, Gaborone",
"pickup_lat": -24.6541, "pickup_lng": 25.9237,
"dest_address": "Plot 22, Phakalane",
"dest_lat": -24.5821, "dest_lng": 25.9412,
"passenger_name": "Kagiso M.",
"passenger_phone": "+26771234567",
"prep_minutes": 15,
"handover_required": false,
"age_verification_required": false,
"cod_amount": 348.50,
"bring_change_amount": 500.00,
"order_payload": {
"store_name": "Choppies Riverwalk",
"store_phone": "+2673901234",
"external_reference": "CHP-88421",
"module_type": "grocery",
"order_amount": 348.50,
"order_note": "Leave at the gate if no answer",
"items": [
{ "name": "Milk 2L", "qty": 2, "price": 24.00,
"image": "https://cdn.example.com/milk.jpg" },
{ "name": "Brown bread", "qty": 1, "price": 12.50,
"options": "Sliced" }
]
}
}
Choosing how money moves
| Model | Send | What Areye does |
|---|---|---|
| Prepaid | order_payload only |
Customer already paid you. Driver delivers; your wallet is debited the delivery fee. |
| Cash on delivery | cod_amount
(+ optional bring_change_amount) |
Driver collects cash into a ring-fenced ledger held in custody, then Areye settles it to you and fires cod.settled. |
| Shop & deliver | shop_deliver: true + purchase_amount |
The driver is advanced a float to buy the goods, uploads the receipt, and the difference is reconciled — order.receipt_reconciled. Restricted to trusted drivers, so dispatch may take longer.
The money, line by line on your wallet: the delivery fare at booking; the purchase float ( purchase_amount) when a driver accepts; at the receipt, any unspent float comes back and any spend above it (within tolerance) is charged. The goods are your money and are never part of the fare: amount on trip.completed is the delivery fare only. A cancelled order gives back the fare and the whole float. |
Age-restricted orders (alcohol, tobacco)
Send age_verification_required: true on any
order containing alcohol, tobacco or anything else with a minimum age. The driver is then
blocked from completing the delivery until they have checked the buyer's photo ID and
recorded the outcome. The proof-of-delivery photo and signature captured at that moment are the evidence.
| Outcome | What happens |
|---|---|
verified |
Buyer proved they are of age. Delivery completes normally. |
refused |
No valid ID, underage, or the buyer declined to show it. The driver is instructed not to hand over and to return the goods to your store. Handle the refund on your side. |
Read the result back from any trip response —
age_verification_required and
age_verification_outcome — and use it to
decide whether to refund. A return leg is exempt from the check, since nothing is being handed to a buyer.
The licence obligation stays with you. Areye gives you an ID check at the door and a durable record of it. That is a control, not a transfer of liability — the duty to sell lawfully (licence conditions, permitted hours, refusing intoxicated or underage buyers) remains with the licensed seller. Make sure delivery windows for these orders fall inside your permitted trading hours, and only flag orders you are licensed to sell.
Webhooks you will receive
Set your endpoint on PATCH /business/profile.
Every payload carries reference — match it to the
external_reference you sent. Each request also
carries X-Areye-Event and
X-Areye-Signature
(sha256=HMAC-SHA256(raw_body, your_webhook_secret)) — your webhook_secret and Webhook URL are on the portal Settings & API page (separate from your API key) —
verify it with a constant-time comparison before trusting the payload.
| trip.accepted | Driver took the order — includes driver_name. Show "driver on the way". |
| trip.picked_up | Collected from your store, en route to the customer |
| trip.completed | Delivered — close the order on your side |
| trip.cancelled | Includes cancelled_by and reason |
| cod.settlement_ready | Collected cash reconciled and ready to settle |
| cod.settled | Cash settled to your account |
| order.receipt_reconciled | Shop & deliver: driver receipt matched against the float |
| wallet.low_balance | Top up before deliveries start failing |
Cash on delivery: the money terms
- Fee: Areye keeps your account's COD fee (
cod_fee_pct, a percentage of the cash collected), booked as afeeline when the settlement is made. - Cycle: collected cash is gathered into a settlement on your
settlement_cycle(for example weekly), one settlement per currency. - Netting: when
cod_netting_enabledis true, what your account owes Areye is taken from the settlement before it is paid out. - Endpoints:
GET /business/cod/ledgerreturnscod_enabled,cod_fee_pct,settlement_cycle,cod_netting_enabled,unsettled_amountand the last 200entries;GET /business/cod/settlementslists settlements. cod.settlement_readyandcod.settledpayload (data):id, reference, period_start, period_end, gross, fee, net, currency, status, paid_at, orders.net = gross − fee(less any netting);paid_atis null untilcod.settled.
Before you go live
- Fund your wallet, or ask us to enable invoiced billing — delivery fees are debited per order, and a flat wallet stops dispatch.
- Send us your store list and service areas so we can confirm driver coverage around each branch.
- Ask us to enable proof-of-delivery photos if you need them for disputes.
- Selling alcohol or tobacco? Flag those orders with
age_verification_required, keep delivery windows inside your licensed trading hours, and handle refunds for refused handovers. - Serve item images over HTTPS from a public URL — signed or expiring links will show as placeholders.
- Store our
referenceagainst your order. It is the key for tracking, chat, webhooks and support. - Dedicated driver pools, default prep times and shop-and-deliver access are configured per account — tell us your volumes and we will set them.
Sandbox
Build and test against https://sandbox.areye.app/api with an sk_sandbox_ key: real references, the real signed webhooks, and simulation endpoints that drive each order through acceptance, collection, delivery, a failed delivery, an ID refusal, a return or a driver cancellation. Nothing there reaches a driver or moves money. Going live changes the base URL and key only. See Sandbox.
Fetch, cancel & the delivery manifest
GET /business/trips/{reference} returns one order by reference or id; GET /business/trips?reference=… (or ?search=…) does server-side lookup. POST /business/trips/{reference}/cancel (body reason) cancels a not-yet-picked-up order and refunds any charge. The manifest — on both the record and the trip.completed webhook — carries the timeline (accepted/picked_up/arrived/completed), proof of delivery (pod_photo_url, pod_signature_url — absolute HTTPS signed links that expire (default 24 hours): download the files you keep as dispute evidence when you receive them, and call GET /business/trips/{reference} for fresh links later — and pod_at), the age result and COD status.
Account billing mode, final amount & /ready
Read billing_type on GET /wallet. Prepaid blocks a booking the wallet can't cover; postpaid dispatches on credit up to credit_limit — balance stays 0 and the amount owed is the positive amount_owed. This account billing mode is separate from the per-order money model (prepaid / COD / shop_deliver). On a trip, payment_status reads paid once a prepaid wallet has paid the fare at completion; a postpaid account's trips stay pending until invoiced. billing_amount on the 201 is a provisional quote; the final settled charge is amount on trip.completed. POST /business/trip/{reference}/ready is only required for orders booked handover_required: true; a per-booking prep_minutes overrides your account default.
Money-field glossary & test button
Same value, different name by surface: declared COD is cod_amount (trip) / declared_amount (cod.collected); collected COD is cod_collected_amount / collected_amount. The authoritative delivery charge is amount on trip.completed (billing_amount is the provisional quote; final_fare_amount is set only if a completion recalc changed it). On Settings & API, Send test webhook POSTs a signed sample to your URL so you can confirm your HMAC check (and catch a stale secret) in one click.
Courier platform
For courier companies running their own operation on Areye, and for the marketplaces and accounting systems that connect to them: instant prices and booking on versioned rate cards, client billing (prepaid, credit or your own accounting system), signed billing events and exports, long-running shipments with milestones, health and alerts, network runs across borders with customs and laboratory samples, and what a courier's drivers may receive.
In-store shopping fulfilment
Version: shopping fulfilment v1 · Last changed: 11 October 2026 (creation vs arrival order clarified; 410 Gone refusal and replay; outcome set and field meanings stated). Policy: within v1 changes are additive only (new optional fields, events or values you can ignore; no field is ever repurposed). A breaking change comes as a new version, announced to your technical contact, with the old version kept running at least 90 days alongside it — and, because these events carry money (refunds and amounts collected), never switched off until every integrator using it has demonstrably moved.
Send one order that a single Areye shopper buys across several stores (for example the shops in a shopping centre) and delivers in one trip.
You send a pick list per store; the shopper marks each line found, substituted or unavailable under the customer's substitution preference,
photographs each store's till receipt, and Areye tells your platform what happened through signed webhooks to your URL,
signed with your secret. It is an ordinary shop & deliver merchant order underneath: the shopper is fronted the shelf money and the float reconciles on the till receipts.
The full integrator guide is also available as docs/SHOPPING_FULFILMENT.md on request.
1. Switching it on
The capability is off by default for every account. While it is off, nothing about your other bookings changes, the endpoints below answer 403 SHOPPING_FULFILMENT_OFF, and nothing is ever sent. Ask Areye support to enable it for your account. Then set your webhook URL and issue your secret:
# where your events go (public HTTPS only; no private networks, no credentials in the URL; redirects are never followed)
curl -s -X PUT $AREYE/business/shopping-fulfilment -H "X-Business-API-Key: $KEY" \
-H "Content-Type: application/json" -d '{"webhook_url":"https://partner.example/webhooks/areye-shopping"}'
# issue (or rotate) your signing secret -- shown ONCE in the response
curl -s -X POST $AREYE/business/shopping-fulfilment/secret/rotate -H "X-Business-API-Key: $KEY"
# check your setup: enabled, webhook_url, webhook_secret_set, sending ("ok" or the reason nothing can be sent)
curl -s $AREYE/business/shopping-fulfilment -H "X-Business-API-Key: $KEY"Rotating takes effect at once, including for retries of earlier events. Each integrator has its own URL and secret; no other account's orders or events are ever visible to you.
2. Authentication
Every call uses your Business API key in X-Business-API-Key, or a scoped partner key (see Partner keys), exactly like the rest of the Business API.
3. Creating a shopping order
POST /business/shopping-orders. It takes the same pickup and drop-off fields as POST /business/book (pickup_address, pickup_lat, pickup_lng, dest_address, dest_lat, dest_lng, optional scheduled_at, notes, delivery_code, payment_method, test), the recipient as first names and surname, and a shopping block. Retries are safe on external_reference (defaults to your external_order_id), as for every booking.
curl -s -X POST $AREYE/business/shopping-orders -H "X-Business-API-Key: $KEY" -H "Content-Type: application/json" -d '{
"recipient_first_names": "Kagiso Thabo",
"recipient_surname": "Molefe",
"recipient_phone": "+26771234567",
"pickup_address": "Riverwalk Mall, Gaborone", "pickup_lat": -24.6639, "pickup_lng": 25.9318,
"dest_address": "Plot 1234, Block 8", "dest_lat": -24.6280, "dest_lng": 25.9030,
"purchase_amount": 120.00,
"shopping": {
"external_order_id": 123456,
"external_location_id": 3,
"substitution_preference": "ask",
"stores": [
{ "external_fulfilment_id": 9001, "external_store_id": 2, "store_name": "Fresh Grocer", "sequence": 1,
"estimated_amount": 56.00,
"items": [ { "external_item_id": 7001, "name": "Full cream milk 2L", "qty": 2, "unit_price": 28.00,
"barcode": "6001234567890", "image": "https://cdn.partner.example/milk.jpg" } ] },
{ "external_fulfilment_id": 9002, "external_store_id": 5, "store_name": "City Pharmacy", "sequence": 2,
"estimated_amount": 64.00,
"items": [ { "external_item_id": 7002, "name": "Paracetamol 500mg x24", "qty": 1, "unit_price": 64.00 } ] }
]
}
}'| Field | Required | Meaning |
|---|---|---|
| recipient_first_names | yes | The recipient's first names. |
| recipient_surname | yes | The recipient's surname. |
| recipient_phone | yes | The recipient's phone, international format. |
| purchase_amount | no | Shelf money fronted to the shopper. Defaults to the sum of the stores' estimated_amount; may not be less than it. |
| shopping.external_order_id | yes | Your order id. Letters, digits and . _ : - up to 64 characters. Echoed back with the type you sent (a number stays a number). |
| shopping.external_location_id | no | Your id for the shopping centre or location, if you have one. |
| shopping.substitution_preference | no | refund (default), match or ask. See below. |
| shopping.stores[] | yes | 1 to 20 stores, shopped in sequence. |
| stores[].external_fulfilment_id | yes | Your id for this store's part of the order. Unique within the order. |
| stores[].external_store_id | yes | Your id for the store. |
| stores[].store_name | no | Shown to the shopper. |
| stores[].sequence | no | Shopping order; defaults to the array order. |
| stores[].estimated_amount | yes | What you expect the shopper to spend at this store. |
| stores[].items[] | yes | 1 to 200 lines. |
| items[].external_item_id | yes | Your line id, unique across the whole order. Areye never invents ids: every event uses yours. |
| items[].name | yes | Product name shown to the shopper. |
| items[].qty | yes | Whole units requested. |
| items[].unit_price | no | Expected shelf price per unit. |
| items[].barcode | no | Lets the shopper scan to confirm. |
| items[].image | no | Product photo URL. |
The response is the same as /business/book (201 with the trip reference). Read the order's live state at any time with GET /business/shopping-orders/{reference}, and your outbound events with GET /business/shopping-fulfilment/deliveries.
4. Substitution preferences and the decision window
The preference is one of exactly refund, match or ask. Omitted means refund; any other value is refused with 422 before anything is booked. It is fixed on the order when it is created: a retry of the same order with a different preference is refused with 409 external_reference_conflict, and the preference travels with the shopper's pick list.
- refund (default): a missing line is marked
unavailable; the shopper may not buy a substitute. You refund the customer for it. "match"— shown to customers as “Allow a suitable substitute”. Send the exact literalmatch; no other spelling is accepted. The shopper may buy a like-for-like substitute without asking (decided_by: "preference") only within limits frozen on the order when it is created: no more pieces than ordered; a unit price no higher than the line'sunit_priceplus the margin (default 10% for new orders, read it frommatch_price_tolerance_pctinGET /business/shopping-fulfilment); a line total no higher than the line'smax_line_amount; and the basket staying withinmax_basket_amount(yourpurchase_amount), counting what is already bought and the original value of lines still to shop. The order keeps its ownmatch_margin_pct,currencyand ceilings: a later change to the default never reinterprets an order already placed. Outside any limit, or when the line has no listed price, the substitute becomes a proposal and you receiveshopping.substitution_requestedwithreason: "outside_substitution_limits", exactly as under ask.- ask: a substitute is only a proposal. You receive
shopping.substitution_requested, ask your customer, and answer within the decision window (default 5 minutes; the current value isask_timeout_minutesinGET /business/shopping-fulfilment). No answer in time falls back to refund (unavailable,decided_by: "preference").
curl -s -X POST $AREYE/business/shopping-orders/T8F3A21QZ/items/7001/decision -H "X-Business-API-Key: $KEY" \
-H "Content-Type: application/json" -d '{"decision":"accept"}' # or "reject"
# 200 -> the line's outcome; 409 "The decision window closed; the item was refunded." when too latedecided_by is one of shopper (found, or unavailable on the shelf), customer (your accept/reject) or preference (match, or the window closing).
5. Events you will receive
POST to your webhook URL, Content-Type: application/json. Store status is collected or unavailable (the whole store could not be shopped); line outcome is found, substituted or unavailable — the complete set; there is no other value (an unavailable line is the one you refund).
Creation order, not arrival order. Every store — collected or unavailable — gets its own shopping.store_collected event, and all of them are created before the order's single shopping.basket_collected. Delivery is at-least-once: a store event that failed and is being retried can arrive after the basket event, and any event can arrive more than once. So: dedupe on event_id (= X-Areye-Delivery), never rely on arrival order, and use basket_collected.stores[] as the authoritative summary of every store's final status and amount. If an event seems to be missing, fetch the order's current state with GET /business/shopping-orders/{trip_reference}; your delivery history is at GET /business/shopping-fulfilment/deliveries.
"proposed_substitute" vs "substitute": deliberately different names. item.proposed_substitute in shopping.substitution_requested is a proposal awaiting the customer's decision; items[].substitute in shopping.store_collected is what was actually bought (present only when outcome is substituted).
shopping.substitution_requested (preference "ask" only)
{"event":"shopping.substitution_requested","event_id":"areye-shop-41-ask-310","external_order_id":123456,"trip_reference":"T8F3A21QZ",
"store":{"external_fulfilment_id":9001,"external_store_id":2},
"item":{"external_item_id":7001,"qty_requested":2,"proposed_substitute":{"name":"Store-brand 2L","barcode":"600xxxx","unit_price":27.25},"qty_found":2},
"expires_at":"2026-10-10T09:05:00+02:00","at":"2026-10-10T09:00:00+02:00"}shopping.store_collected (once per store)
{"event":"shopping.store_collected","event_id":"areye-shop-41-store-87","external_order_id":123456,"trip_reference":"T8F3A21QZ",
"store":{"external_fulfilment_id":9001,"external_store_id":2,"status":"collected","estimated_amount":56.0,"collected_amount":54.5,
"currency":"BWP","receipt_photo_url":"https://api.areye.app/api/shopping/receipts/87?expires=...&signature=..."},
"items":[{"external_item_id":7001,"outcome":"substituted","qty_requested":2,"qty_found":2,"unit_price_paid":27.25,
"substitute":{"name":"Store-brand 2L","barcode":"600xxxx","unit_price":27.25},"decided_by":"customer"}],
"at":"2026-10-10T09:00:00+02:00"}shopping.basket_collected (exactly once per order)
{"event":"shopping.basket_collected","event_id":"areye-shop-41-basket","external_order_id":123456,"trip_reference":"T8F3A21QZ",
"stores":[{"external_fulfilment_id":9001,"external_store_id":2,"status":"collected","estimated_amount":56.0,"collected_amount":54.5},
{"external_fulfilment_id":9002,"external_store_id":5,"status":"unavailable","estimated_amount":64.0,"collected_amount":0.0}],
"collected_total":54.5,"currency":"BWP","at":"2026-10-10T09:40:00+02:00"}shopping.basket_collected is emitted exactly once, when every store is collected or unavailable; then the shopper heads to the customer and the ordinary trip webhooks (see Webhooks) continue. Those trip.* events go to your business webhook URL (the one for all your trips), not to the shopping webhook URL — a shopping receiver receives only shopping.* events. Amounts are in currency, which is the order's billing currency fixed when you booked it; it never changes during the order, and you need not validate it beyond that.
6. Signature (HMAC-V2)
Each request carries:
X-Areye-Timestamp: 1791621174 # unix seconds at the moment of sending
X-Areye-Delivery: areye-shop-41-store-87 # the event_id; identical on every retry
X-Areye-Event: shopping.store_collected
X-Areye-Signature-V2: t=1791621174,sha256=<hex>
signing string = <timestamp> + "." + <raw body bytes>
hex = lowercase hex of HMAC-SHA256(your secret, signing string)Worked example. Secret TEST_ONLY_example_secret, timestamp 1791621174, body exactly the shopping.store_collected example above written on one line with no spaces and "receipt_photo_url":"https://signed.example/receipt.jpg", gives 4bb3c00dcf528d4f53795e87c5e1d958175d9d33b7496e3bbc8f2f9fc78d7e47. The exact bytes are in the integrator guide.
// PHP -- verify BEFORE parsing; use the raw body exactly as received
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_AREYE_TIMESTAMP'] ?? '';
$hdr = $_SERVER['HTTP_X_AREYE_SIGNATURE_V2'] ?? '';
$ok = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
&& hash_equals("t={$ts},sha256=" . hash_hmac('sha256', $ts . '.' . $raw, getenv('AREYE_SHOPPING_SECRET')), $hdr);
if (! $ok) { http_response_code(401); exit; }
// Node (Express: app.use(express.raw({ type: 'application/json' })))
const ts = req.get('X-Areye-Timestamp') || '';
const want = `t=${ts},sha256=` + crypto.createHmac('sha256', process.env.AREYE_SHOPPING_SECRET)
.update(ts + '.').update(req.body).digest('hex');
const got = req.get('X-Areye-Signature-V2') || '';
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) <= 300;
const ok = fresh && got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want));Reject a timestamp more than 5 minutes from your clock. The timestamp is fresh on every attempt, so a retry hours later still verifies; the body bytes never change.
7. Idempotency and retries
- Any 2xx is success. Anything else, a timeout, or a redirect is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (6 attempts by default).
- Versioning on every event: the body carries
api_version(v1),event_type(same asevent),event_idandoccurred_at; headers carryX-Areye-Api-Version: v1andX-Areye-Attempt-Id(new on every attempt — dedupe onevent_id, not on the attempt id). In v2 the event-specific fields will move underpayload; v1 keeps its current shape. - Signature V3 (recommended):
X-Areye-Signature-V3: t={timestamp},sha256={hex}, hex = HMAC-SHA256 with your secret of this canonical string — five lines joined by\n, in this order:api_version,X-Areye-Delivery-Timestamp,event_id,X-Areye-Attempt-Id, and base64 of the SHA-256 digest of the exact raw body. Also check thatX-Areye-Event-IdandX-Areye-Api-Versionequal the body'sevent_idandapi_version.X-Areye-Key-Idnames the secret that signed it: when you rotate your secret, keep the old one until events arrive under the new key id. Reject a delivery timestamp more than 5 minutes from your clock; a replay is a fresh delivery (new timestamp and attempt id, same event_id and body), so it always verifies. V2 (timestamp + "." + body) is still sent unchanged; move to V3 when you can. - To refuse an event for good (for example it names an order you do not have), answer
410 Gone, optionally with a short reason in the body. Areye records that event as refused (never as delivered), with your reason, endpoint and time, alerts its operations team, and stops retrying that event only — every other event to your endpoint carries on. Once you have corrected your side, replay it withPOST /business/shopping-fulfilment/deliveries/{event_id}/replay(the same event_id and body; areasonis required and recorded; at most 10 replays a minute; it always goes to your configured webhook URL, never to one in the request). Do not use 2xx for an event you did not process; dead-letter it on your side or answer 410. - Every retry sends the same body bytes and the same
X-Areye-Delivery(=event_id). Dedupe on it and answer 2xx for anything you have already processed. - Events are sent only after Areye has saved the change they report. Order between events is not guaranteed across retries; use
statusandoutcome, never arrival order. - While your account has no secret or no valid URL, events are held (not dropped and not counted as attempts) and go out once both are set.
GET /business/shopping-fulfilmentsays why nothing is sending.
8. Receipt photos
receipt_photo_url is a signed link to the photographed till receipt, valid for at least 10 minutes (currently receipt_url_ttl_minutes in GET /business/shopping-fulfilment, 24 hours by default). Copy the image to your own storage when you receive the event; an expired or altered link answers 403.
9. Errors
| 403 SHOPPING_FULFILMENT_OFF | The capability is not switched on for your account. |
| 422 | The shopping block is invalid; message names the first problem (for example "each external_item_id may appear only once", or purchase_amount below the stores' estimates). Nothing is booked. |
| 422 (webhook_url) | The URL is not a public HTTPS address. |
| 404 | No shopping order with that reference for your account (other accounts' orders are never visible). |
| 409 | A decision arrived for a line that is not waiting for one, or after the window closed. |
| 401 / 429 | As for the rest of the Business API (bad key, rate limit). |
10. Testing
Test your signature check against the worked example above before anything else. To run a shopping order end to end, ask Areye support for a sandbox session: real shopping orders are only ever handled by real shoppers, so test runs are arranged with the operations team rather than fired at the live network. Point your webhook URL at a public HTTPS endpoint you control and watch GET /business/shopping-fulfilment/deliveries for each event's state, attempts and last error.
Rewards & Gift Cards
Areye is the parent registry for coupons and gift cards across the whole suite. One code is minted once, and every platform that accepts it — Areye itself and any merchant integrated with these endpoints — validates and redeems it against the same ledger. That means a customer cannot spend the same code twice on two different platforms, and you get one reconciliation trail instead of several.
Areye is the primary redemption surface. A code minted here is, first and foremost, spend-on-Areye value — rides, deliveries, cargo and car hire. Dijo, Mmaraka and other participating merchants are secondary surfaces that the same code also happens to work on. When you issue a batch you are issuing value your customer can spend on Areye today, and in your own store as a bonus — not the other way round.
Authentication. Every merchant endpoint in this section is authenticated with your business
API key in the X-Business-API-Key header —
not an Authorization: Bearer token.
All paths below are relative to https://api.areye.app/api.
The public storefront endpoints at the end of this section take no key at all.
Two instruments, two very different money models
Read this before you issue anything. The difference is not cosmetic — it decides when your wallet is debited and what happens to value the customer does not use.
Reward coupon (discount) |
Gift card (gift_card) |
|
|---|---|---|
| Spending behaviour | Consumed in full, once. A P20 coupon against a P15 fare gives P15 and the remaining P5 is simply lost — the code is spent either way. One live use per code. | Stored value that draws down. A P500 card against a P120 order leaves P380 on the card, and that balance follows the customer across further orders and across platforms until it is gone. |
| When your wallet is debited | Depends on the discount type. A percentage reward costs nothing to mint — each code a customer actually uses debits the issuing merchant’s wallet for the discount granted, and unused codes never cost you a thebe. A fixed-amount reward is different: it is minted as stored value, so the whole batch face value is debited at issuance, exactly like a gift card, and the recipient keeps any unspent balance. | At issuance. Gift cards are prepaid stored value: the whole batch face value (unit value × quantity) is debited the moment the batch is minted. The float then sits with Areye and each redemption draws it down. |
| Solvency check at issue | Wallet must cover the batch at maximum exposure (fixed value, or max_discount for percent codes), else 422. |
Wallet must cover the full face value, else 422 telling you exactly how much to top up. |
| PIN | Not used. | Bearer cards carry a 4-digit PIN, returned at issuance and never retrievable again. Send pin on validate, redeem and balance. A card issued straight to a named Areye account carries no PIN — the account itself is the proof of ownership. |
| Activation | Live as soon as it is issued (subject to starts_at). |
Not spendable until activated — either activate: true at issuance, or at the till via /gift-cards/activate. |
| Currency | No FX conversion, ever. A code prices only in its own currency; a mismatch is refused with “This coupon can only be used in BWP”. Run multi-currency campaigns as sibling batches. | |
Three ways a gift card is redeemed
All three are live at the same time, and all three move the same balance on the same card. You do not choose one at issuance — the holder chooses at spend time.
1. Drawn down at checkout
The customer types the code (and PIN) at your checkout. You call /validate then /redeem, and only the order amount comes off the balance. The rest stays on the card.
2. Loaded into the Areye wallet
In the Areye app the holder can load the card whole into their wallet. The entire remaining balance is redeemed in one move and becomes wallet credit, spendable on any Areye service without re-entering the code.
3. Assigned to an account
A card can be issued directly against an existing Areye account. It then appears in that customer’s Promotions screen with nothing to type and no PIN to remember — they simply tap it at checkout.
Settlement: who bears the discount, who gets paid back
How a code is consumed
Every code also carries a redemption mode,
which decides what happens the first time it is used. You rarely set this yourself — it is derived
from the instrument type — but it is returned on every code and is worth understanding.
| Mode | Behaviour |
|---|---|
| once | Consumed whole on first use, whatever the order is worth. A percentage discount works this way: use it and it is spent. |
| balance | Spends down. Each redemption draws only what the order needs and the remainder stays on the code for next time. Gift cards and fixed-amount rewards work this way, which is why both are prepaid at issuance. |
| Funding | Where the money comes from | Is the honouring merchant credited? |
|---|---|---|
| gift_card | The buyer already prepaid Areye and Areye holds the float. Nothing is debited at redemption — the card’s stored value is simply drawn down. | Yes. Your wallet is credited the amount you honoured. |
| platform | Areye’s own marketing budget. No merchant wallet is touched to fund it. | Yes. Areye is running the promotion, so Areye pays for it. |
| issuer | The issuing merchant’s wallet is debited at the moment of redemption. If the balance cannot cover it the whole redemption fails rather than granting an unfunded discount. | Yes — unless the issuer is also the merchant honouring it. A shop redeeming its own promo owes itself nothing, so both the debit and the credit are skipped and it nets to zero. |
| merchant | The merchant honouring the code bears the discount out of their own margin. | No. No reimbursement is written. |
Batches minted through POST /business/coupons/issue
are issuer-funded reward coupons, or prepaid
gift_card stored value when
instrument_type says so. Reversing a redemption
claws back both sides: the reimbursement is debited off the merchant who no longer gave anything
up, and a wallet-funded issuer gets their money back. A gift card gets its drawn-down value returned to the
balance. That is why /reverse is not optional.
Redemption lifecycle
Three calls, in this order. /validate is a dry run you make
while the customer is still on the checkout screen; /redeem is the
committing call and the only place a usage counter or a stored balance ever moves;
/reverse puts the value back if the order later fails.
| Endpoint | Body | What it does |
|---|---|---|
| POST /business/coupons/validate | code, currency, cart_total, optional user_key, merchant_ref, platform, pin |
Prices the code against the cart and returns the value that would apply. Changes nothing. Safe to call as often as you like. |
| POST /business/coupons/redeem | As above, plus order_amount and external_order_ref (both required) instead of cart_total |
Commits the redemption under a row lock, re-checking every cap. Idempotent on external_order_ref — retrying the same order reference returns the original redemption rather than double-spending. Always send your own order id. Because you honoured the value, you are the party reimbursed — see settlement above. |
| POST /business/coupons/reverse | redemption_id, or coupon_id + external_order_ref (with platform) |
Releases a redemption on refund or cancellation. A discount code becomes usable again; a gift card gets its value back on the balance; settlement is unwound on both sides. Idempotent — reversing twice is a safe no-op. If you never call this, the value stays consumed. |
/validate returns
valid,
reason (a message safe to show the customer when
invalid), discount_amount,
discount_type,
coupon_id,
currency,
instrument_type,
stackable, and for gift cards
remaining_value plus
remaining_after so you can show the customer what
will be left on the card. /redeem answers
200 with
redemption_id and
idempotent, or
422 with a reason (expired, already used, below
min_spend, wrong currency, out of scope, bad PIN,
issuer wallet short). Treat any non-200 as “no discount applied” and charge the full amount.
POST /api/business/coupons/redeem
X-Business-API-Key: your_api_key_here
Content-Type: application/json
{
"code": "AREYE-7K2MQX",
"currency": "BWP",
"order_amount": 120.00,
"external_order_ref": "SHOP-88421",
"user_key": "+26771234567",
"merchant_ref": "riverwalk",
"pin": "4821"
}
Issuing a batch
POST /business/coupons/issue mints up to
1,000 codes at once and returns them, along with one-time PINs when the batch is gift cards. Set
instrument_type to
discount (default) or
gift_card. Codes are generated from an
unambiguous character set with no 0/O/1/I/L, so they survive being printed and read over the phone.
| Field | Notes |
|---|---|
| quantity | Required. 1–1000 codes in the batch. |
| currency | Required. The wallet debited is the wallet for this currency. |
| instrument_type | discount (default) or gift_card. |
| discount_type / discount_value | Required for discount batches. fixed or percent; percent cannot exceed 100. |
| max_discount | Caps a percent code — and is what your exposure is calculated against. An uncapped percent batch is treated as zero exposure, so cap it. |
| value | Required for gift-card batches. Face value per card. Debited in full at issuance. |
| activate / activation_channel | Gift cards activate immediately by default; send activate: false to leave them dormant for till activation. Channel is one of web, retail, ecommerce, admin. |
| prefix / title / batch_label | Your own branding on the codes and your reference for the batch. |
| scope_platforms / scope_merchants | Arrays restricting where the codes may be spent. Omit for suite-wide (["*"]). |
| min_spend / starts_at / expires_at / stackable | Standard promo controls. expires_at must be in the future. |
POST /api/business/coupons/issue
X-Business-API-Key: your_api_key_here
Content-Type: application/json
{
"instrument_type": "gift_card",
"quantity": 50,
"currency": "BWP",
"value": 200.00,
"prefix": "STAFF",
"batch_label": "Q3 staff rewards",
"activate": true,
"activation_channel": "admin",
"expires_at": "2027-12-31T23:59:59+02:00"
}
201 Created
{
"success": true,
"data": {
"batch_id": "BATCH-XXXXXXXXXX",
"issued": 50,
"coupons": [
{ "id": 9001, "code": "STAFF-4H8P2Q", "pin": "8140", "value": 200 }
],
"note": "PINs are shown once and cannot be retrieved later ..."
}
}
Store the PINs on the 201. They are hashed on our side and cannot be recovered. The batch above debits your BWP wallet P10,000 (50 × P200) immediately, before the first card is ever spent. If minting fails after the debit, the float is credited straight back.
Managing batches and cards
| Endpoint | What it does |
|---|---|
| GET /business/coupon-batches | Lists your batches with total, active, redeemed, outstanding_value and issued_at — your reconciliation view over the float you are carrying. |
| PATCH /business/coupon-batches/{batch}/revoke | Kills the codes in a batch (leaked print run, cancelled campaign) and returns how many were disabled; 404 if the batch is not yours or does not exist. Already-redeemed value is not clawed back. |
| POST /business/gift-cards/activate | Till activation. Body: code, optional activation_channel and purchase_reference. Cards you print and rack unactivated are worthless until this call, so a stolen card off the shelf is not spendable. Only the issuing business may activate its own cards; idempotent on an already-live card. |
| POST /business/gift-cards/balance | Body: code, optional pin. Returns currency, initial_value, remaining_value, activated, expires_at, status. Wrong PIN is a 403; unknown card a 404. |
Public gift-card storefront
Consumers buy Areye-issued cards on the storefront at
areye.app/gift-cards, which uses the
unauthenticated, rate-limited /api/gift-cards/*
family. You can drive the same flow from your own site. Nothing spendable is minted until the gateway
confirms the money — never from the browser.
| Endpoint | What it does |
|---|---|
| GET /gift-cards/options | Optional ?currency=. Returns the offered denominations, supported currencies, the gateways that can actually take money in that currency, and the terms. |
| POST /gift-cards/purchase | Records an intent to buy. Body: amount, currency (BWP or ZAR), buyer_name, buyer_email, optional buyer_phone, recipient_name, recipient_email, recipient_phone, message, gateway. Returns a reference. No card exists yet. |
| POST /gift-cards/pay | Body: reference, gateway, optional msisdn for mobile money. Returns either a redirect_url for card gateways or on-screen instructions for a mobile-money push. |
| POST /gift-cards/confirm | Body: reference. Asks the gateway whether the money actually arrived and, if so, mints and delivers the card. Safe to call repeatedly — the browser return, the webhook and the cron sweep all call it and only one card is ever minted. 202 while payment is still unconfirmed. |
| GET /gift-cards/purchase/{reference} | Buyer-facing status poll. Returns status, amount, currency, and the code once paid. The PIN is delivered to the recipient by email and SMS and is never re-exposed here. |
| POST /gift-cards/balance | Body: code, optional pin. The public “how much is left on my card?” lookup. Wrong PIN is a 403. |
Stored value can only be backed by money we can see. A gift card is minted only against a gateway confirmation that carries a positive collected amount matching the order. Cash and deferred gateways — the ones that report “paid” because a driver will collect on arrival — are refused outright at both payment initiation and confirmation. A gift card has no delivery leg to collect on, so accepting them would hand out free spendable value. Underpayments and zero-amount confirmations are rejected and logged.
Legacy promos and the unified code box
Areye had its own promotions system before the suite-wide registry existed. The two now resolve through one path: a passenger types a code into a single box, and Areye checks the suite registry first and the legacy promo table second. The customer never has to know which system their code lives in, and the booking screen and the Promotions screen can never disagree about what a code is worth.
| Family | Endpoints | Reach |
|---|---|---|
| Suite registry (use this) | POST /business/coupons/validate POST /business/coupons/redeem POST /business/coupons/reverse POST /business/coupons/issue |
Suite-wide. Coupons and gift cards, one ledger, settlement and reversal built in. |
| Legacy per-business promos | POST /business/coupons GET /business/coupons GET /business/coupons/{promo} PATCH /business/coupons/{promo}/deactivate |
Areye trips only. Still supported for existing integrations, and still resolved by the same unified code box — but they carry no stored value, no gift-card behaviour and no cross-platform settlement. |
Note the shapes: POST /business/coupons is the
legacy single-promo create, while POST /business/coupons/issue
mints a suite-wide batch. New integrations should use the /coupons/*
family throughout.
Errors
Areye uses standard HTTP status codes. Responses are JSON with a success flag and, on failure, a human-readable message. Every refusal from the business API carries a stable machine-readable code (a refusal without a specific one gets the code for its status: conflict, unprocessable, forbidden, …): branch on code, never on the message text.
| Status | Meaning |
|---|---|
| 200 | Success |
| 201 | Created — resource was successfully created |
| 401 api_key_required / invalid_api_key | Missing or unknown API key |
| 401 session_ended / business_inactive | The portal login was ended, or the account is not active |
| 403 portal_admin_only | A portal coordinator tried an admin action |
| 403 signed_in_admin_only | An integration key tried an action on people or payouts; the owner or an admin must do it signed in |
| 404 not_found / trip_not_found / endpoint_not_found | No such record for your account, or no such endpoint |
| 409 reference_conflict / request_in_progress | Idempotency-Key or reference reused with a different body, or the first attempt is still running |
| 410 tracking_link_revoked / tracking_link_expired | The tracking link was turned off or has expired (the older error field carries the same value) |
| 422 webhook_url_unsafe | The webhook URL is not a public HTTPS address (also from POST /webhook-test) |
| 422 invalid_phone | A phone number is not valid; send it in international form |
| 422 flight_time_required / flight_too_soon | Airport booking without a flight time, or too late to schedule |
| 502 webhook_receiver_failed | POST /webhook-test: your receiver did not answer 2xx (status and error in data) |
| 403 | Forbidden — your account does not have this feature enabled |
| 404 | Not Found — resource does not exist |
| 403 scope_denied | A partner key used outside its scopes |
| 409 external_reference_conflict | That external_reference was already booked with different details |
| 409 booking_in_progress | The first attempt with that reference is still running; retry after Retry-After |
| 422 | Unprocessable — validation failed, insufficient wallet balance, or credit limit exceeded |
| 422 not_cancellable | The trip has been collected or has ended |
| 401 sandbox_key_on_live | A sandbox key (sk_sandbox_…) sent to live; use https://sandbox.areye.app/api |
| 401 live_key_on_sandbox | Anything but a sandbox key sent to the sandbox |
| 404 sandbox_only / live_only | An endpoint that exists only in the other environment |
| 422 test_booking_refused | Live booking with a test marker (field and marker are named in the response); test in the sandbox |
| 422 test_mode_moved_to_sandbox | "test": true on live; test in the sandbox |
| 422 validation_failed | A field is missing or invalid; errors names each field |
| 422 insufficient_balance | Prepaid wallet cannot cover this booking (balance, required, currency in the response) |
| 422 credit_limit_exceeded | Postpaid: this booking would pass your credit limit |
| 422 OUT_OF_SERVICE_AREA | Pickup is outside every Areye zone |
| 422 zone_service_unavailable | That service is switched off in the pickup zone |
| 422 vehicle_fit_refused | What you declared does not fit any vehicle class you may book |
| 422 cod_refused | Cash on delivery not allowed for this order or account |
| 422 shopping_payload_invalid | The shopping block of a shopping order is invalid |
| 422 booking_refused | The booking was refused at the last step (the message says why) |
| 403 service_not_enabled | That booking type is not enabled for your account |
| 403 allocation_spend_limit / allocation_trip_limit | The employee has reached a monthly limit |
| 503 service_paused | Areye has paused that service platform-wide |
| 429 too_many_requests | Rate limit exceeded; wait Retry-After seconds |
| 500 server_error | Internal Server Error — contact support |
SDKs & Libraries
Official SDKs are under development. In the meantime, use the REST API directly with your preferred HTTP client.
Ready to build?
Get a sandbox key in two minutes and run a delivery end to end today. When you are ready for live, our team sets up your account.
Get a sandbox key Request API Access