Skip to content

Easypicks API — Integration Guide

Backend for the Easypicks marketplace: market shopping (MVP), with real-time chat, live rider tracking, Paystack payments, and Cloudinary storage.

This guide is for the frontend (web) and mobile teams integrating against the API. It covers auth, request/response conventions, flows, WebSockets, uploads, and what your client must implement.

Not in this guide: server deployment, env vars, Celery workers, Firebase/Resend dashboard setup — see the README in the repo.

For an always-up-to-date interactive endpoint reference, use the auto-generated Swagger UI and ReDoc:

  • Swagger UI (interactive): <API_BASE>/docs
  • ReDoc (read-only): <API_BASE>/redoc
  • OpenAPI spec (JSON): <API_BASE>/openapi.json

Where <API_BASE> is the deployed root (e.g. https://easypicksapi.onrender.com).


Table of Contents

  1. Environments & base URLs
  2. Conventions
  3. Response envelope
  4. Error envelope
  5. HTTP status codes
  6. Pagination
  7. IDs, money, timestamps
  8. Authentication
  9. Register
  10. Login
  11. Using the access token
  12. Refresh
  13. Logout
  14. Token lifetimes
  15. Roles
  16. User profile
  17. Browsing markets & products
  18. Cart & checkout flow
  19. Shopper flow (pick + handover)
  20. Payments (Paystack)
  21. Order lifecycle
  22. Real-time: chat over WebSocket
  23. Real-time: live rider tracking
  24. Image uploads (Cloudinary signed)
  25. Push notifications (FCM)
  26. Email notifications
  27. Ratings & reviews
  28. Error reference
  29. Tooling: Postman, Swagger, generators

Environments & base URLs

Environment Base URL Notes

| Render (staging) | https://easypicksapi.onrender.com | Free tier — may cold-start (5–10s on first request after idle) |

All versioned endpoints live under /api/v1. Health check lives at the root: GET /healthz{"status": "ok"}.

Concrete request URL example:

GET https://easypicksapi.onrender.com/api/v1/users/me

Conventions

Response envelope

Every successful JSON response is wrapped in a uniform envelope by EnvelopeMiddleware. Endpoints declare their payload as data; the middleware adds success and a human-readable message.

{
  "success": true,
  "message": "Logged in successfully",
  "data": {
    "access_token": "eyJhbGciOi...",
    "refresh_token": "eyJhbGciOi...",
    "token_type": "bearer"
  }
}

Your client code should:

  • Always read data for the payload (not the root)
  • Show message as a toast / snackbar when useful
  • Check success before trusting data

The Swagger schema shows the inner data shape (e.g. TokenPair), not the envelope. That's intentional — response_model documents the data contract; the envelope is applied transparently at the edge.

Error envelope

Failures use the same shape but with success: false and an optional errors field for structured detail (mostly validation errors):

{
  "success": false,
  "message": "Validation failed",
  "errors": [
    {
      "type": "string_too_short",
      "loc": ["body", "password"],
      "msg": "String should have at least 8 characters",
      "input": "abc"
    }
  ]
}

For simple errors (HTTPException(status_code=401, detail="...")), errors is omitted and message carries the detail string.

{ "success": false, "message": "Invalid phone or password" }

HTTP status codes

Code Meaning in this API
200 OK — successful GET/PATCH/DELETE
201 Created — successful POST that created a resource
400 Bad request — semantic error (e.g. empty cart on checkout)
401 Unauthorised — missing/invalid/expired bearer token
403 Forbidden — authenticated but wrong role / not your resource
404 Not found — resource doesn't exist or is hidden from you
409 Conflict — state transition not allowed (e.g. cancel after delivered) or duplicate (phone already registered)
422 Validation error — request body failed Pydantic validation; check errors
502 Upstream failed — Paystack / Maps API returned no result
503 Service not configured — e.g. Cloudinary keys missing

Pagination

List endpoints that support pagination accept limit and offset query params:

GET /api/v1/admin/users?role=customer&limit=50&offset=100

For endpoints that return a Page<T> envelope, the shape is:

{
  "items": [ /* ... */ ],
  "total": 1245,
  "limit": 50,
  "offset": 100
}

(Most list endpoints currently return a plain list[T] for simplicity. Page<T> is used where total counts matter, e.g. admin dashboards.)

IDs, money, timestamps

  • All resource IDs are UUIDs (RFC 4122) returned as lowercase strings: "f3a1c2d8-...". Generate them client-side only when explicitly required.
  • Money is Ghanaian Cedi (GHS) represented as decimal strings to avoid float precision loss:
  • "15.00", "2500.50"
  • In TypeScript, parse with a Decimal library (big.js, decimal.js) when doing math; for display, Number(value).toFixed(2) is fine.
  • Timestamps are ISO 8601 with Z (UTC): "2026-06-16T10:32:14.123Z". Display in the user's local timezone.
  • Geo coordinates use {"lat": <number>, "lng": <number>} with lat ∈ [-90, 90] and lng ∈ [-180, 180].

Authentication

The API uses JWT bearer tokens. There are no cookies and no server-side sessions; clients hold tokens and present them on each request.

Register

POST /api/v1/auth/register
Content-Type: application/json

{
  "phone": "+233244123456",
  "full_name": "Ama Owusu",
  "password": "at-least-8-characters",
  "email": "ama@example.com",       // optional
  "role": "customer"                // customer | shopper | rider | admin (default: customer)
}

201 Created

{
  "success": true,
  "message": "Account created successfully",
  "data": {
    "id": "f3a1c2d8-...",
    "phone": "+233244123456",
    "email": "ama@example.com",
    "full_name": "Ama Owusu",
    "role": "customer",
    "is_active": true
  }
}

Validation:

  • phone — 7 to 32 chars (no format enforced; include country code)
  • password — 8 to 128 chars
  • email — optional, must be a valid email if provided
  • role — defaults to customer. Admins should be created via the admin API, not self-registration.

Duplicate phone → 409 Phone already registered.

Note: Registration does not return a token. The client must call POST /auth/login after registration to get tokens.

Login

POST /api/v1/auth/login
Content-Type: application/json

{ "phone": "+233244123456", "password": "..." }

200 OK

{
  "success": true,
  "message": "Logged in successfully",
  "data": {
    "access_token": "eyJhbGciOi...",
    "refresh_token": "eyJhbGciOi...",
    "token_type": "bearer"
  }
}

Errors: - 401 Invalid phone or password - 403 Account disabled — user has been deactivated by admin

Using the access token

Send Authorization: Bearer <access_token> on every protected request:

GET /api/v1/users/me
Authorization: Bearer eyJhbGciOi...

JavaScript / TypeScript (Axios):

import axios from "axios";

export const api = axios.create({
  baseURL: "https://easypicksapi.onrender.com/api/v1",
});

api.interceptors.request.use((config) => {
  const token = localStorage.getItem("access_token");
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

Mobile (Dart / Flutter — Dio):

final dio = Dio(BaseOptions(
  baseUrl: "https://easypicksapi.onrender.com/api/v1",
));

dio.interceptors.add(InterceptorsWrapper(
  onRequest: (options, handler) async {
    final token = await secureStorage.read(key: "access_token");
    if (token != null) options.headers["Authorization"] = "Bearer $token";
    handler.next(options);
  },
));

curl:

curl -H "Authorization: Bearer eyJhbGciOi..." \
  https://easypicksapi.onrender.com/api/v1/users/me

Refresh

When the access token expires (401 Could not validate credentials), exchange the refresh token for a new pair:

POST /api/v1/auth/refresh
Content-Type: application/json

{ "refresh_token": "eyJhbGciOi..." }

200 OK — returns the same TokenPair shape as login. Replace both tokens on the client (the API issues a fresh refresh token too).

Recommended client pattern: an Axios/Dio response interceptor that on 401 automatically calls /auth/refresh, stores the new tokens, and retries the original request once. If the refresh itself returns 401, redirect the user to login.

Logout

POST /api/v1/auth/logout
Authorization: Bearer eyJhbGciOi...

Returns {"detail": "logged out"}. JWTs are stateless — the server doesn't track tokens. Logout is a client-side operation: drop both tokens from storage. The endpoint exists for symmetry and audit logging.

Token lifetimes

Token Default TTL Configurable via
access_token 60 minutes ACCESS_TOKEN_TTL_MINUTES
refresh_token 30 days REFRESH_TOKEN_TTL_DAYS

Tokens are signed HS256 JWTs with claims {sub: user_uuid, iat, exp, type: "access"|"refresh"}. Don't try to parse claims for business logic — call GET /users/me instead.


Roles

The role enum drives authorization across the API:

Role What they do Common endpoints
customer Browse markets, build carts, place & track orders /cart, /orders, /markets
shopper In-store personnel who pick items for assigned orders /shopper/*, /users/me/shopper
rider Delivery personnel who pick up and deliver /rider/*, /users/me/rider
admin Full platform management /admin/*

Endpoints check role via dependencies. Wrong-role calls return 403 Forbidden: requires role in [...]. A shopper trying to hit /cart will get 403, etc.


User profile

Method Path Description
GET /users/me Current user info
PATCH /users/me Update full_name, email
POST /users/me/device-token Register FCM device token for push notifications
GET /users/me/shopper Shopper-only profile (status, rating, current market)
PATCH /users/me/shopper Update shopper status / current market
GET /users/me/rider Rider-only profile (status, vehicle, plate, rating)
PATCH /users/me/rider Update rider status, vehicle info, last known location

Browsing markets & products

Public catalogue endpoints (no auth required for read):

Method Path Description
GET /markets List active markets
GET /markets/{market_id} Market detail
GET /markets/{market_id}/categories Categories within a market
GET /markets/{market_id}/products Products within a market

Full schemas and query params: see Swagger.


Cart & checkout flow

The cart is server-side and per-customer — one cart per user, scoped to a single market at a time.

1. Add items

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

{ "product_id": "...", "qty": 3 }

Constraint: All cart items must come from the same market. Adding a product from a different market returns:

{
  "success": false,
  "message": "Cart already contains items from a different market. Clear it first."
}

→ Frontend should prompt the user: "Clear cart and switch to ?" and call DELETE /api/v1/cart if confirmed.

2. Get cart

GET /api/v1/cart
Authorization: Bearer ...

Returns items with product details and a running subtotal_estimate.

3. Checkout

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

{
  "delivery_address": "House 12, Spintex Rd, Accra",
  "delivery_location": { "lat": 5.6037, "lng": -0.1870 },
  "special_instructions": "Ring the bell, don't knock.",
  "callback_url": "https://my-frontend.com/payments/return"   // optional
}

200 OK

{
  "success": true,
  "message": "Checkout initiated",
  "data": {
    "order_id": "ord-uuid",
    "order_code": "EP-A1B2C3",
    "payment_reference": "EP-A1B2C3",
    "authorization_url": "https://checkout.paystack.com/abc123",
    "total_estimate": "147.50"
  }
}

What happens server-side:

  1. Validates cart isn't empty
  2. Calculates subtotal + service_fee + delivery_fee (delivery fee is distance-based via Google Distance Matrix; falls back to flat fee if no coords)
  3. Creates an Order in status pending_payment
  4. Creates a Payment row (status pending) and calls Paystack to initialize a transaction
  5. Empties the cart
  6. Returns authorization_urlredirect the user to it to pay

After payment, Paystack: - Redirects the user to callback_url (or PAYSTACK_CALLBACK_URL from env) - Calls our /payments/webhook server-to-server with charge.success → flips the order to paid and assigns a shopper

4. After payment (customer)

Poll GET /api/v1/orders/{order_id} to check status. The frontend can also show the order's progress through the order lifecycle.


Shopper flow (pick + handover)

Method Path Description
GET /shopper/queue Orders assigned to this shopper
POST /shopper/orders/{id}/accept Start shopping
POST /shopper/orders/{id}/items/{item_id}/mark Mark item bought / unavailable
POST /shopper/orders/{id}/items/{item_id}/replace Propose replacement (awaits customer)
POST /shopper/orders/{id}/complete-shopping Finish picking — ready for rider handover
GET /shopper/orders/{id}/available-riders List riders the shopper can pick (nearest first)
POST /shopper/orders/{id}/handover Hand over to a chosen rider

Choosing a rider at handover

  1. Complete shopping: POST /shopper/orders/{id}/complete-shopping
  2. List riders near the market:
GET /api/v1/shopper/orders/{order_id}/available-riders
Authorization: Bearer <shopper_token>

200 OK

{
  "success": true,
  "message": "Request successful",
  "data": [
    {
      "user_id": "rider-uuid",
      "full_name": "Kofi Mensah",
      "vehicle_type": "motorbike",
      "plate_number": "GR-1234-21",
      "rating_avg": 4.8,
      "ratings_count": 120,
      "distance_m": 842.5,
      "location": { "lat": 5.6037, "lng": -0.1870 }
    }
  ]
}

Only riders with status: available appear. Sorted by distance to the order's market (riders without GPS last). Empty list [] means no riders online — show "try again shortly" in the app.

  1. Shopper picks one and hands over:
POST /api/v1/shopper/orders/{order_id}/handover
Authorization: Bearer <shopper_token>

{ "rider_id": "rider-uuid" }

Success: order moves to assigned_to_rider, rider gets push/email, shopper goes back to available.

Errors:

Code When
409 Shopping not complete, or chosen rider became unavailable (another order grabbed them)
404 Invalid rider_id

Payments (Paystack)

Init a new payment for a pending order

If the customer abandons the Paystack page and comes back later, re-initialize:

POST /api/v1/payments/orders/{order_id}/init
Authorization: Bearer ...

Returns a fresh authorization_url. Only works while order.status == pending_payment.

Webhook (server-to-server, do not call from client)

POST /api/v1/payments/webhook — Paystack hits this on charge.success. The signature is verified via the x-paystack-signature header. Idempotent (same event_id won't double-process).

Currency

All Paystack amounts are GHS. The integration submits amounts in pesewas (GHS × 100) internally, but the API surface stays in decimal GHS — clients never need to think about pesewas.


Order lifecycle

Orders progress through the OrderStatus enum:

                    ┌─────────────────────┐
                    │   pending_payment   │  ← created by checkout
                    └──────────┬──────────┘
                               │ Paystack charge.success
                    ┌─────────────────────┐
                    │        paid         │  → auto-assigns shopper
                    └──────────┬──────────┘
                    ┌─────────────────────┐
                    │ assigned_to_shopper │
                    └──────────┬──────────┘
                               │ shopper accepts
                    ┌─────────────────────┐
            ┌──────►│ shopping_in_progress│
            │       └──────────┬──────────┘
            │                  │ item replacement needed
            │                  ▼
            │       ┌─────────────────────────────┐
            └───────│ awaiting_customer_confirm   │  ← customer must accept/decline replacement
                    └──────────┬──────────────────┘
                    ┌─────────────────────┐
                    │  shopping_complete  │  → shopper picks rider
                    └──────────┬──────────┘
                               │ shopper handover (chosen rider_id)
                    ┌─────────────────────┐
                    │  assigned_to_rider  │
                    └──────────┬──────────┘
                    ┌─────────────────────┐
                    │      picked_up      │  ← rider has the items
                    └──────────┬──────────┘
                    ┌─────────────────────┐
                    │  out_for_delivery   │
                    └──────────┬──────────┘
                    ┌─────────────────────┐
                    │      delivered      │  ← rider marks delivered
                    └──────────┬──────────┘
                               │ customer confirms receipt
                    ┌─────────────────────┐
                    │      completed      │  ✓ ratings now allowed
                    └─────────────────────┘

Cancellation paths:
  pending_payment, awaiting_customer_confirm → cancelled  (by customer)
  paid                                       → refund_pending → refunded

Customer-facing order endpoints

Method Path Description
GET /orders My orders (filtered by role automatically)
GET /orders/{id} Order detail with items
POST /orders/{id}/cancel Cancel (only valid from certain statuses)
POST /orders/{id}/replacement-decision Accept/decline a replacement for an out-of-stock item
POST /orders/{id}/confirm-receipt Customer confirms delivery → status → completed
POST /orders/{id}/rate/shopper Rate shopper 1–5 stars (after delivered/completed)
POST /orders/{id}/rate/rider Rate rider 1–5 stars (after delivered/completed)
GET /orders/{id}/eta Lightweight ETA + distance (cached when possible)
GET /orders/{id}/route Full route with encoded polyline for drawing the line on the map

Real-time: chat over WebSocket

Per-order chat lets the customer, assigned shopper, assigned rider, and admins talk in one room.

Connect

ws://localhost:8000/api/v1/ws/orders/{order_id}/chat?token=<access_token>
wss://easypicksapi.onrender.com/api/v1/ws/orders/{order_id}/chat?token=<access_token>

Use wss:// (TLS) in production. Auth happens via the ?token= query param, not the header (browsers can't set headers on WebSocket handshake).

On connection failure:

  • 4001 / closed immediately → invalid token, user not allowed to see this order, or order doesn't exist (server uses WS code 1008 POLICY_VIOLATION)

Send a message

Send a JSON text frame:

{ "body": "Hello!", "attachment_url": null }

Empty bodies are silently ignored.

Receive messages

Every connected client (including the sender) receives:

{
  "type": "chat.message",
  "id": "msg-uuid",
  "order_id": "order-uuid",
  "sender_id": "user-uuid",
  "body": "Hello!",
  "attachment_url": null,
  "created_at": "2026-06-16T10:32:14.123Z"
}

REST fallback / history

GET /api/v1/orders/{order_id}/messages?limit=200
POST /api/v1/orders/{order_id}/messages   { "body": "...", "attachment_url": null }

Posting via REST also broadcasts to all subscribed WS clients. Use REST for sending if you haven't connected the WS yet; use WS for live receive.

Example: browser WebSocket

const accessToken = localStorage.getItem("access_token")!;
const ws = new WebSocket(
  `wss://easypicksapi.onrender.com/api/v1/ws/orders/${orderId}/chat?token=${accessToken}`
);

ws.onopen = () => console.log("chat connected");
ws.onmessage = (e) => {
  const msg = JSON.parse(e.data);
  if (msg.type === "chat.message") appendMessage(msg);
};
ws.onclose = (e) => {
  if (e.code === 1008) console.error("auth failed");
};

// Send
ws.send(JSON.stringify({ body: "Hello", attachment_url: null }));

Real-time: live rider tracking

Same room model as chat, but for lat/lng updates.

Connect

wss://easypicksapi.onrender.com/api/v1/ws/orders/{order_id}/tracking?token=<access_token>

Who can publish vs subscribe

  • Publisher: only the assigned rider (user.role == rider AND order.rider_id == user.id). Send location updates as JSON: {"lat": 5.6037, "lng": -0.1870}. Server persists every point to rider_locations for replay and broadcasts to all subscribers.
  • Subscribers (read-only): customer who placed the order, assigned shopper, admins. They receive broadcasts but cannot send.

Non-rider clients that send data are ignored — only receive_text is awaited on subscribers.

Receive

{
  "type": "rider.location",
  "order_id": "order-uuid",
  "rider_id": "rider-uuid",
  "lat": 5.6037,
  "lng": -0.1870,
  "ts": "2026-06-16T10:32:14.123Z"
}

Rider-side example (mobile)

// Dart / Flutter
final channel = WebSocketChannel.connect(
  Uri.parse("wss://easypicksapi.onrender.com/api/v1/ws/orders/$orderId/tracking?token=$accessToken"),
);

// Publish location every 5 seconds
Timer.periodic(Duration(seconds: 5), (_) async {
  final pos = await Geolocator.getCurrentPosition();
  channel.sink.add(jsonEncode({"lat": pos.latitude, "lng": pos.longitude}));
});

Customer-side example (frontend)

const ws = new WebSocket(
  `wss://easypicksapi.onrender.com/api/v1/ws/orders/${orderId}/tracking?token=${token}`
);

ws.onmessage = (e) => {
  const evt = JSON.parse(e.data);
  if (evt.type === "rider.location") {
    map.setMarker({ lat: evt.lat, lng: evt.lng });
  }
};

Image uploads (Cloudinary signed)

We use client-direct uploads to Cloudinary so the API server never proxies image bytes (faster, cheaper). The pattern is two steps:

Step 1 — Ask the API for a signed payload

For a new product (admin):

POST /api/v1/admin/products/upload-image
Authorization: Bearer <admin_token>

Or for re-uploading an existing product's image:

POST /api/v1/admin/products/{product_id}/upload-image

Both return:

{
  "success": true,
  "message": "Cloudinary upload signature",
  "data": {
    "cloud_name": "your-cloud",
    "api_key": "123456789012345",
    "timestamp": 1718539200,
    "signature": "abc...sha1",
    "folder": "easypicks/products",
    "public_id": "rand-uuid-here",
    "upload_url": "https://api.cloudinary.com/v1_1/your-cloud/auto/upload"
  }
}

Equivalent endpoints exist for markets: - POST /api/v1/admin/markets/upload-cover (pre-create) - POST /api/v1/admin/markets/{market_id}/upload-cover (re-upload)

Step 2 — Upload the file directly to Cloudinary

POST multipart/form-data to upload_url with all the returned params (api_key, timestamp, signature, folder, public_id) plus the file. Cloudinary responds with secure_url:

{
  "secure_url": "https://res.cloudinary.com/your-cloud/image/upload/v1718.../products/abc.jpg",
  "public_id": "easypicks/products/abc",
  /* ... */
}

Step 3 — Submit the URL with your create/update call

POST /api/v1/admin/products
Authorization: Bearer <admin_token>

{
  "market_id": "...",
  "name": "Plantain (bundle of 5)",
  "price_estimate_ghs": "12.50",
  "photo_url": "https://res.cloudinary.com/your-cloud/.../products/abc.jpg",
  /* ... */
}

Frontend example (browser FormData)

async function uploadProductImage(file: File) {
  const sig = await api.post("/admin/products/upload-image").then(r => r.data.data);

  const fd = new FormData();
  fd.append("file", file);
  fd.append("api_key", sig.api_key);
  fd.append("timestamp", String(sig.timestamp));
  fd.append("signature", sig.signature);
  fd.append("folder", sig.folder);
  fd.append("public_id", sig.public_id);

  const cloudinaryRes = await fetch(sig.upload_url, { method: "POST", body: fd });
  const { secure_url } = await cloudinaryRes.json();
  return secure_url;
}

Errors

  • 503 Image storage is not configuredCLOUDINARY_* env vars missing on the API server. Tell ops to set them.
  • Cloudinary 4xx during step 2 → usually clock skew or expired signature. Re-request the signature (step 1) and retry.

Push notifications (FCM)

Mobile apps receive push notifications when order status changes. The mobile team integrates Firebase in the app; the backend team configures FCM on the server (see README).

1. What your app must do

  1. Add Firebase to the Android/iOS app (google-services.json / GoogleService-Info.plist).
  2. Request notification permission from the user.
  3. Obtain the FCM device token from the Firebase SDK.
  4. Register it with the API after every login (and whenever the token refreshes):
POST /api/v1/users/me/device-token
Authorization: Bearer ...

{ "token": "fcm-device-token", "platform": "android" }

Platforms: "android", "ios". Duplicate tokens for the same user are silently deduped.

  1. Handle incoming notifications in the app (foreground + background):
  2. Read the data payload (see below).
  3. Navigate to the correct screen (order detail, shopper queue, rider queue, etc.).

Without device-token registration and notification handlers, the user will not see pushes even when the backend is configured correctly.

2. When pushes are sent

Every successful transition() on an order enqueues FCM jobs for the relevant parties:

New status Customer Shopper Rider
paid ✅ Payment received
assigned_to_shopper ✅ Shopper assigned ✅ New order
awaiting_customer_confirm ✅ Action needed
assigned_to_rider ✅ Rider assigned ✅ Rider on the way ✅ New delivery
picked_up ✅ Order picked up
out_for_delivery ✅ Out for delivery
delivered ✅ Delivered
completed ✅ Order complete
cancelled ✅ Order cancelled
refund_pending ✅ Refund pending
refunded ✅ Refund issued

Statuses like shopping_in_progress and shopping_complete do not send pushes (too noisy; the next meaningful event follows shortly).

3. data payload (always string → string)

Every order-status push includes this data map (in addition to notification.title / notification.body):

{
  "type": "order.status",
  "order_id": "f3a1c2d8-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
  "status": "assigned_to_rider"
}

Mobile handling example (Flutter):

FirebaseMessaging.onMessageOpenedApp.listen((message) {
  final data = message.data;
  if (data["type"] == "order.status") {
    final orderId = data["order_id"];
    final status = data["status"];
    navigator.push(OrderDetailRoute(orderId: orderId));
  }
});

Android (Kotlin) — read from intent extras when user taps the notification:

val type = intent.getStringExtra("type")          // "order.status"
val orderId = intent.getStringExtra("order_id")   // UUID string
val status = intent.getStringExtra("status")      // e.g. "assigned_to_rider"

Web clients do not receive pushes today.


Email notifications

If the user has an email on their profile (register or PATCH /users/me), the backend may also send transactional emails for the same order events as push (welcome on signup, status updates).

Frontend / mobile: no SDK or API calls required to receive email — just encourage users to add an email in registration or profile settings as a fallback when push is disabled or unavailable.


Ratings & reviews

After an order reaches delivered or completed, the customer can rate both the shopper and the rider once:

POST /api/v1/orders/{order_id}/rate/shopper
Authorization: Bearer ...

{ "stars": 5, "comment": "Picked everything perfectly." }
POST /api/v1/orders/{order_id}/rate/rider
Authorization: Bearer ...

{ "stars": 4, "comment": "On time, but no helmet." }
  • stars — 1 to 5 (Pydantic-validated)
  • comment — optional text
  • Returns 409 Already rated on second attempt for the same role on the same order
  • Returns 409 Order not yet delivered before delivery

Aggregate rating_avg and ratings_count are auto-updated on the recipient's profile.


Error reference

Common errors you'll see across the API:

HTTP message Likely cause
400 Cart is empty Checkout called with no items
400 Cart has no market Cart was cleared / never set a market
401 Could not validate credentials Missing/invalid bearer token — refresh and retry
401 Invalid phone or password Login failed
401 Invalid refresh token Refresh token expired or tampered — force re-login
403 Forbidden: requires role in [...] Wrong role for this endpoint
403 Account disabled Admin disabled the account
404 Order not found Wrong ID, or user can't view this order
409 Phone already registered Duplicate registration
409 Cart already contains items from a different market Prompt user to clear cart
409 Cannot cancel from status <x> Order is past the cancellable window
409 Already rated Customer tried to rate same role twice
422 Validation failed Look at errors[] — each item has loc, msg, type
502 Maps service returned no result Google Maps API issue — retry or fall back to cached ETA
503 Image storage is not configured Cloudinary env vars missing on server
503 Maps service is not configured GOOGLE_MAPS_API_KEY missing on server

Tooling: Swagger, generators

Swagger UI

Open <API_BASE>/docs in a browser. Click Authorize → paste your access_token → "Try it out" any endpoint.

Type-safe clients

You can generate fully-typed client SDKs from <API_BASE>/openapi.json:

Language Generator
TypeScript openapi-typescript + openapi-fetch
Dart / Flutter openapi-generator with --generator-name dart-dio
Kotlin (Android) openapi-generator with --generator-name kotlin
Swift (iOS) openapi-generator with --generator-name swift5

Example (TypeScript):

npx openapi-typescript https://easypicksapi.onrender.com/openapi.json -o src/api/types.d.ts

Then call endpoints with full type-safety:

import createClient from "openapi-fetch";
import type { paths } from "./api/types";

const api = createClient<paths>({ baseUrl: "https://easypicksapi.onrender.com/api/v1" });

const { data, error } = await api.GET("/users/me");
// `data` is typed as the UserOut envelope

Questions?

  • For endpoint-level questions, check <API_BASE>/docs first — the OpenAPI is the source of truth and is always in sync with the code.
  • For integration patterns and gotchas, this guide.
  • Anything missing or wrong here? Open an issue at https://github.com/Sottie1/easypicksapi/issues or ping the backend team.