Areye REST API

API Documentation

Integrate Areye rides, deliveries, cargo, car rental, and rent-to-own directly into your platform. Manage fleets, employees, wallets, and billing — all through one authenticated API.

Live: https://api.areye.app/api
Sandbox: https://sandbox.areye.app/api
Get a sandbox key Request API Key

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.

Request Header
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.

LiveSandbox
Base URLhttps://api.areye.app/apihttps://sandbox.areye.app/api
API keysk_live_…sk_sandbox_…
Wrong key401 sandbox_key_on_live401 live_key_on_sandbox
Every responseheader X-Areye-Environment: liveheader X-Areye-Environment: sandbox and "environment": "sandbox" in the body
DriversRealNone: you drive each trip with the simulation endpoints, or let the simulated driver do it
MoneyRealTest 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 gatewaysReal cards, mobile money and EFTNone: wallet top-ups and card payments cannot be completed in the sandbox
Drivers and timingReal drivers, real delaysThe sandbox driver; you step each trip (or turn the autopilot on). Webhooks to the built-in inbox arrive instantly
One-time codesSent by SMS / WhatsAppNever sent; always 123456
Test names (__TEST…)Refused: 422 test_booking_refusedWelcome

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.9087 and drop-off -24.6600, 25.9100 in Gaborone; GET /business/zones lists them.
  • What: GET /business/vehicle-types and GET /business/parcel-sizes are 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_code you set is entered by the simulated driver at the door. Customer and driver one-time codes in the sandbox are always 123456.

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
stepWhat the driver doesWebhooks you receive
assign_driverAccepts the trip (status accepted).trip.accepted
arrive_pickupArrives at the pickup. For a ride, marks arrived; for a delivery, only moves the driver.none for a delivery (live sends none either)
pick_upCollects the order (status active). A bag label scan is overridden and recorded, as the app allows.trip.picked_up
arrive_dropoffArrives at the customer (status arrived).none
deliverEnters 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_deliveryCannot deliver; starts the return. Body: {"reason": "Nobody home"}.trip.delivery_failed {reason, return_reason, returning: true, return_due_at}
id_refusedAge-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_returnBrings 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_driverThe 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_driver

A 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 to https://sandbox.areye.app/api/channel/{source}/events. See Partner sales channel contract and Areye Shop API.
  • In-store shopping fulfilment: POST /business/shopping-orders works in the sandbox the same way. See In-store shopping fulfilment.

7. Going live checklist

  1. 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.
  2. Your receiver verified every signature, deduplicated a repeated id, ignored an event older than the order's state, and treated delivery_outcome: "returned" as goods back in stock.
  3. A retried booking with the same external_reference came back 200 replayed: true, and you handle every refusal by its code.
  4. Swap two settings only: base URL to https://api.areye.app/api and 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.
  5. Nothing that reaches live carries a test marker: __TEST, [TEST], TEST ORDER, DO NOT DELIVER at the start of any name, item, address or note is refused (test_booking_refused), and Areye's operations team is alerted.
  6. 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

POST /business/book Book a ride, parcel, cargo or airport transfer on behalf of an employee or guest — supports scheduling and up to 3 stops
GET /business/trips List trips, newest first. Filters: month (YYYY-MM), reference, search (reference, passenger, address or your external_reference), test (0/1), per_page
GET /business/trips/active List trips currently in progress
GET /business/trips/{id} Retrieve a single trip with receipt and route details
POST /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
GET /business/trip/fare-estimate Price a trip before booking it (same calculator the booking uses)
GET /business/trips/{id}/waybill Download the printable waybill for a delivery
GET /trip-track/{reference} Public shareable tracking link — no authentication required

Merchant Orders & Delivery

POST /business/book Same booking endpoint — add order_payload, prep_minutes, cod_amount or shop_deliver to turn a delivery into a merchant order
POST /business/trip/{reference}/ready Signal the order is packed and ready for collection — releases the waiting driver immediately
GET /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
GET /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
GET /business/packing-slip-settings Your packing slip settings: show_prices (default off) and returns_note
PUT /business/packing-slip-settings Change show_prices and / or returns_note (max 1000 characters)
POST /business/book Age-restricted goods: add age_verification_required to force a photo-ID check at the door before handover
GET /trip-track/{reference} Customer-facing live tracking — no auth, safe to embed in your own app or SMS
GET /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
POST /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

POST /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
GET /business/batches List batches with per-stop status
GET /business/batches/{id} Retrieve a single batch with all stops
GET /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)
POST /business/subscriptions Create a recurring delivery (parcel/cargo, days of week, pickup time)
GET /business/subscriptions List recurring delivery subscriptions
PATCH /business/subscriptions/{id}/{action} Pause, resume or cancel a subscription

Car Rental & Fleet

