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. Admin (high level)
  19. Error reference
  20. 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 (customer / shopper / rider)

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 | shopper | rider only. Admins use /admin/auth/*.
  • Returns UserOut (no tokens). Then call login.
  • Shopper/rider: SMS OTP may be sent on register (Wigal). Phone verification is required before those roles can use protected workforce endpoints.
  • Customers: phone verification can wait until checkout (see checkout).

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

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/verify-email Body: email + code
POST /auth/resend-email-verification Body: email
POST /auth/forgot-password Body: email or phone
POST /auth/reset-password Body: code + new password
POST /auth/change-password Auth required; old + new password

Admin auth (separate)

POST /api/v1/admin/auth/login
POST /api/v1/admin/auth/register   // may require ADMIN_BOOTSTRAP_SECRET after first admin

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

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.


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

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/...",
  "total_estimate": "147.50"
}

Fees: subtotal + service_fee + delivery_fee (distance-based via Google when possible; min delivery fee applies server-side). Cart is emptied after order creation.

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

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 deliveredcompleted
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)
GET /shopper/history All assigned orders (record-keeping); limit/offset
POST /shopper/orders/{id}/accept Start shopping
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
POST /shopper/orders/{id}/handover { "rider_id": "..." }
GET / POST / PATCH /shopper/profile Workforce profile

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 / POST / PATCH /rider/profile

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

Rider tracking

wss://…/api/v1/ws/orders/{order_id}/tracking?token=…
  • Publisher: assigned rider only — send { "lat", "lng" }
  • Subscribers: customer / shopper / admin — receive { "type": "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

FCM

Register after login:

POST /users/me/device-token
{ "token": "...", "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.


Admin (high level)

All under /api/v1/admin/* with admin JWT (/admin/auth/login).

Useful groups:

Area Examples
Markets / categories / products / variants Full CRUD; product images required on create
Customers GET /admin/customers/{id} includes addresses[]
Shoppers / riders / admins List + get with role profiles
Orders List (filter by status / unassigned), get detail, assign shopper/rider
Payments POST /payments/admin/orders/{id}/mark-paid (admin path)

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>/docsAuthorize 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.