In-store shopping fulfilment — integrator guide
A generic Areye Business API capability: any merchant platform can send one order that a single Areye shopper buys across several stores and delivers in one trip. Everything here is public; the same text (shorter) is in the developer docs at /developers#shopping-fulfilment.
Underneath, a shopping order is an ordinary shop & deliver merchant order: the shopper is fronted the shelf money (purchase float), and the float reconciles against the till receipts when the basket is complete. It is not part of the courier network.
1. Switching it on
- Off by default for every account. While off: your other bookings are unchanged, the endpoints below return
403withcode: SHOPPING_FULFILMENT_OFF, and nothing is ever sent. - Areye switches it on per account (admin: Configuration → Shopping fulfilment).
- Then configure your own endpoint and secret:
PUT /api/business/shopping-fulfilment {"webhook_url": "https://partner.example/webhooks/areye-shopping"}
POST /api/business/shopping-fulfilment/secret/rotate → {"data": {"webhook_secret": "shf_…"}} (shown once)
GET /api/business/shopping-fulfilment → enabled, webhook_url, webhook_secret_set, sending, ask_timeout_minutes, receipt_url_ttl_minutes
GET /api/business/shopping-fulfilment/deliveries → your last 100 outbound events and their state
webhook_url must be public HTTPS (no private/loopback/metadata addresses, no credentials in the URL); it is re-checked at every attempt and redirects are never followed. Each integrator has its own URL and secret; no account sees another's orders or events. Rotation takes effect immediately, including for retries of older events.
2. Authentication
X-Business-API-Key: <your key> (or a scoped partner key), as for the rest of the Business API.
3. Create a shopping order
POST /api/business/shopping-orders
Same pickup/drop-off fields as POST /api/business/book (pickup_address, pickup_lat, pickup_lng, dest_address, dest_lat, dest_lng; optional scheduled_at, notes, delivery_code, payment_method), plus:
| Field | Req. | Meaning |
|---|---|---|
recipient_first_names |
yes | Recipient's first names |
recipient_surname |
yes | Recipient's surname |
recipient_phone |
yes | International format |
purchase_amount |
no | Shelf money fronted to the shopper. Defaults to Σ estimated_amount; may not be lower |
external_reference |
no | Idempotency key for the booking; defaults to shopping.external_order_id |
shopping.external_order_id |
yes | Your order id ([A-Za-z0-9._:-]{1,64}) |
shopping.external_location_id |
no | Your id for the shopping centre/location |
shopping.substitution_preference |
no | refund (default) | match | ask |
shopping.stores[] |
yes | 1–20 stores |
stores[].external_fulfilment_id |
yes | Your id for this store's part of the order; unique in the order |
stores[].external_store_id |
yes | Your store id |
stores[].store_name |
no | Shown to the shopper |
stores[].sequence |
no | Shopping order (default: array order) |
stores[].estimated_amount |
yes | Expected spend at this store |
stores[].items[] |
yes | 1–200 lines |
items[].external_item_id |
yes | Your line id, unique across the order. Areye never invents ids |
items[].name |
yes | Product name |
items[].qty |
yes | Whole units |
items[].unit_price |
no | Expected shelf price per unit |
items[].barcode |
no | Lets the shopper scan to confirm |
items[].image |
no | Product photo URL |
External ids are stored exactly as received and echoed with the JSON type you sent (a numeric id comes back as a number, a string as a string).
Response: as /business/book (201 with the trip reference; a repeat with the same external_reference replays). Live state: GET /api/business/shopping-orders/{reference}.
4. Substitution preferences
- refund — a missing line is
unavailable; no substitute may be bought. - match — the shopper may buy a like-for-like substitute (
decided_by: "preference"). - ask — the shopper proposes; you get
shopping.substitution_requested, ask the customer, and answer:
POST /api/business/shopping-orders/{reference}/items/{external_item_id}/decision {"decision": "accept" | "reject"}
The window is 5 minutes by default (ask_timeout_minutes). No answer in time → unavailable, decided_by: "preference"; a late answer gets 409 "The decision window closed; the item was refunded."
decided_by ∈ shopper | customer | preference.
5. Events
Store status ∈ collected | unavailable. Line outcome ∈ found | substituted | unavailable, with qty_requested, qty_found, unit_price_paid, optional substitute {name, barcode, unit_price}, and decided_by.
shopping.substitution_requested— preferenceaskonly; carriesitem.proposed_substitute,qty_foundandexpires_at.shopping.store_collected— once per store, when it is collected (receipt photographed, amount paid entered) or declared unavailable.shopping.basket_collected— exactly once per order, when every store is collected or unavailable; carries every store's amounts andcollected_total. After it, the ordinary trip webhooks continue.
Example (shopping.store_collected), exact bytes used in §6:
{"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://signed.example/receipt.jpg"},"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"}
6. Signature (HMAC-V2)
Headers on every request:
X-Areye-Timestamp: <unix seconds at sending>
X-Areye-Delivery: <event_id> (identical on every retry)
X-Areye-Event: <event>
X-Areye-Signature-V2: t=<timestamp>,sha256=<hex>
hex = HMAC-SHA256(secret, "<timestamp>.<raw body bytes>"), lowercase. Verify on the raw bytes before parsing, compare in constant time, and reject a timestamp more than ±5 minutes from your clock.
Worked example: secret TEST_ONLY_example_secret, timestamp 1791621174, body = the exact bytes in §5 →
4bb3c00dcf528d4f53795e87c5e1d958175d9d33b7496e3bbc8f2f9fc78d7e47
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_AREYE_TIMESTAMP'] ?? '';
$ok = ctype_digit($ts) && abs(time() - (int) $ts) <= 300
&& hash_equals("t={$ts},sha256=" . hash_hmac('sha256', $ts . '.' . $raw, getenv('AREYE_SHOPPING_SECRET')),
$_SERVER['HTTP_X_AREYE_SIGNATURE_V2'] ?? '');
7. Idempotency and retries
- 2xx = delivered. Anything else, a timeout or a redirect → retried after 1 min, 5 min, 30 min, 2 h, 6 h (6 attempts by default).
- Retries send the same body bytes and the same
X-Areye-Delivery(=event_id). Dedupe on it. - Events are sent only after the change they report is saved. Do not rely on arrival order.
- With no secret or no valid URL on your account, events are held (not dropped, no attempt spent) and sent once both are set.
8. Receipt URLs
receipt_photo_url is a signed link valid for at least 10 minutes (receipt_url_ttl_minutes; 24 h by default). Copy the image when you receive the event; an expired or altered link answers 403.
9. Errors
| Status | When |
|---|---|
403 SHOPPING_FULFILMENT_OFF |
Capability not enabled for your account |
| 422 | Invalid shopping block (message names the first problem); nothing is booked. Or a non-public webhook_url |
| 404 | No shopping order with that reference for your account |
| 409 | Decision for a line not awaiting one, or after the window closed |
| 401 / 429 | Bad key / rate limit, as for the Business API |
10. Testing
Check your verifier against the worked example first. Real shopping orders are handled only by real shoppers, so end-to-end runs are arranged with Areye operations as a sandbox session — never fired at the live network. Watch GET /api/business/shopping-fulfilment/deliveries for state, attempts and last error.