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¶
- Environments & base URLs
- Conventions
- Response envelope
- Error envelope
- HTTP status codes
- Pagination
- IDs, money, timestamps
- Authentication
- Register
- Login
- Using the access token
- Refresh
- Logout
- Token lifetimes
- Roles
- User profile
- Browsing markets & products
- Cart & checkout flow
- Shopper flow (pick + handover)
- Payments (Paystack)
- Order lifecycle
- Real-time: chat over WebSocket
- Real-time: live rider tracking
- Image uploads (Cloudinary signed)
- Push notifications (FCM)
- Email notifications
- Ratings & reviews
- Error reference
- 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:
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
datafor the payload (not the root) - Show
messageas a toast / snackbar when useful - Check
successbefore trustingdata
The Swagger schema shows the inner
datashape (e.g.TokenPair), not the envelope. That's intentional —response_modeldocuments 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.
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:
For endpoints that return a Page<T> envelope, the shape is:
(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>}withlat ∈ [-90, 90]andlng ∈ [-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 charsemail— optional, must be a valid email if providedrole— defaults tocustomer. 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/loginafter 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:
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:
Refresh¶
When the access token expires (401 Could not validate credentials), exchange the refresh token for a new pair:
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¶
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¶
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 DELETE /api/v1/cart if confirmed.
2. Get cart¶
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:
- Validates cart isn't empty
- Calculates
subtotal + service_fee + delivery_fee(delivery fee is distance-based via Google Distance Matrix; falls back to flat fee if no coords) - Creates an
Orderin statuspending_payment - Creates a
Paymentrow (statuspending) and calls Paystack to initialize a transaction - Empties the cart
- Returns
authorization_url— redirect 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¶
- Complete shopping:
POST /shopper/orders/{id}/complete-shopping - List riders near the market:
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.
- 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:
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 code1008 POLICY_VIOLATION)
Send a message¶
Send a JSON text frame:
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¶
Who can publish vs subscribe¶
- Publisher: only the assigned rider (
user.role == riderANDorder.rider_id == user.id). Send location updates as JSON:{"lat": 5.6037, "lng": -0.1870}. Server persists every point torider_locationsfor 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):
Or for re-uploading an existing product's 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 configured—CLOUDINARY_*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¶
- Add Firebase to the Android/iOS app (
google-services.json/GoogleService-Info.plist). - Request notification permission from the user.
- Obtain the FCM device token from the Firebase SDK.
- 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.
- Handle incoming notifications in the app (foreground + background):
- Read the
datapayload (see below). - 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 ratedon second attempt for the same role on the same order - Returns
409 Order not yet deliveredbefore 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):
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>/docsfirst — 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.