GET /business/rentals/vehicles List your rental fleet with rates, deposits and availability
POST /business/rentals/vehicles Add a vehicle to your rental fleet (photos, daily rate, km limits)
POST /business/rentals/vehicles/bulk Bulk-add vehicles to your fleet
PATCH /business/rentals/vehicles/mode Bulk-set listing mode (rental / ride-hailing / both)
PATCH /business/rentals/vehicles/{id} Update a vehicle — rates, deposit, marketplace publishing
DELETE /business/rentals/vehicles/{id} Remove a vehicle from your fleet
GET /business/rentals/bookings List rental bookings against your fleet
POST /business/rentals/bookings Create a rental booking (self-drive or chauffeured)
PATCH /business/rentals/bookings/{id}/{action} Confirm, complete or cancel a rental booking

Rent-to-Own & Driver Applications

GET /business/rto List rent-to-own agreements against your vehicles
POST /business/rto Create a rent-to-own agreement (earnings-skim or direct-pay, chosen cadence)
POST /business/rto/{id}/payments Record an instalment payment toward an agreement
GET /business/fleet-applications List driver applications for your marketplace-published vehicles
PATCH /business/fleet-applications/{id}/{action} Approve or reject a driver application (assigns the vehicle / opens the agreement)

Wallet, Payouts & Billing

GET /business/wallet Get current wallet balance and billing type
GET /business/wallet/transactions List wallet transactions (debits, credits, top-ups)
POST /business/wallet/topup Initiate a wallet top-up via a payment gateway
GET /business/wallet/topup/{ref}/status Poll the status of a wallet top-up
GET /business/payment-gateways List available payment gateways for your zone
GET /business/payouts List earnings payout requests
POST /business/payouts Request a payout of positive earnings (owner or portal admin signed in; not an integration key)
DELETE /business/payouts/{id} Cancel a pending payout request
GET /business/billing/statements List monthly billing statements

People, Groups & Coupons

GET /business/people List all employees and their spending limits
POST /business/people Invite an employee to your business account
PATCH /business/people/{id} Update employee details, group or spending limit
DELETE /business/people/{id} Remove an employee from your account
GET /business/groups List groups for organising employees
POST /business/groups Create a group
GET /business/coupons List employee discount coupons
POST /business/coupons Issue a new coupon (fixed or percent)
PATCH /business/coupons/{id}/deactivate Revoke a coupon

Reference Data

GET /business/vehicle-types List vehicle types with live pricing and availability
GET /business/parcel-sizes List parcel size tiers with rates
GET /business/zones List active service zones

Account

GET /business/profile Retrieve your business account profile and settings (includes portal_role and principal)
GET /business/trips/export Every trip matching from, to, type and status, as CSV
POST /business/change-password A signed-in person changes their password; their other devices are signed out
PATCH /business/profile Update company name, billing contact, webhook URL or low-balance threshold
GET /business/api-key Retrieve API key metadata
POST /business/api-key/regenerate Rotate your API key (previous key invalidated immediately)
GET /business/dashboard Aggregate account metrics — trips, spend, active employees

Example: Book a ride for an employee

POST /api/business/book
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

  1. Finish picking and packing the order as you do today.
  2. Fetch scan_label_url — an A6 PDF label carrying the QR (encoding scan_code) with your order number printed underneath it. Print it and stick it on the bag.
  3. 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).

201 Created — Response
{
  "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. 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_key
  2. 2. 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. 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. 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. 5. Prove your retry is safe

    Send exactly the same request again. Expect 200 with "replayed": true and the same reference. Your code must treat this as success. Change any detail and you get 409 external_reference_conflict.

    # same command as step 4
  6. 6. 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. 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. 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 on id, and confirm each changes your order exactly once. Then try fail_delivery and complete_return on 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. 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: true is 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.

201New delivery created.
200 · replayed: trueSame reference, same details: the original delivery is returned. No second trip, charge or driver. Treat it as success.
409 · external_reference_conflictSame reference, different details. The original is untouched; reference names it.
409 · booking_in_progressThe 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)
statusMeaningCancellableFinal
pendingLooking for a driver (a merchant order with prep_minutes is offered when the food is nearly ready)yesno
scheduledBooked for later; offered to drivers shortly before scheduled_atyesno
queuedA driver accepted it to start straight after their current tripyesno
acceptedA driver is on the way to pickupyesno
enrouteDriver heading to pickup (older clients); treat as acceptedyesno
arrivedDriver is at pickupyesno
activeCollected; on the way to the customernono
completedDelivered / finishednoyes
cancelledEnded without completing. An unanswered booking expires as cancelled with cancelled_by: systemnoyes

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.

ScopeWhat it may doEndpoints
deliveriesDeliveries: 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
rewardsRewards: 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 (compare timestamp; 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) while is_active. Slow down or pause when your screen is in the background; stop when it is null.
  • Freshness: location_is_fresh uses 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 stops and destination ("scope": "token"). Draw the route once from those points, then move only the driver marker. route_polyline is 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 to tracking_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_refused with 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} under bulky.
  • Problems: trip.exception reports 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_window and, 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's rules, once each.
  • Shop connectors: GET /business/bulky/schema is 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 577

3. 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 469

