Dijo sales channel — partner contract v1 (DRAFT for agreement)
Areye sells Dijo goods inside the Areye app. Dijo is merchant of record and the authority for catalogue, basket, order, payment, substitutions and refunds. Areye is a sales channel and, separately, the fulfilment network (through Dijo's existing Areye Business account and the shopping fulfilment v1 contract, unchanged).
Hosted by Dijo under /channel/v1. Versioned: breaking changes only in
/channel/v2; additive fields may appear at any time and must be ignored if
unknown. This is a partner contract, independent of Dijo's own mobile-app API.
Conventions
- Auth (Areye → Dijo):
X-Channel-Key: <secret issued by Dijo to Areye>over HTTPS. Areye calls only from its servers; the key is never in the app. - Events (Dijo → Areye): POSTed to Areye's channel webhook, signed exactly
like shopping fulfilment v1 (V3 canonical signature, key id, timestamp,
event_idstable per event, new attempt id per attempt). 2xx = processed. - Idempotency: every create carries a stable Areye reference; a repeat
with the same reference and the same body returns the first result; the
same reference with a different body returns
409 channel_reference_conflict. - Money: decimal strings with an ISO currency on every amount.
- Errors:
{ "error": { "code": "...", "message": "..." } }, HTTP 4xx/5xx.503 channel_unavailablemeans Areye shows Dijo stores as temporarily unavailable; Areye never sells from a cached card. - Ids: Dijo's own (
store_id,item_id,variant_id,order_id,line_id), opaque to Areye.
Customer identity
Areye sends a channel customer, never a Dijo login:
"channel_customer": {
"channel": "areye",
"channel_customer_id": "acu_7f3c…", // opaque, stable per Areye customer
"first_names": "Kagiso", "surname": "Molefe",
"phone": "+26771000000", "email": null,
"marketing_consent": false
}
Dijo may keep it for order history and support. Areye passes no password or
token; Dijo cannot reach the Areye account and markets directly only with
marketing_consent: true.
Categories
Areye owns the customer-facing categories (grocery, restaurant,
pharmacy, alcohol, hardware, …). Dijo keeps its own codes and is never
asked to remodel its catalogue. Dijo puts its native codes on every store,
item, category and validated line:
"source_vertical": "grocery", // Dijo's module or store type
"source_category": "dairy" // Dijo's category, where it has one
Areye keeps an approved, versioned mapping from those codes to its own categories:
- Anything with missing or unmapped codes is hidden.
- A category is sold only when an Areye admin has switched it on; restricted categories (liquor, pharmacy) are off by default.
- Prohibited goods are never shown.
- Item-level codes win over the store's, so a supermarket's paint aisle never appears in groceries.
Areye passes source_verticals=a,b on store and search reads to ask only for
what the tile shows, and filters again on what comes back. The vertical is
frozen when the basket is validated; every validated line must carry its
codes, and Areye refuses lines outside it. It is checked again when the order
is created.
Delivery coverage: the native fence applies
Each merchant's contract records its own delivery coverage, and the channel applies exactly what the merchant's native app applies (Nii, 11 Oct 2026). For Dijo that is zone-polygon containment, through the same code as the Dijo app (Dijo commit c708f5e):
- Store lists (
/stores,/search) show only stores in an active zone whose polygon contains the delivery point. No distance clause, no per-store radius, and the same rule for every module. - Basket validation and order use Dijo's real order engine. A point
outside the store's zone is refused with
out_of_coverage_area. A point in no active zone is refused withoutside_service_area. Areye relays the code and message to the app unchanged. - The zones are Dijo's data. Gaborone is a tight production zone. The South Africa zone is deliberately wide during pre-launch testing, so every SA store reaches every SA point. Tightening it is a Dijo zone edit, not a channel change.
- Areye's own distance cap stays off for Dijo. Areye can cap on-demand shopping distance per partner and category, but only for partners that do not fence their own stores. Goods meant to travel far go by Areye courier, not the on-demand shop.
1. Catalogue reads
| Call | Purpose |
|---|---|
GET /stores?lat=&lng= |
Stores serving that point: id, name, logo, open now, next opening, minimum order, delivery fee estimate, mall id if any |
GET /stores/{store_id}/categories |
Category tree |
GET /stores/{store_id}/items?category=&cursor= |
Items with their purchasable options, price, was-price (only when genuinely discounted), availability, images, quantity limits. Each option (Dijo variation + add-ons together) carries an opaque option_token minted by Dijo; Areye never models the structure and echoes the token back |
GET /search?q=&lat=&lng=&cursor= |
Items and stores |
Areye may cache for a few minutes for speed. The cache is never authoritative.
2. Basket validation
POST /baskets/validate
{
"channel_reference": "ab_9d1…",
"channel_customer": { … },
"delivery": { "address": "…", "lat": -24.65, "lng": 25.91, "instructions": null },
"substitution_preference": "refund | ask | match",
"mall_id": null, // set when the lines span several stores of one mall
"lines": [ { "store_id": "…", "item_id": "…", "option_token": null, "qty": 2, "shown_unit_price": "28.00" } ]
}
Basket scope: a basket is either one store, or several stores of ONE mall
(mall_id set, up to the mall's store limit). Lines from stores outside a
single mall are refused (basket_scope_invalid). The delivery address must
fall in a Dijo-served zone (outside_service_area).
Response: validation_id, expires_at, per line (status:
ok | price_changed | reduced | unavailable, current unit price, quantity
allowed), fees named separately (delivery, which depends on distance, zone, speed tier and surge; service; tax, which is added, never included), discounts,
total, currency, changes_require_acceptance: true|false, and any basket
rule refusals (store closed, minimum order, mall combination).
3. Order creation (pending payment)
POST /orders
{
"validation_id": "…",
"channel_order_reference": "ao_51c…", // idempotency key
"accepted_changes": true, // required when the validation had changes
"channel_customer": { … },
"return_url": "https://areye.app/channel/dijo/return?ref=ao_51c…"
}
Response: order_id, status: "pending_payment", total, currency,
payment_session { url, expires_at }. One payment session per order: a
repeated call or a reopened page reuses or re-queries the same session and
never creates a second one (a second session risks a double charge). An expired or changed validation is
refused (409 validation_stale) and Areye validates again.
4. Payment outcome (authoritative)
Areye opens payment_session.url in its payment webview. On return, or when
the page is closed, Areye calls:
GET /orders/{order_id} → payment_status: pending | paid | failed | expired
Closing the page or hitting return_url is never proof. Dijo activates the
order, and sends order.payment_confirmed, only after it has confirmed
capture with the gateway server to server, then books fulfilment through its Areye Business account with
sales_channel: "areye" and channel_order_reference, so Areye can join the
fulfilment trip to this order.
5. Order retrieval and cancellation
GET /orders/{order_id}: commerce status (pending_payment,paid,accepted,in_fulfilment,completed,cancelled,refunded), lines with their current outcome, refunds, the receipt link, and the fulfilment trip reference once booked.POST /orders/{order_id}/cancel{ "reason": "…" }: Dijo decides whether it is allowed and what is refunded, and answers with the result.
6. Substitution decisions
Dijo stays the order-state authority:
- The Areye shopper proposes; Dijo receives
shopping.substitution_requested(fulfilment v1). - Dijo records it and sends the channel event
order.substitution_decision_requested(order_id,line_id,request_id, proposed name, unit price, quantity,expires_at). - The Areye app asks the customer.
- Areye sends
POST /orders/{order_id}/lines/{line_id}/substitution-decision{ "request_id": "…", "decision": "accept | reject" }. - Dijo confirms with
order.line_changed, then relays the decision to fulfilment as today.
Areye never changes the shopping order itself.
7. Events Dijo → Areye
order.payment_confirmed, order.payment_failed, order.accepted,
order.substitution_decision_requested, order.line_changed,
order.refunded, order.cancelled, order.completed. Each carries order_id,
channel_order_reference and the order's current commerce status. Areye's
backend keeps the source of every status. The customer sees one order screen
that combines Dijo's commerce status with Areye's fulfilment status: shopper,
stores collected, driver, live tracking and proof.
8. Native shopping parity (added 12 Oct 2026)
Areye mirrors the partner's own shopping, catalogue through checkout. Each item below follows the partner's native rule; the partner's validate total is always the authoritative money.
- Store card adds:
schedule_order,schedule_lead_minutes,payment_methods,cod_max_amount,order_types,address,opening_hours,delivery_tiers [{id, name, extra_fee}],deals,rating {average, count},free_delivery_over. - Item card adds:
recommended,store_discount_pct,rating {average, count}. Food items carry no stock count, so for foodavailableis true andmax_qtyis null. - Search inside one shop:
GET /search?q=&lat=&lng=&store_id=(ormall_id=), paginated withcursor/next_cursor. - Goes well with:
GET /items/{id}/frequently-bought-together?limit=returns{products: [item cards]}. - Reviews (read-only):
GET /stores/{id}/reviewsandGET /items/{id}/reviewsreturn{reviews: [{rating, comment, at}], next_cursor}. - Bundles:
GET /stores/{id}/bundleslists them. A basket line may be{bundle_id, qty}instead of an item; the partner prices it. Each bundle and bundle line carriessource_vertical,source_categoryandrestriction: any alcohol item makes the bundle alcohol (18+ check), and a bundle spanning categories is refused (bundle_mixed_module). Areye never offers or sells a bundle without a category. - Validate and order also accept:
schedule_at(ISO; order for later),order_type(delivery|pickup),payment_method(card|cash_on_delivery),delivery_tier_id,order_note(500) andlines[].note(255). Validate returnspayment_methodsandcod {allowed, max_amount, reason}. - Order for later:
GET /stores/{id}/slots?date=YYYY-MM-DDreturns{schedule_order, date, lead_minutes, slot_duration_minutes, windows, slots}. Refusals:order_time(past, or closed at that time),schedule_at(the store takes no orders for later). A scheduled order is booked to Areye as a scheduled trip. - Pickup: no Areye trip and no delivery fee. Readiness comes as status
ready_for_pickup, thencompleted. - Cash on delivery: placed and confirmed with no payment page; the
booking carries
cod_amountand the driver collects. - Delivery speed: a priority tier reaches the Areye booking as
delivery_speed: "priority"(express: offered at once, never combined with another order). The order then showsdelivery_speed_applied,delivery_speed_reasonandpriority_fee. - Refund after delivery:
GET /refund-reasons, thenPOST /orders/{id}/refund-requestwith{customer_reason, customer_note?}, which returnsrefund_status: "pending". The partner reviews it; money moves on the usualorder.refundedevent. - A booking that fails is never silent: the partner flags the order "not with a driver" with the reason, and alerts the store and operations at once.
Tips
Tips are for the driver and belong to Areye's relationship with its drivers, not to the merchant. They never pass through Dijo: validate returns no tip, the Dijo payment session never includes one, and Dijo adds none to channel orders. In the Areye app the customer tips the driver of the fulfilment trip through Areye's own tipping (from the Areye wallet, once per trip, after delivery).
Proven in the end-to-end dry run (11 Oct 2026)
Both partners passed these journeys on an isolated Areye stage with no live drivers and sandbox or demo payments: complete with an ID check, tip and settlement; cancel; substitution ask and answer; refused ID returned to the store. The rules below came out of it, and apply to every partner:
- Field names are exact. Stores use
id, name, logo_url, is_open, opens_at, eta_minutes, delivery_fee, currency, minimum_order, mall_id. Items useid, name, image_url, unit_price, was_price, currency, available, max_qty, has_options. Image and logo URLs are absolute https. All money is decimal strings. - Order creation re-sends the validated basket.
delivery,substitution_preference,mall_id,linesandchannel_referencecome withvalidation_id, so a partner whose validation id is a signed token over the basket can verify it. A validation id may be up to 255 characters. - Events may carry the order fields under
dataor at the top level besideevent. Areye accepts both. - Reply fast, work later. Partner endpoints Areye calls, and webhook
receivers Areye posts to, must answer within about a second and do slow
work (relays, finalisation, refunds) in a job.
- Areye allows partners 8s for reads and 25s for writes.
- Its own outbound webhooks time out at 5s and retry with backoff.
- A lost reply on a decision is re-checked against the order before the customer sees an error.
- Delivery joins the order only when the booking comes from the selling
account (Dijo's account, or the Storefront merchant's own) and carries
sales_channel: "areye"andchannel_order_reference. - Shop-and-deliver channel orders always carry the full
shoppingblock, single-store included. Without it the shopper has no pick list. - Alcohol:
age_confirmedplusage_verification_requiredon any basket with an alcohol line. The driver cannot complete without the ID photo and signature. A refused check returns the goods, and the partner cancels and refunds.
Out of scope for v1
Areye-wallet payment for Dijo goods (needs a collection-agent agreement), cross-listing of Storefront merchants on Dijo, and per-store Areye accounts.