Easypicks API — Integration Guide¶
Backend for the Easypicks marketplace: market shopping, product variants, cart/checkout (Paystack), shopper picking, rider delivery, real-time chat & tracking, wishlist, reviews, and more.
This guide is for frontend (web), mobile, and admin teams integrating against the API. It covers auth, envelopes, main flows, WebSockets, payments return paths, and image upload conventions.
Not in this guide: server deployment, Docker Compose, Celery, Firebase/Resend ops — see the README.
Interactive reference (always matches the running code):
| Resource | URL |
|---|---|
| Swagger UI | <API_BASE>/docs |
| ReDoc | <API_BASE>/redoc |
| OpenAPI JSON | <API_BASE>/openapi.json |
Where <API_BASE> is e.g. https://api.easypicks.app (production) or your local URL.
Table of Contents¶
- Environments & base URLs
- Conventions
- Authentication
- Roles
- User profile & addresses
- Browsing markets & products
- Wishlist
- Cart & checkout
- Payments (Paystack)
- Orders
- Shopper flow
- Rider flow
- Real-time WebSockets
- Image uploads
- Product reviews
- Push & email
- Ratings
- Complaints & support
- Admin (high level)
- Error reference
- Tooling
Environments & base URLs¶
| Environment | Base URL | Notes |
|---|---|---|
| Production | https://api.easypicks.app |
Primary public API |
| Local | http://localhost:8001 |
Host port when using Docker Compose (api maps 8001:8000) |
All versioned endpoints live under /api/v1. Health: GET /healthz → {"status":"ok"}.
Example:
Conventions¶
Response envelope¶
Successful JSON responses are wrapped by middleware:
- Read
datafor the resource. - Show
messageas a toast when useful. - Swagger documents the inner
datatype only.
Error envelope¶
HTTP status codes¶
| Code | Meaning |
|---|---|
200 / 201 |
OK / Created |
400 |
Semantic bad request (empty cart, etc.) |
401 |
Missing/invalid token |
403 |
Wrong role, phone not verified where required, etc. |
404 |
Not found |
409 |
Conflict (state machine, duplicate) |
422 |
Validation failed — inspect errors |
429 |
Rate limit (e.g. OTP resend cooldown) |
502 |
Upstream (Paystack / Maps / SMS) failed |
503 |
Integration not configured (Cloudinary, Maps, etc.) |
IDs, money, timestamps¶
- IDs: UUID strings.
- Money: GHS as decimal strings (
"15.00"). Never float-math for totals. - Timestamps: ISO 8601 UTC (where returned:
created_at/updated_aton orders, markets, etc.). - Geo:
{ "lat": number, "lng": number }.
Authentication¶
JWT bearer tokens. No cookies/sessions.
Register (customers only)¶
POST /api/v1/auth/register
Content-Type: application/json
{
"phone": "+233244123456",
"full_name": "Ama Owusu",
"password": "at-least-8-characters",
"email": "ama@example.com",
"role": "customer"
}
role:customeronly. Shoppers and riders are invited by an admin (POST /admin/shoppers/invite,POST /admin/riders/invite). Admins use/admin/auth/*. Customers may sign in with Clerk.- Returns
UserOut(no tokens). Then call login. - Customers: phone verification can wait until checkout (see checkout).
- Shoppers and riders cannot use this endpoint. An admin with workforce edit invites them; the temporary password is emailed and they must change it on first login. Phone is marked verified so they can sign in.
Login¶
→ access_token, refresh_token, token_type: "bearer".
Shopper/rider cannot log in until phone is verified (403 with verify instructions). Admin invites mark the phone verified so they can sign in with the emailed password.
Clerk sign-in (customers only)¶
Shoppers and riders are created by admins and sign in with phone + password. Customers use Clerk in the mobile app (Google / Apple / email via Clerk UI), then exchange the Clerk session JWT for Easypicks JWTs.
Important: This API never accepts Google or Apple ID tokens. Configure Google/Apple (and email) in the Clerk dashboard, sign the user in with the Clerk SDK, then send only the Clerk session token from getToken() to Easypicks. Google and Apple are Clerk sign-in methods, not separate Easypicks OAuth providers.
Mobile flow¶
- In the Clerk Dashboard for this instance (
clerk.easypicks.appor your Frontend API host): enable Native API (required for Expo / React Native — without it,GET /v1/client?_is_native=1returnsnative_api_disabled). Also enable Google / Apple / email as sign-in methods. - Configure the app with that instance’s publishable key (and matching Frontend API URL).
- After the user signs in with Clerk, call
getToken()to obtain a Clerk session JWT. POST /api/v1/auth/oauth/clerkwith that token (optionalpictureif you already have the Clerk image URL).- Store
access_token/refresh_tokenlike normal login; useAuthorization: Bearer <access_token>for API calls. - If
phone_requiredistrue, link and verify a phone before checkout.
POST /api/v1/auth/oauth/clerk
Content-Type: application/json
{
"session_token": "<from Clerk getToken()>",
"picture": "<optional Clerk image URL>"
}
→ Same token pair as login, plus phone_required, avatar_url, and full_name. Profile photo is saved to Cloudinary when configured.
Example (Expo / React Native with @clerk/clerk-expo — call after Clerk sign-in succeeds):
// Inside a component / hook where useAuth() and useUser() are valid:
const { getToken } = useAuth();
const { user } = useUser();
const sessionToken = await getToken(); // Clerk session JWT — not a Google/Apple ID token
if (!sessionToken) throw new Error("No Clerk session");
const res = await fetch(`${API_BASE}/api/v1/auth/oauth/clerk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_token: sessionToken,
picture: user?.imageUrl ?? undefined,
}),
});
if (!res.ok) throw new Error(await res.text());
const {
access_token,
refresh_token,
phone_required,
avatar_url,
full_name,
} = await res.json();
// Persist access_token / refresh_token; if phone_required, run link-phone + verify-phone.
After Clerk sign-in, link a phone before checkout when phone_required is true:
POST /api/v1/auth/link-phone
Authorization: Bearer <access_token>
{ "phone": "+233244123456" }
POST /api/v1/auth/verify-phone
{ "phone": "+233244123456", "code": "123456" }
Server env:
| Variable | Purpose |
|---|---|
CLERK_SECRET_KEY |
Instance secret used by Clerk verify_token (not a PEM) |
CLERK_ISSUER |
Frontend API URL, no trailing slash (https://….clerk.accounts.dev) |
CLERK_JWT_KEY |
Leave unset. A PEM from another instance rejects native session tokens |
CLERK_AUTHORIZED_PARTIES |
Leave unset. Native tokens have no azp claim |
Phone verification (Wigal SMS)¶
POST /api/v1/auth/verify-phone
{ "phone": "+233244123456", "code": "123456" }
POST /api/v1/auth/resend-verification
{ "phone": "+233244123456" }
Resend has a cooldown (~60s → 429).
Customer pre-checkout helper:
Email verification & password¶
| Method | Path | Notes |
|---|---|---|
POST |
/auth/resend-email-verification |
No auth. Body: email or phone. Sends verify code to the account email. |
POST |
/auth/verify-email |
No auth. Body: { email, code } |
POST |
/auth/forgot-password |
No auth. Body: email or phone. Sends reset code to a verified email. |
POST |
/auth/reset-password |
No auth. Body: { email, code, new_password } |
POST |
/auth/change-password |
Auth required; old + new password |
If the user never verified email and forgot the password: resend verification → verify-email → forgot-password → reset-password.
Admin auth (separate)¶
POST /api/v1/admin/auth/login
POST /api/v1/admin/auth/register // authenticated admin required (first admin is the only exception)
POST /api/v1/admin/auth/change-password
GET /api/v1/admin/auth/me // logged-in admin profile (includes avatar_url)
PATCH /api/v1/admin/auth/me // { full_name?, email?, phone?, image_base64? }
POST /admin/auth/register body: { phone, full_name, email, temporary_password?, image_base64? }.
If temporary_password is omitted, one is generated. The password is emailed to email (never returned in the API). Login returns must_change_password: true until POST /admin/auth/change-password.
Tokens¶
| Token | Default TTL |
|---|---|
| Access | ~15 minutes (ACCESS_TOKEN_TTL_MINUTES) |
| Refresh | ~30 days |
Replace both tokens on refresh. POST /auth/logout is client-side drop (JWT is stateless).
Roles¶
| Role | Typical endpoints |
|---|---|
customer |
/cart, /wishlist, /orders, /addresses, /markets, /products |
shopper |
/shopper/* (queue WS, history, pick, handover) |
rider |
/rider/* (queue WS, history, pickup → deliver) |
admin |
/admin/* |
Wrong role → 403.
User profile & addresses¶
Profile¶
| Method | Path |
|---|---|
GET / PATCH |
/users/me |
DELETE |
/users/me — soft-delete the signed-in customer, shopper, or rider |
POST |
/users/me/device-token — FCM { token, platform } |
GET / PATCH |
/users/me/shopper, /users/me/rider |
UserOut includes phone_verified, email_verified, avatar_url (set via base64 on profile patch).
Change email (logged in):
- New address must not already be in use
email_verifiedbecomesfalse- A verification code is emailed to the new inbox
- Confirm with
POST /auth/verify-email{ "email": "new@example.com", "code": "..." }(no login required)
Admins: same via PATCH /admin/auth/me.
Delete account (customer, shopper, or rider):
Soft delete. The user row stays, but:
deleted_atis set andis_activebecomesfalse- Password login returns
403Account deleted - Clerk sign-in for that linked customer returns
403Account deleted - Refresh and any authenticated call (including WebSockets) fail, so the account cannot place orders, shop, deliver, or update a profile
- Shopper/rider presence is set offline and device tokens are removed
- Admin
POST /admin/users/{id}/enablecannot restore a deleted account
Admins cannot delete themselves on this endpoint.
Saved addresses¶
| Method | Path |
|---|---|
GET / POST |
/addresses |
GET / PATCH / DELETE |
/addresses/{address_id} |
Create/update body fields: label, address, location, is_default.
Checkout can use saved_address_id instead of free-text address.
Geocoding (public)¶
No auth. Rate-limited by IP. Cached in Redis.
| Method | Path |
|---|---|
POST |
/geo/geocode — { "address": "..." } |
POST |
/geo/reverse-geocode — { "location": { "lat", "lng" } } |
Checkout distance, /orders/{id}/eta, and /route also use LocationIQ (auth still required for those).
Browsing markets & products¶
Public (no auth for reads):
| Method | Path | Notes |
|---|---|---|
GET |
/markets |
Active markets |
GET |
/markets/{id} |
Detail includes created_at, updated_at, location |
GET |
/markets/{id}/categories |
Ordered by sort_order |
GET |
/markets/{id}/products |
Available products |
GET |
/products |
Filter: market_id, category_id, q |
GET |
/products/{id} |
Full product |
Product payload highlights¶
{
"id": "...",
"market_id": "...",
"category_id": "...",
"category_name": "Vegetables",
"name": "Tomato",
"photo_url": "https://...",
"price_estimate_ghs": "10.00",
"price_from_ghs": "8.00",
"rating_avg": 4.5,
"reviews_count": 12,
"variants": [
{
"id": "...",
"label": "1kg",
"unit": "kg",
"price_estimate_ghs": "8.00",
"is_available": true,
"photo_url": "https://..."
}
]
}
- Sellable unit of inventory is the variant, not the bare product.
price_from_ghsis the lowest available variant price (computed).- Product-level
price_estimate_ghsis a display/default value kept in sync with variants admin-side.
Wishlist¶
Customer auth required.
| Method | Path |
|---|---|
GET |
/wishlist |
POST |
/wishlist/items — { "product_id": "..." } |
DELETE |
/wishlist/items/{item_id} |
DELETE |
/wishlist/items/by-product/{product_id} |
DELETE |
/wishlist — clear all |
Items include nested product (+ variants).
Cart & checkout¶
One cart per customer; single market per cart.
Add item (variant-based)¶
Breaking vs early MVP: cart uses
variant_id, notproduct_id.
Get cart¶
Each line includes:
- Nested
product(withcategory_name, variants) - Nested
variant - Top-level
unit(from variant) - Top-level
category_name qty,line_estimate,subtotal_estimateon the cart
Other cart ops¶
| Method | Path |
|---|---|
PATCH |
/cart/items/{item_id} — { "qty": n } |
DELETE |
/cart/items/{item_id} |
DELETE |
/cart — clear |
Quote (fee preview)¶
Call this when the customer picks a delivery address, before checkout. Same fee formula as checkout; no order and no payment.
POST /api/v1/cart/quote
Authorization: Bearer ...
{
"delivery_address": "House 12, Spintex Rd",
"delivery_location": { "lat": 5.6037, "lng": -0.1870 },
"saved_address_id": null
}
Returns subtotal_estimate, service_fee, delivery_fee, total_estimate, plus optional distance_m / duration_s when routing is available. If distance cannot be measured, delivery_fee is the flat fallback.
Checkout¶
POST /api/v1/cart/checkout
Authorization: Bearer ...
{
"delivery_address": "House 12, Spintex Rd",
"delivery_location": { "lat": 5.6037, "lng": -0.1870 },
"saved_address_id": null,
"special_instructions": "Call on arrival",
"callback_url": "easypicks://payments/callback",
"verification_code": "123456"
}
| Field | Notes |
|---|---|
delivery_address or saved_address_id |
One required |
delivery_location |
Preferred for distance-based delivery fee |
callback_url |
Optional. HTTPS → Paystack callback. easypicks:// / exp:// → still uses API HTTPS bridge, then redirects into the app |
verification_code |
Required on first checkout if customer phone not yet verified; otherwise omit |
First checkout without code (unverified phone): sends SMS, returns 403 (Phone verification required…). Client resubmits with verification_code.
Success data:
{
"order_id": "...",
"order_code": "EP-A1B2C3",
"payment_reference": "EP-A1B2C3",
"authorization_url": "https://checkout.paystack.com/...",
"subtotal_estimate": "126.80",
"service_fee": "5.00",
"delivery_fee": "15.70",
"total_estimate": "147.50"
}
Fees: total_estimate = subtotal_estimate + service_fee + delivery_fee. service_fee and the delivery formula (base + per_km * km, min, max km, flat fallback) are admin-editable at GET/PATCH /admin/settings/pricing (.env values are defaults). Cart stays filled through Paystack; it is cleared only after payment succeeds (webhook, GET /payments/verify/{reference}, or admin mark-paid).
Open authorization_url for Paystack.
Payments (Paystack)¶
Confirm payment (mobile-friendly)¶
| Method | Path | Use |
|---|---|---|
GET |
/payments/orders/{order_id}/status |
Poll payment + order status |
GET |
/payments/verify/{reference} |
Active verify against Paystack + mark paid |
POST |
/payments/orders/{order_id}/init |
New Paystack URL if still pending_payment |
GET |
/payments/callback |
Browser bridge after Paystack (HTTPS). 302 → app deeplink |
POST |
/payments/webhook |
Server-to-server only (do not call from app) |
Recommended mobile flow:
- Checkout → open
authorization_url - On return (deeplink or foreground), call
GET /payments/verify/{reference}(or poll status) - Do not rely only on client redirect — webhook also confirms payment
Amounts are GHS decimal throughout the public API (Paystack pesewas handled server-side).
Orders¶
List & detail¶
| Method | Path | Notes |
|---|---|---|
GET |
/orders |
Role-scoped list (customer / shopper / rider history subset) |
GET |
/orders/{id} |
Full detail |
GET |
/orders/{id}/participants |
Peer profiles: customer / shopper / rider (assigned only; includes phone, no email) |
GET |
/orders/{id}/participants/{role} |
One of customer | shopper | rider |
List + detail include created_at, updated_at, and items[] (product + variant, qty, prices, status).
Customer actions¶
| Method | Path |
|---|---|
POST |
/orders/{id}/cancel |
POST |
/orders/{id}/replacement-decision — { item_id, accept } |
POST |
/orders/{id}/confirm-receipt — after delivered → completed |
POST |
/orders/{id}/rate/shopper , /rate/rider — { stars, comment? } |
GET |
/orders/ratings/me |
GET |
/orders/{id}/eta , /route |
Order status machine (summary)¶
pending_payment
→ paid (Paystack success)
→ assigned_to_shopper
→ shopping_in_progress
→ (optional) awaiting_customer_confirm ↔ shopping_in_progress
→ shopping_complete
→ assigned_to_rider
→ picked_up → out_for_delivery → delivered → completed
Cancel / refund: from early statuses (see API errors for exact conflicts)
Shopper flow¶
HTTP¶
| Method | Path | Description |
|---|---|---|
GET |
/shopper/queue |
Active pick list (assigned → shopping → awaiting confirm → shopping_complete until handover) |
GET |
/shopper/history |
All assigned orders (record-keeping); limit/offset |
POST |
/shopper/orders/{id}/accept |
Start shopping |
POST |
/shopper/orders/{id}/fund-requests |
Request purchase float (amount_ghs?, note?) |
GET |
/shopper/fund-requests |
My fund requests (status, order_id, limit, offset) |
GET |
/shopper/orders/{id}/fund-requests |
Requests for one order |
GET |
/shopper/fund-requests/{id} |
One request |
POST |
/shopper/orders/{id}/items/{item_id}/mark |
bought / unavailable (+ optional photo base64, actual price) |
POST |
/shopper/orders/{id}/items/{item_id}/replace |
Propose replacement product |
POST |
/shopper/orders/{id}/complete-shopping |
Ready for handover |
GET |
/shopper/orders/{id}/available-riders |
Nearest available riders (avatar_url, ratings, distance, location) |
POST |
/shopper/orders/{id}/handover |
{ "rider_id": "..." } |
GET / PATCH |
/shopper/profile |
Workforce profile (created on admin invite) |
Customer Paystack payment does not go to the shopper. After the order is assigned (assigned_to_shopper … awaiting_customer_confirm), the shopper requests float to buy the goods. One pending request per order. Amount defaults to the goods subtotal.
POST /api/v1/shopper/orders/{order_id}/fund-requests
{ "amount_ghs": 80.00, "note": "Need cash for Makola stall" }
Admin:
POST /api/v1/admin/fund-requests/{id}/approve
{ "note": "Sent to your MTN", "disbursement_method": "mobile_money", "disbursement_reference": "optional" }
POST /api/v1/admin/fund-requests/{id}/reject
{ "note": "Please wait until tomorrow" }
disbursement_method: bank_transfer | mobile_money | cash (required on approve). After the decision is committed, the shopper gets a push (data.type = fund_request.status, plus fund_request_id, order_id, status, amount_ghs).
Live queue (WebSocket)¶
Requires shopper role + verified phone. On connect and after every order status change affecting this shopper:
HTTP GET /shopper/queue remains for reconnect hydration. Clients may send ping text frames as keep-alive.
Rider flow¶
| Method | Path |
|---|---|
GET |
/rider/queue |
GET |
/rider/history |
POST |
/rider/orders/{id}/pickup |
POST |
/rider/orders/{id}/start-trip |
POST |
/rider/orders/{id}/deliver |
GET |
/orders/{id}/delivery-code |
GET / PATCH |
/rider/profile |
When the rider starts the trip, the API stores a delivery code (not an SMS). The customer reads it in the app and shows it to the rider. The code stays valid for DELIVERY_OTP_TTL_HOURS (default 24), so a long trip does not expire it after 10 minutes. Wigal is not used for this code.
POST /api/v1/rider/orders/{id}/start-trip
Authorization: Bearer <rider_access_token>
GET /api/v1/orders/{id}/delivery-code
Authorization: Bearer <customer_access_token>
POST /api/v1/rider/orders/{id}/deliver
Authorization: Bearer <rider_access_token>
{ "delivery_code": "123456" }
GET /orders/{id}/delivery-code is customer-only and only while the order is out_for_delivery. Riders, shoppers, and admins do not receive the code. Response: { "order_id", "delivery_code" }.
Customer still confirms receipt separately (POST /orders/{id}/confirm-receipt) after delivery.
Live queue (WebSocket)¶
Same snapshot shape with "role": "rider". Active statuses: assigned_to_rider, picked_up, out_for_delivery.
Real-time WebSockets¶
Auth on all WS: ?token=<access_token> (not header). Failed auth → close 1008.
Chat¶
Send: { "body": "Hello", "attachment_url": null }
Receive: { "type": "chat.message", ... }
REST history:
Complaint & support threads¶
Same auth pattern as chat. Reporters receive public messages + status events; staff also receive internal notes. REST message endpoints fan out to the same rooms.
Rider tracking¶
- Publisher: assigned rider only — send
{ "lat", "lng" } - Subscribers: customer / shopper / admin — first message is
tracking.snapshot, then live{ "type": "rider.location", ... }
Admin live delivery tracking¶
REST (admin JWT):
GET /admin/orders/{id}/tracking # map snapshot: rider pin, dropoff, market, is_live
GET /admin/orders/{id}/tracking/history # GPS trail (up to limit points)
GET /admin/orders/{id}/eta
GET /admin/orders/{id}/route
Live stream (admin only; same room as rider publishes):
On connect → tracking.snapshot, then each rider ping → rider.location.
Shopper / rider queues¶
See Shopper flow and Rider flow (/shopper/ws/queue, /rider/ws/queue).
Image uploads¶
Write APIs accept base64 (image_base64, optionally as a data-URI). The API uploads to Cloudinary and returns photo_url / cover_image_url / avatar_url on the resource.
Do not send public URLs on create/update for products/markets/users — those are response-only.
Products (admin)¶
POST /api/v1/admin/products
{
"market_id": "...",
"name": "Tomato",
"image_base64": "<required base64>",
"price_estimate_ghs": "10.00",
"variants": [
{
"label": "1kg",
"unit": "kg",
"price_estimate_ghs": "10.00",
"image_base64": "<optional; falls back to product photo>"
}
]
}
- Product image is required on create.
- Variant images optional; default inherits product photo.
PATCH /admin/products/{id}can includevariantsupsert list:{ "id": "…", ... }→ update- no
id→ create - omitted variants are not deleted (use variant DELETE endpoints)
Variant CRUD:
Media helper¶
→ returns a URL if you need a standalone upload.
Shopper item mark can still attach proof photos via image_base64 on the mark endpoint.
Product reviews¶
Purchase-verified (order delivered/completed and item bought).
| Method | Path |
|---|---|
GET |
/products/{id}/reviews |
GET / PUT / DELETE |
/products/{id}/reviews/me |
GET |
/products/reviews/me |
Body for create/update: { "stars": 1..5, "comment": "..." }.
Product aggregates: rating_avg, reviews_count.
Push & email¶
Expo push¶
Register after login (expo-notifications → getExpoPushTokenAsync()):
Order transitions enqueue pushes (data.type = "order.status", order_id, status).
Email¶
If the user has an email, Resend may send verification, password reset, welcome, and order status messages. No client SDK required to receive.
Ratings¶
After delivered / completed:
POST /orders/{id}/rate/shopper
POST /orders/{id}/rate/rider
{ "stars": 1..5, "comment": "optional" }
Once each; then 409 Already rated.
Complaints & support (users)¶
Customers, shoppers, and riders can file either type. order_id is optional on both.
POST /api/v1/complaints
{ "category": "wrong_item", "subject": "…", "body": "…", "order_id": null }
GET /api/v1/complaints
GET /api/v1/complaints/{id}
POST /api/v1/complaints/{id}/messages
{ "body": "…" }
POST /api/v1/support
{ "category": "account", "subject": "…", "body": "…" }
Statuses: open → in_progress → waiting_on_user → resolved → closed. Staff can open on behalf via POST /admin/complaints / POST /admin/support with reporter_id.
Realtime threads (?token=<access_token>):
Send: { "body": "…", "is_internal": false } (is_internal staff-only).
Receive: { "type": "complaint.message"|"support.message", … } and { "type": "….status", … }.
Reporters join the public room (no internal notes). Staff with disputes / support view join the staff room (all messages).
Admin (high level)¶
All under /api/v1/admin/* with admin JWT (/admin/auth/login).
Useful groups:
| Area | Examples |
|---|---|
| Dashboard | GET /admin/dashboard — lightweight all-time counters (includes pending fund requests + approved float) |
| Analytics | GET /admin/analytics?date_from=&date_to=&market_id= — comprehensive period report (orders, payments, fund requests, workforce, catalog) |
| Markets / categories / products / variants | Full CRUD; product images required on create |
| Customers | GET /admin/customers/{id} (+ addresses); order history GET /admin/customers/{id}/orders |
| Shoppers / riders | Invite POST /admin/shoppers/invite (phone, full_name, email, optional temporary_password, image_base64, shopper_status, current_market_id) and POST /admin/riders/invite (phone, full_name, email, optional temporary_password, image_base64, rider_status, vehicle_type, plate_number); list + get + profile PATCH; assigned order history GET /admin/shoppers/{id}/orders, GET /admin/riders/{id}/orders |
| Users | POST /admin/users (customers only, optional image_base64); PATCH /admin/users/{id} (full_name, email, phone, image_base64, is_active); password reset, phone verify/clear/resend OTP |
| Admins | List + get |
| Orders | List/filter, get detail, assign/reassign shopper (POST …/assign-shopper), assign rider, cancel, refund, force status, unassign staff |
| Pricing | GET / PATCH /admin/settings/pricing — delivery fee formula + service fee (overrides .env defaults) |
| Fund requests | Shopper purchase float: GET /admin/fund-requests, POST …/approve (note + disbursement_method), POST …/reject (note) |
| Complaints | Disputes queue: GET|POST /admin/complaints (?mine=true = assigned to me), assign/status/messages (permission: disputes) |
| Support | Help desk: GET|POST /admin/support (?mine=true = assigned to me), assign/status/messages (permission: support) |
Staff create/update (POST / PATCH /admin/staff/accounts and PATCH /admin/staff/accounts/{account_id}) accepts is_department_head (not for management — they already have full access). Requires staff edit: management, IT, or a full-access override. Anyone with edit can claim a case for themselves. Assigning to another staff member, or clearing the assignee, requires management / full override, or a department head assigning to an active peer in the same department.
Shopper assign / reassign:
POST /api/v1/admin/orders/{order_id}/assign-shopper
Authorization: Bearer <admin_access>
{ "shopper_id": "<uuid>" }
- Assign: order is
paidwith no shopper → links shopper, statusassigned_to_shopper. - Reassign: order already has a shopper (
assigned_to_shopper…shopping_complete) → swaps to the new shopper, frees the old one, keeps current status so work can continue. Rejects if a rider is already linked. | Live tracking |GET /admin/orders/{id}/tracking(+ history/eta/route); WS/admin/ws/orders/{id}/tracking| | Payments |GET /admin/payments, by id/reference, per-order; alsoPOST /payments/admin/orders/{id}/mark-paid| | Moderation |GET|DELETE /admin/reviews…,GET /admin/orders/{id}/messages,DELETE /admin/messages/{id}|
Prefer OpenAPI for full admin request bodies.
Error reference¶
| HTTP | Typical message |
|---|---|
400 |
Cart empty / no market |
401 |
Invalid credentials / bad token |
403 |
Wrong role; phone verification required (login or checkout) |
404 |
Resource not found |
409 |
Phone taken; wrong order transition; market cart conflict; already rated |
422 |
Validation failed (errors[]) |
429 |
OTP resend too soon |
502 |
Wigal / Paystack / Maps failure |
503 |
Cloudinary / Maps not configured |
Tooling¶
- Open
<API_BASE>/docs→ Authorize with access token → Try it out. - Import OpenAPI into Postman:
<API_BASE>/openapi.json. - Generate clients:
Questions?¶
- Endpoints: OpenAPI is ground truth.
- Patterns & gotchas: this guide.
- Gaps/bugs: GitHub issues or backend team.