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
- 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 (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|rideronly. 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¶
→ 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:
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¶
| 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 |
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_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 |
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:
- 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 |
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) |
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)¶
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 / POST / PATCH |
/rider/profile |
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:
Rider tracking¶
- 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 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¶
FCM¶
Register after login:
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¶
- 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.