API Endpoints
All backend routes exposed by the Seken NestJS + Fastify backend. Intended for developers building or maintaining the web and mobile clients.
Backend Overview​
The backend is a single NestJS 10 + Fastify service with Drizzle ORM, deployed as a Docker container on Dokploy. Route groupings below map to NestJS controller modules.
| Route group | Purpose | Base URL |
|---|---|---|
| Main API | Listings, orders, shipping, AI, auth, notifications, push, webhooks | https://api.pasarseken.id (prod) |
| Admin routes | Internal ops panel API (/admin/*) | Same service, role-gated |
| Cuan delivery | Outbox → cuan webhook delivery (in-process scheduler via @nestjs/schedule) | Same service |
All authenticated routes require two headers: Authorization: Bearer <anon-key> and X-User-Token: <user-jwt> (see Authentication below).
Main API​
Health​
| Method | Path | Description |
|---|---|---|
GET | /health | Returns { ok: true, version: "..." }, used by uptime monitoring |
Auth​
| Method | Path | Description |
|---|---|---|
POST | /auth/register | Create user profile after Supabase Auth signup |
GET | /auth/me | Get current user profile |
PATCH | /auth/me | Update profile (bio, photo, location) |
Listings​
| Method | Path | Description |
|---|---|---|
GET | /listings | List all active listings (paginated, filterable) |
GET | /listings/:id | Get single listing by ID |
POST | /listings | Create a new listing |
PATCH | /listings/:id | Update a listing (seller only) |
DELETE | /listings/:id | Soft-delete a listing (seller only) |
GET | /listings/user/:userId | Get all listings by a user |
GET | /listings/boosted | Get the ranked pool of boosted and organic listings (paginated, stable sort) |
GET /listings, Query Parameters (PS-233)​
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Maximum number of listings to return |
offset | integer | 0 | Number of listings to skip (for pagination) |
gender | string | , | Filter by gender (e.g. male, female, unisex) |
product_category | string | , | Filter by product category slug |
sortBy | string | , | Sort order: newest, lowest_price, or highest_price |
GET /listings, Response Shape (PS-233)​
Before PS-233 this endpoint returned a bare KVListing[] array. It now returns a paginated envelope:
{
"listings": [...],
"total": 120,
"offset": 0,
"limit": 50
}
| Field | Type | Description |
|---|---|---|
listings | KVListing[] | The page of results |
total | integer | Total number of listings matching the query (across all pages) |
offset | integer | The offset value used for this request |
limit | integer | The limit value used for this request |
Detecting more pages: hasMore is not included in the response body. Callers compute it as:
hasMore = offset + listings.length < total
Breaking change: Clients that treated the response as a direct array must be updated to read response.listings instead.
Flutter and web clients updated in PS-233. If you maintain a third-party integration against this endpoint, update your response parsing before deploying against the latest backend.
GET /listings/boosted, Query Parameters (PS-234)​
| Parameter | Type | Default | Max | Description |
|---|---|---|---|---|
limit | integer | 24 | 50 | Maximum number of listings to return per page |
offset | integer | 0 | , | Number of listings to skip (for pagination) |
GET /listings/boosted, Response Shape (PS-234)​
Before PS-234 this endpoint performed a non-deterministic shuffle and returned a single page with no pagination support. It now returns a stable, paginated envelope sorted by boost score descending, with listing id as the tiebreaker.
{
"listings": [...],
"total": 84,
"offset": 0,
"limit": 24
}
| Field | Type | Description |
|---|---|---|
listings | KVListing[] | The page of results, sorted by score desc then id asc |
total | integer | Total pool size before slicing, i.e. the full number of boosted + organic listings available, not the length of the returned page |
offset | integer | The offset value used for this request |
limit | integer | The limit value used for this request |
Detecting more pages: hasMore is not included in the response body. Callers compute it as:
hasMore = offset + listings.length < total
Breaking changes in PS-234:
- Response key renamed:
boosted→listings. Clients readingresponse.boostedmust switch toresponse.listings. - Fields removed:
promoted_countandorganic_countare no longer present in the response. totalsemantics changed: it now reflects the full pool size before pagination, not the number of items returned in the current page.
Flutter and web clients updated in PS-234. If you maintain a third-party integration against
GET /listings/boosted, update your response parsing, the oldboostedkey andpromoted_count/organic_countfields no longer exist.
React web, Infinite scroll implementation (PS-235)​
PS-235 is a consumer-side change, no new backend endpoints. The React home page (DesktopHome.tsx) was updated to use the paginated envelopes introduced in PS-233 and PS-234 and to replace the manual "Muat Lebih Banyak" load-more button with an IntersectionObserver-based infinite scroll.
use-boosted-listings.ts
Reads .listings from the GET /listings/boosted response (PS-234 envelope). Exposes three new values to callers:
| Value | Type | Description |
|---|---|---|
loadMore() | function | Fetches the next page by incrementing offset |
isLoadingMore | boolean | true while the next-page request is in flight |
hasMore | boolean | Derived as total > 0 && offset < total |
use-listings.ts
Unwraps the GET /listings paginated envelope (PS-233) internally. Existing callers receive the same KVListing[] shape they always did, no call-site changes required.
DesktopHome.tsx, sentinel pattern
An IntersectionObserver watches a zero-height sentinel div rendered below the listing grid. When the sentinel enters the viewport (configured with rootMargin: "200px", so the observer fires 200 px before the element reaches the fold), loadMore() is called automatically. A Loader2 spinner is shown below the grid while isLoadingMore is true. The observer is disconnected and not re-attached when hasMore is false, preventing over-fetching.
Flutter parity gap (resolved in PS-236)
The Flutter boosted datasource previously read the old boosted key from the GET /listings/boosted response. This gap is closed by PS-236, see the section below.
Flutter mobile, Infinite scroll implementation (PS-236)​
PS-236 is a consumer-side change, no new backend endpoints. It wires infinite scroll on the Flutter home screen, fixes both the PS-233 and PS-234 API contract changes in the Flutter datasource layer, and closes the Flutter parity gap flagged in PS-235.
New types
| Type | Layer | Shape |
|---|---|---|
ListingsPage | Datasource boundary | { listings, total, offset, limit }, mirrors the paginated envelope returned by the backend |
ListingsPageResult | Domain layer (Equatable) | Wraps ListingsPage for use in providers and use cases |
API contract fixes
| Datasource method | What changed |
|---|---|
getListings | Unwraps response['listings'] and response['total'] from the PS-233 paginated envelope (was reading a bare array) |
getBoostedListings | Reads the listings key (was boosted): PS-234 fix. promoted_count and organic_count reads removed. |
Offline cache fallback
When the device is offline and the datasource falls back to cache, total is synthesised as cached.length. This ensures hasMore evaluates to false while offline, preventing the provider from attempting further page loads against an unavailable network.
Infinite scroll wiring
ListingsProvider drives infinite scroll with the following logic:
| Concern | Implementation |
|---|---|
hasMore formula | _offset < page.total, replaces the previous broken check of length >= pageSize |
| Error state | isLoadingMoreError flag is set on a failed loadMore() call |
| Retry | Calling loadMore() resets isLoadingMoreError before attempting the next fetch |
| Scroll guard | loadMore() is skipped when isLoadingMoreError is true, preventing the scroll listener from hammering a failing endpoint |
home_screen.dart footer
The footer below the listing grid renders one of three states:
| State | UI shown |
|---|---|
| Loading next page | Spinner |
| Error on last load | "Tap untuk coba lagi" retry button |
| No more pages / idle | Hidden (zero-height) |
Pagination feature complete
PS-236 is the final ticket in the four-part pagination series:
| Ticket | Scope |
|---|---|
| PS-233 | Backend: GET /listings paginated envelope |
| PS-234 | Backend: GET /listings/boosted stable pagination |
| PS-235 | React web: infinite scroll via IntersectionObserver |
| PS-236 | Flutter mobile: infinite scroll + API contract fixes |
All four tickets are shipped. The pagination feature is complete end-to-end across backend, web, and mobile.
Orders​
| Method | Path | Description |
|---|---|---|
POST | /orders | Create an order and generate Xendit invoice |
GET | /orders/:id | Get order details |
GET | /orders/my | Get all orders for current user (buyer + seller) |
PUT | /orders/:id/status | Advance order status (state-machine transition) |
PUT | /orders/:id/ship | Seller marks item as shipped + enters tracking number |
PUT | /orders/:id/confirm | Buyer confirms receipt → triggers escrow release |
PUT | /orders/:id/dispute | Buyer opens a dispute |
PUT | /orders/:id/cancel | Cancel an order (within cancellation window) |
POST | /orders/:id/return-request | Buyer requests a return |
PUT | /orders/:id/return-approve | Seller/admin approves a return |
PUT | /orders/:id/return-received | Seller marks the returned item received |
PUT | /orders/:id/return-reject | Seller/admin rejects a return |
Order state-transition actions use
PUT; order creation (POST /orders) and return-request (POST /orders/:id/return-request) usePOST. There are noPATCHorder routes. The Xendit payment webhook is listed under Webhooks, not here.For payment flows and escrow behaviour, see Payments & Buyer Protection →. For fee configuration (0% seller commission, buyer protection fee), see Architecture →.
Shipping​
| Method | Path | Description |
|---|---|---|
POST | /shipping/rates | Get shipping cost estimates from Biteship |
POST | /shipping/book | Book a shipment (generate airway bill) |
GET | /shipping/track/:trackingNumber | Get live tracking status |
GET | /shipping/couriers | List available couriers |
For courier options and user-facing shipping flow, see Shipping & Delivery →.
AI Vision​
| Method | Path | Description |
|---|---|---|
POST | /ai/categorize | Send a listing photo (HTTPS URL) to the vision model → returns category, brand, condition, and 20+ structured fields |
Model:
gpt-4o-mini(overridable via theOPENAI_VISION_MODELenv var). This route powers the 60-second AI listing flow. See How to Sell → for the user perspective.
In-App Notifications​
Notifications are stored in the notifications table and polled by clients (React every 30 s, Flutter on screen mount).
| Method | Path | Description |
|---|---|---|
GET | /notifications | List all notifications for the current user (read + unread, newest first) |
PUT | /notifications/read-all | Mark all notifications as read |
PUT | /notifications/:id/toggle-read | Toggle read state of a single notification |
GET | /notifications/preferences | Get user notification preferences |
PUT | /notifications/preferences | Update notification preferences (muted categories, email digest, push enabled) |
Push Notifications​
VAPID-based Web Push. Subscriptions are stored in the push_subscriptions table.
| Method | Path | Description |
|---|---|---|
GET | /push/vapid-public-key | Get the VAPID public key for client-side subscription setup (no auth required) |
POST | /push/subscribe | Register a device push subscription |
DELETE | /push/unsubscribe | Remove a push subscription (soft-delete) |
GET | /push/subscriptions | List the current user's active push subscriptions |
POST | /push/test | Send a test push notification to all of the current user's subscribed devices |
KTP Verification​
| Method | Path | Description |
|---|---|---|
POST | /ktp/submit | Upload KTP photo + selfie for verification |
GET | /ktp/status | Get verification status for current user |
For verification flow from user perspective, see KTP Verification →.
Reviews​
| Method | Path | Description |
|---|---|---|
POST | /reviews | Submit a review after order completion |
GET | /reviews/user/:userId | Get reviews for a user |
Ops Panel API (/admin/*)​
Internal only. Requires admin JWT. Used by the Ops Panel dashboard.
| Method | Path | Description |
|---|---|---|
GET | /admin/analytics | MAU, transactions, GMV, revenue |
GET | /admin/users | List all users |
GET | /admin/orders | List all orders with filters |
GET | /admin/ktp/pending | KTP submissions pending review |
POST | /admin/ktp/:id/approve | Approve a KTP submission |
POST | /admin/ktp/:id/reject | Reject a KTP submission |
GET | /admin/disputes | List open disputes |
POST | /admin/disputes/:id/resolve | Resolve a dispute (release or refund) |
Webhooks​
Inbound callbacks from payment, logistics, and email providers. All live in the main NestJS service and are authenticated by provider signature/token verification, not the user dual-header scheme.
| Method | Path | Description |
|---|---|---|
POST | /webhooks/xendit | Xendit payment callback (invoice paid, expired, failed) |
POST | /webhooks/xendit/payout | Xendit payout/disbursement status callback |
POST | /webhooks/xendit-native | Xendit native (non-invoice) payment callback |
POST | /webhooks/biteship | Biteship tracking update callback (shipment status changes) |
POST | /webhooks/resend | Resend email delivery callback (Svix-signed) |
Request / Response Conventions​
Authentication​
All authenticated routes require both headers, the two-header invariant (PS-798):
Authorization: Bearer <supabase-anon-key>
X-User-Token: <user-jwt>
Authorization carries the platform anon key (public; used for gateway-level validation). X-User-Token carries the SEKEN JWT that identifies the caller and is verified by the NestJS auth guard. Both are required: a request missing either header is rejected with 401. The earlier "Authorization Bearer fallback" was removed as part of the P2.1 security hardening. Webhook routes are exempt (they use provider signature verification).
Standard Success Response​
{
"ok": true,
"data": { ... }
}
Standard Error Response​
{
"ok": false,
"error": {
"code": "NOT_FOUND",
"message": "Listing not found"
}
}
Error Codes​
| Code | HTTP Status | Meaning |
|---|---|---|
VALIDATION | 400 | Missing or invalid input |
UNAUTHORIZED | 401 | No valid JWT |
FORBIDDEN | 403 | Action not allowed for this user |
NOT_FOUND | 404 | Resource does not exist |
CONFLICT | 409 | Duplicate or state conflict |
INTERNAL | 500 | Unexpected server error |
Deployment​
Backend deploys automatically via CI on merge to develop (staging) or main (production). CI builds the Docker image, pushes to DOCR, and signals Dokploy to pull and restart.
See Deployment → for the full deploy process. See Operations: Deployment Guide → for the deploy checklist and rollback steps.