4. 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.status and 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, search by 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_nameStore name as the card heading
order_payload.external_referenceYour 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[].imageProduct 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_noteHighlighted 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_tipA 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_speedstandard (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_minutesCountdown so the driver is timed to your kitchen or picking floor

The flow

  1. Customer orders and pays in your app, however you already collect payment.
  2. Your backend calls POST /business/book with pickup (your store), drop-off (your customer) and the order payload.
  3. Areye dispatches a nearby driver and starts firing webhooks.
  4. You call POST /business/trip/{reference}/ready when the order is packed.
  5. 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.
  6. 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.acceptedDriver took the order — includes driver_name. Show "driver on the way".
trip.picked_upCollected from your store, en route to the customer
trip.completedDelivered — close the order on your side
trip.cancelledIncludes cancelled_by and reason
cod.settlement_readyCollected cash reconciled and ready to settle
cod.settledCash settled to your account
order.receipt_reconciledShop & deliver: driver receipt matched against the float
wallet.low_balanceTop 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 a fee line 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_enabled is true, what your account owes Areye is taken from the settlement before it is paid out.
  • Endpoints: GET /business/cod/ledger returns cod_enabled, cod_fee_pct, settlement_cycle, cod_netting_enabled, unsettled_amount and the last 200 entries; GET /business/cod/settlements lists settlements.
  • cod.settlement_ready and cod.settled payload (data): id, reference, period_start, period_end, gross, fee, net, currency, status, paid_at, orders. net = gross − fee (less any netting); paid_at is null until cod.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 reference against 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.

Read the courier platform integrator guide →

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 } ] }
    ]
  }
}'
FieldRequiredMeaning
recipient_first_namesyesThe recipient's first names.
recipient_surnameyesThe recipient's surname.
recipient_phoneyesThe recipient's phone, international format.
purchase_amountnoShelf money fronted to the shopper. Defaults to the sum of the stores' estimated_amount; may not be less than it.
shopping.external_order_idyesYour order id. Letters, digits and . _ : - up to 64 characters. Echoed back with the type you sent (a number stays a number).
shopping.external_location_idnoYour id for the shopping centre or location, if you have one.
shopping.substitution_preferencenorefund (default), match or ask. See below.
shopping.stores[]yes1 to 20 stores, shopped in sequence.
stores[].external_fulfilment_idyesYour id for this store's part of the order. Unique within the order.
stores[].external_store_idyesYour id for the store.
stores[].store_namenoShown to the shopper.
stores[].sequencenoShopping order; defaults to the array order.
stores[].estimated_amountyesWhat you expect the shopper to spend at this store.
stores[].items[]yes1 to 200 lines.
items[].external_item_idyesYour line id, unique across the whole order. Areye never invents ids: every event uses yours.
items[].nameyesProduct name shown to the shopper.
items[].qtyyesWhole units requested.
items[].unit_pricenoExpected shelf price per unit.
items[].barcodenoLets the shopper scan to confirm.
items[].imagenoProduct 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 literal match; 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's unit_price plus the margin (default 10% for new orders, read it from match_price_tolerance_pct in GET /business/shopping-fulfilment); a line total no higher than the line's max_line_amount; and the basket staying within max_basket_amount (your purchase_amount), counting what is already bought and the original value of lines still to shop. The order keeps its own match_margin_pct, currency and 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 receive shopping.substitution_requested with reason: "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 is ask_timeout_minutes in GET /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 late

decided_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 as event), event_id and occurred_at; headers carry X-Areye-Api-Version: v1 and X-Areye-Attempt-Id (new on every attempt — dedupe on event_id, not on the attempt id). In v2 the event-specific fields will move under payload; 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 that X-Areye-Event-Id and X-Areye-Api-Version equal the body's event_id and api_version. X-Areye-Key-Id names 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 with POST /business/shopping-fulfilment/deliveries/{event_id}/replay (the same event_id and body; a reason is 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 status and outcome, 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-fulfilment says 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_OFFThe capability is not switched on for your account.
422The 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.
404No shopping order with that reference for your account (other accounts' orders are never visible).
409A decision arrived for a line that is not waiting for one, or after the window closed.
401 / 429As 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.
Every code carries a funding mode. It decides two separate money movements at redemption: whether anyone is debited to fund the value, and whether the merchant who honoured the value at the till is reimbursed for it. Both are written in the same database transaction as the redemption itself, so a discount is never granted unfunded.

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
quantityRequired. 1–1000 codes in the batch.
currencyRequired. The wallet debited is the wallet for this currency.
instrument_typediscount (default) or gift_card.
discount_type / discount_valueRequired for discount batches. fixed or percent; percent cannot exceed 100.
max_discountCaps a percent code — and is what your exposure is calculated against. An uncapped percent batch is treated as zero exposure, so cap it.
valueRequired for gift-card batches. Face value per card. Debited in full at issuance.
activate / activation_channelGift 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_labelYour own branding on the codes and your reference for the batch.
scope_platforms / scope_merchantsArrays restricting where the codes may be spent. Omit for suite-wide (["*"]).
min_spend / starts_at / expires_at / stackableStandard 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.

JavaScript / Node.js
Planned
Python
Planned
PHP / Laravel
Planned

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