Skip to content

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

  1. Environments & base URLs
  2. Conventions
  3. Authentication
  4. Roles
  5. User profile & addresses
  6. Browsing markets & products
  7. Wishlist
  8. Cart & checkout
  9. Payments (Paystack)
  10. Orders
  11. Shopper flow
  12. Rider flow
  13. Real-time WebSockets
  14. Image uploads
  15. Product reviews
  16. Push & email
  17. Ratings
  18. Complaints & support
  19. Admin (high level)
  20. Error reference
  21. 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:

GET https://api.easypicks.app/api/v1/users/me

Conventions

Response envelope

Successful JSON responses are wrapped by middleware:

{
  "success": true,
  "message": "Logged in successfully",
  "data": { /* endpoint payload */ }
}
  • Read data for the resource.
  • Show message as a toast when useful.
  • Swagger documents the inner data type only.

Error envelope

{
  "success": false,
  "message": "Validation failed",
  "errors": [ /* optional; mainly 422 */ ]
}

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_at on 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: customer only. 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

POST /api/v1/auth/login
{ "phone": "+233244123456", "password": "..." }

→ 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

  1. In the Clerk Dashboard for this instance (clerk.easypicks.app or your Frontend API host): enable Native API (required for Expo / React Native — without it, GET /v1/client?_is_native=1 returns native_api_disabled). Also enable Google / Apple / email as sign-in methods.
  2. Configure the app with that instance’s publishable key (and matching Frontend API URL).
  3. After the user signs in with Clerk, call getToken() to obtain a Clerk session JWT.
  4. POST /api/v1/auth/oauth/clerk with that token (optional picture if you already have the Clerk image URL).
  5. Store access_token / refresh_token like normal login; use Authorization: Bearer <access_token> for API calls.
  6. If phone_required is true, 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:

POST /api/v1/cart/request-verification
Authorization: Bearer ...

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

Authorization: Bearer <access_token>
Token Default TTL
Access ~15 minutes (ACCESS_TOKEN_TTL_MINUTES)
Refresh ~30 days
POST /api/v1/auth/refresh
{ "refresh_token": "..." }

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):

PATCH /api/v1/users/me
Authorization: Bearer ...
{ "email": "new@example.com" }
  • New address must not already be in use
  • email_verified becomes false
  • 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):

DELETE /api/v1/users/me
Authorization: Bearer <access_token>

Soft delete. The user row stays, but:

  • deleted_at is set and is_active becomes false
  • Password login returns 403 Account deleted
  • Clerk sign-in for that linked customer returns 403 Account 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}/enable cannot 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_ghs is the lowest available variant price (computed).
  • Product-level price_estimate_ghs is 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)

POST /api/v1/cart/items
Authorization: Bearer ...

{ "variant_id": "...", "qty": 2 }

Breaking vs early MVP: cart uses variant_id, not product_id.

Get cart

GET /api/v1/cart

Each line includes:

  • Nested product (with category_name, variants)
  • Nested variant
  • Top-level unit (from variant)
  • Top-level category_name
  • qty, line_estimate, subtotal_estimate on 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:

  1. Checkout → open authorization_url
  2. On return (deeplink or foreground), call GET /payments/verify/{reference} (or poll status)
  3. 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)

wss://api.easypicks.app/api/v1/shopper/ws/queue?token=<access_token>

Requires shopper role + verified phone. On connect and after every order status change affecting this shopper:

{
  "type": "queue.snapshot",
  "role": "shopper",
  "orders": [ /* OrderListItem[] */ ]
}

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)

wss://api.easypicks.app/api/v1/rider/ws/queue?token=<access_token>

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

wss://…/api/v1/ws/orders/{order_id}/chat?token=…

Send: { "body": "Hello", "attachment_url": null }
Receive: { "type": "chat.message", ... }

REST history:

GET  /orders/{id}/messages
POST /orders/{id}/messages

Complaint & support threads

wss://…/api/v1/ws/complaints/{id}?token=…
wss://…/api/v1/ws/support/{id}?token=…

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

wss://…/api/v1/ws/orders/{order_id}/tracking?token=…
  • 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):

wss://…/api/v1/admin/ws/orders/{order_id}/tracking?token=<admin_access_token>

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 include variants upsert list:
  • { "id": "…", ... } → update
  • no id → create
  • omitted variants are not deleted (use variant DELETE endpoints)

Variant CRUD:

GET|POST   /admin/products/{id}/variants
PATCH|DELETE /admin/products/{id}/variants/{variant_id}

Media helper

POST /api/v1/media/upload
{ "image_base64": "...", "folder": "optional" }

→ 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()):

POST /users/me/device-token
{ "token": "ExponentPushToken[…]", "platform": "android" | "ios" }

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>):

wss://…/api/v1/ws/complaints/{id}?token=…
wss://…/api/v1/ws/support/{id}?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 paid with no shopper → links shopper, status assigned_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; also POST /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

  1. Open <API_BASE>/docs → Authorize with access token → Try it out.
  2. Import OpenAPI into Postman: <API_BASE>/openapi.json.
  3. Generate clients:
npx openapi-typescript https://api.easypicks.app/openapi.json -o src/api/types.d.ts

Questions?

  • Endpoints: OpenAPI is ground truth.
  • Patterns & gotchas: this guide.
  • Gaps/bugs: GitHub issues or backend team.