Generated from NestJS controllers under src/.
Most endpoints are protected by a global JWT guard. Send an Authorization: Bearer <token> header.
Public endpoints are marked with @Public() in code. In particular, the following are public:
/auth/login/auth/register/user/auth/register/companyAdmin-only endpoints: Marked with @AdminOnly(). Access requires a token whose payload includes isAdmin: true.
Admin or Carrier endpoints: Marked with @AdminOrCarrier(). Access requires a token whose payload includes isAdmin: true OR isCarrier: true.
JWT payload includes: { sub: userId, email, isAdmin, isCarrier }. The guard attaches req.user = { id, email, isAdmin, isCarrier }.
Flow overview:
carrierAccountUserId and pricePerKgSar).approved) or rejects (refunds escrow).carrier_confirmed_delivery).completed) and escrow is released to the configured carrier account.Pricing: Unit is fixed to KG. Price is taken from admin settings (pricePerKgSar), not from listing price.
Escrow receiver: Escrow is held against the configured carrierAccountUserId (carrier receives points on completion).
Statuses: pending → approved → carrier_confirmed_delivery → completed (or rejected/expired).
Process diagram (ASCII):
Admin (isAdmin)
|
| POST /admin/sample-order-settings
v
Settings: { carrierAccountUserId, pricePerKgSar, unit=KG }
Buyer (authenticated)
|
| POST /sample-requests
| - compute totalSar = pricePerKgSar * quantity
| - hold points in escrow (receiver = carrierAccountUserId)
v
SampleRequest: pending + Escrow: holding
|
| PATCH /sample-requests/:id/approve (Seller)
v
SampleRequest: approved
|
| PATCH /sample-requests/:id/carrier-confirm-delivery (Carrier account)
v
SampleRequest: carrier_confirmed_delivery
|
| PATCH /sample-requests/:id/confirm-delivery (Buyer)
| - release escrow to carrier account
v
SampleRequest: completed + Escrow: released
Alternative:
PATCH /sample-requests/:id/reject (Seller) -> SampleRequest: rejected + Escrow: refunded
(Escrow expires) -> SampleRequest: expired + Escrow: refunded
Process diagram (Mermaid text):
flowchart TD
A[Admin sets sample order settings\nPOST /admin/sample-order-settings] --> S[Settings saved\ncarrierAccountUserId + pricePerKgSar + unit=KG]
B[Buyer creates request\nPOST /sample-requests] --> E[Escrow created\npoints held (frozen)]
E --> P[SampleRequest status: pending]
P -->|Seller approve\nPATCH /sample-requests/:id/approve| AP[status: approved]
P -->|Seller reject\nPATCH /sample-requests/:id/reject| RJ[status: rejected\nescrow refunded]
AP -->|Carrier confirm\nPATCH /sample-requests/:id/carrier-confirm-delivery| CD[status: carrier_confirmed_delivery]
CD -->|Buyer confirm\nPATCH /sample-requests/:id/confirm-delivery| C[status: completed\nescrow released to carrier]
AP -->|Escrow expires| X[status: expired\nescrow refunded]
CD -->|Escrow expires| X
Admin: GET /admin/sample-order-settings Admin only
{
"unitOfMeasure": "KG",
"carrierAccountUserId": "carrier-user-uuid",
"pricePerKgSar": 12.5
}
Admin: POST /admin/sample-order-settings Admin only
{
"carrierAccountUserId": "carrier-user-uuid",
"pricePerKgSar": 12.5
}
// response: same shape as GET
POST /sample-requests
{
"listingId": "uuid",
"quantity": 2.5,
"addressId": "uuid",
"notes": "Optional"
}
Payment: Buyer points are held immediately in escrow (frozen). Seller approval does not release escrow; escrow is released only when the buyer confirms delivery.
Quantity: A double with max 3. Unit is KG. Total SAR = pricePerKgSar * quantity. Points = ceil(totalSar * 100).
Response (example)
{
"id": "sample-request-uuid",
"status": "pending",
"listing": { "id": "listing-uuid" },
"buyer": { "id": "buyer-uuid" },
"seller": { "id": "seller-uuid" },
"carrierAccountUserId": "carrier-user-uuid",
"quantity": 2.5,
"unitPriceAtRequest": "12.50",
"totalAmountAtRequest": "31.25",
"pointsHeld": "3125",
"escrowId": "escrow-uuid",
"approvedAt": null,
"carrierConfirmedDeliveryAt": null,
"buyerConfirmedDeliveryAt": null,
"createdAt": "2026-03-16T11:00:00.000Z"
}
GET /sample-requests/carrier?page=1&limit=20 Carrier only
[
{
"id": "sample-request-uuid",
"status": "approved",
"carrierAccountUserId": "carrier-user-uuid",
"quantity": 2.5,
"unitPriceAtRequest": "12.50",
"totalAmountAtRequest": "31.25",
"pointsHeld": "3125",
"escrowId": "escrow-uuid",
"approvedAt": "2026-03-16T11:10:00.000Z",
"carrierConfirmedDeliveryAt": null,
"buyerConfirmedDeliveryAt": null,
"listing": { "id": "listing-uuid", "title": "..." },
"buyer": { "id": "buyer-uuid", "email": "buyer@example.com" },
"seller": { "id": "seller-uuid", "email": "seller@example.com" },
"address": { "id": "address-uuid" },
"createdAt": "2026-03-16T11:00:00.000Z"
}
]
Authorization: The authenticated user must have isCarrier: true. The endpoint returns only rows where carrierAccountUserId matches the authenticated user's id.
PATCH /sample-requests/:id/approve Seller only
// No body
// status transition: pending -> approved
PATCH /sample-requests/:id/reject Seller only
{ "reason": "Out of stock" }
// status transition: pending -> rejected
// escrow: refunded to buyer
PATCH /sample-requests/:id/carrier-confirm-delivery Carrier only
// No body
// status transition: approved -> carrier_confirmed_delivery
PATCH /sample-requests/:id/confirm-delivery Buyer only
{ "note": "Received the sample" }
// status transition: carrier_confirmed_delivery -> completed
// escrow: released to carrier account user configured in settings
Auto-expiry: If escrow expires before completion, the escrow is refunded and the sample request may be marked expired.
GET /admin/sample-order-settings
{
"unitOfMeasure": "KG",
"carrierAccountUserId": "carrier-user-uuid",
"pricePerKgSar": 12.5
}
POST /admin/sample-order-settings
{
"carrierAccountUserId": "carrier-user-uuid",
"pricePerKgSar": 12.5
}
// response: same shape as GET
Validation: carrierAccountUserId must belong to a user with isCarrier: true. pricePerKgSar must be a number >= 0.
Used by: Sample Requests pricing. Total SAR = pricePerKgSar * quantity. Points held = ceil(totalSar * 100).
POST /admin/platform-config
{
"key": "service_platform_profit_percent",
"value": "3"
}
Usage: This value is used when the buyer pays a service order. Buyer pays basePoints + profitPoints, where profitPoints = round(basePoints * percent / 100).
Response: GET /notifications
[
{
"id": "uuid",
"title": "Payment received",
"message": "Order ORD-123 has been paid successfully",
"type": "order_paid",
"read": false,
"order": { "id": "uuid" },
"createdAt": "2025-01-01T10:00:00Z"
}
]
Request: PATCH /notifications/:id/read
{ "id": "uuid", "read": true, "readAt": "2025-01-01T11:00:00Z" }
Notification types: order_paid, payment_failed, shipment_created, shipment_updated, order_status_changed, material_listing_added.
POST /notifications/subscribe
Subscribe a device to receive Firebase Cloud Messaging (FCM) notifications for the authenticated user's topic.
Request
{
"deviceToken": "fcm-device-token-from-client"
}
Response
{ "success": true }
User topic: Devices are subscribed to the topic user_{userId} (dashes replaced with underscores). This allows the user to receive push notifications for events like new chats.
{
"message": "Hello World!"
}
Request: POST /auth/request-otp
{
"email": "user@example.com"
}
Response
{
"success": true
}
Request: POST /auth/login (OTP)
{
"email": "user@example.com",
"otp": "000000"
}
Response
{
"accessToken": "<jwt>",
"user": {
"id": "uuid",
"email": "user@example.com",
"isAdmin": false,
"isCarrier": false
}
}
Note: Call /auth/request-otp first to receive a 6-digit code via email, then submit it to /auth/login. OTPs expire after OTP_TTL_MINUTES (default 10 minutes) and can be used once.
Request: POST /auth/login/google
{
"email": "user@example.com",
"googleId": "google-oauth-sub"
}
Response
{
"accessToken": "<jwt>",
"user": {
"id": "uuid",
"email": "user@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"isAdmin": false,
"isCarrier": false
}
}
Request: POST /auth/request-otp
// 404 Not Found
{ "statusCode": 404, "message": "User not found", "error": "Not Found" }
Request: POST /auth/login
// 401 Unauthorized (invalid code)
{ "statusCode": 401, "message": "Invalid OTP", "error": "Unauthorized" }
// 401 Unauthorized (expired code)
{ "statusCode": 401, "message": "OTP expired", "error": "Unauthorized" }
Rate limiting recommendation: To prevent abuse, consider allowing up to 5 OTP requests per email per hour (rolling window) and enforce a 30s cooldown between requests. Lock or slow down after repeated invalid login attempts.
Request: POST /auth/register/user
{
"email": "new@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"countryId": "uuid-optional",
"googleId": "google-oauth-sub-optional",
"registerType": "email" // or "google", defaults to "email" if omitted
}
Request: POST /auth/register/company-user
Create a new user and a company owned by that user, then returns a JWT.
{
"email": "company.owner@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+966500000000",
"userCountryId": "uuid-optional",
"googleId": "google-oauth-sub-optional",
"registerType": "email",
"companyName": "Acme Inc",
"vatNumber": "TR1234567",
"website": "acme.com",
"companyCountryId": "uuid-optional"
}
Request: POST /auth/register/carrier-user
Create a new carrier user (isCarrier: true), then returns a JWT.
{
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966500000000",
"countryId": "uuid-optional",
"googleId": "google-oauth-sub-optional",
"registerType": "email"
}
Request: POST /auth/register/company
{
"companyName": "Acme Inc",
"vatNumber": "TR1234567",
"website": "acme.com",
"countryId": "uuid-optional"
}
Request: POST /auth/admin/register/company-user Admin only
Admin creates a new company owner user and company. Returns the created objects (no JWT).
{
"email": "company.owner@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+966500000000",
"userCountryId": "uuid-optional",
"registerType": "email",
"companyName": "Acme Inc",
"vatNumber": "TR1234567",
"website": "acme.com",
"companyCountryId": "uuid-optional"
}
Request: POST /auth/admin/register/carrier-user Admin only
Admin creates a carrier user (isCarrier: true). Returns the created user (no JWT).
{
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966500000000",
"countryId": "uuid-optional"
}
Response (register)
{
"id": "uuid",
"email": "new@example.com" // or company object depending on route
}
Response: GET /auth/me
{
"user": { "id": "uuid", "email": "user@example.com", "isAdmin": false }
}
Account status: Any endpoint protected by JWT will reject users whose accountStatus is not active (e.g. suspended or deleted).
GET /admin/dashboard
Returns high-level metrics for the platform. Requires an admin JWT token (isAdmin: true in payload).
Response
{
"totalUsers": 123,
"totalCompanies": 45,
"totalListings": 67,
"activeListings": 12,
"totalIncome": 999.99
}
Total income: Calculated as the sum of amount for all payments with status succeeded. The value is returned as a number.
GET /admin/carriers
Returns all users who have isCarrier: true.
Response
[
{
"id": "uuid",
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966501234567",
"isCarrier": true,
"accountStatus": "active"
}
]
POST /admin/welcome-users
Sends the platform welcome email to all users in the database.
Request
{}
Response
{
"attempted": 123,
"sent": 123
}
PATCH /admin/carriers
Create a carrier user (admin panel).
Request
{
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966500000000",
"countryId": "uuid-optional"
}
Response
{
"id": "uuid",
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966500000000",
"isCarrier": true,
"accountStatus": "active"
}
PATCH /admin/carrier-status
Set or revoke carrier role for a user.
Request
{
"userId": "uuid",
"isCarrier": true
}
Response
{
"id": "uuid",
"email": "user@example.com",
"isCarrier": true
}
Note: Setting isCarrier: true grants the user carrier privileges, allowing them to update shipment statuses. The user can then create a carrier profile via /carrier-profiles.
GET /admin/profit/reports Admin only
Returns aggregated platform profit (fees) grouped by period and by side (buyer / seller).
Query params
period - weekly | monthly | yearly (required)from - ISO date/time or date (optional)to - ISO date/time or date (optional)Example
GET /admin/profit/reports?period=monthly&from=2026-01-01&to=2026-12-31
Response
[
{
"period": "2026-01-01T00:00:00.000Z",
"side": "buyer",
"totalQuantity": 1500,
"totalSar": 15,
"totalPoints": 1500
},
{
"period": "2026-01-01T00:00:00.000Z",
"side": "seller",
"totalQuantity": 1500,
"totalSar": 15,
"totalPoints": 1500
}
]
Request: POST /users
{
"email": "user@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+90...",
"accountStatus": "active",
"subscriptionTier": "pro",
"countryId": "uuid",
"isAdmin": false,
"isCarrier": false,
"googleId": "google-oauth-sub-optional",
"registerType": "email" // or "google", defaults to "email" if omitted
}
Response: GET /users?page=1&limit=20
[
{ "id": "uuid", "email": "user@example.com", "firstName": "Ada", "lastName": "Lovelace", "isAdmin": false, "isCarrier": false, "country": null }
]
Response: GET /users/:id
{ "id": "uuid", "email": "user@example.com", "firstName": "Ada", "lastName": "Lovelace", "isAdmin": false, "isCarrier": false, "country": null }
Response (create/get)
{
"id": "uuid",
"email": "user@example.com",
"firstName": "Ada",
"lastName": "Lovelace"
}
Request: PATCH /users/:id
{
"firstName": "Grace",
"subscriptionTier": "enterprise"
}
Request: POST /companies/admin/create-company-and-user Admin only
{
// User fields
"email": "newuser@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+90...", // optional
"userCountryId": "uuid-optional", // optional
// Company fields
"companyName": "Acme Inc",
"vatNumber": "TR1234567", // optional
"website": "acme.com", // optional
"companyCountryId": "uuid-optional" // optional
}
Response
{
"user": {
"id": "uuid",
"email": "newuser@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"isAdmin": false
},
"company": {
"id": "uuid",
"companyName": "Acme Inc",
"verificationStatus": "verified",
"owner": { "id": "uuid" }
}
}
Request: POST /companies
{
"companyName": "Acme Inc",
"vatNumber": "TR1234567",
"website": "acme.com",
"verificationStatus": "pending",
"countryId": "uuid"
}
Response: GET /companies?page=1&limit=20
[
{ "id": "uuid", "companyName": "Acme Inc", "website": "acme.com", "owner": { "id": "uuid", "email": "owner@example.com" } }
]
Response: GET /companies/:id
{ "id": "uuid", "companyName": "Acme Inc", "website": "acme.com", "owner": { "id": "uuid", "email": "owner@example.com" } }
Response
{
"id": "uuid",
"companyName": "Acme Inc"
}
Response: GET /companies/me Requires Bearer token
[
{ "id": "uuid", "companyName": "Acme Inc", "owner": { "id": "uuid" }, "country": null }
]
Request: POST /company-addresses
{
"street": "Main St 1",
"city": "Istanbul",
"state": "",
"postalCode": "34000",
"countryId": "uuid",
"isDefault": true,
"companyId": "uuid"
}
Request: POST /user-addresses
{
"street": "Main St 1",
"city": "Istanbul",
"postalCode": "34000",
"countryId": "uuid",
"isDefault": false,
"userId": "uuid"
}
Request: POST /materials
{
"name": "Aluminum",
"unitOfMeasure": "kg",
"categoryId": "uuid",
"i18n": {
"ar": { "name": "ألمنيوم", "unitOfMeasure": "كغم" },
"en": { "name": "Aluminum", "unitOfMeasure": "kg" }
}
}
Query Parameters:
page - Page number (default: 1)limit - Items per page (default: 20, max: 100)categoryId - Filter by category ID (optional)lang - Optional language code (ar or en) to return localized name, unitOfMeasure, and category name when availableResponse: GET /materials?page=1&limit=20&lang=ar
[ { "id": "uuid", "name": "Aluminum", "unitOfMeasure": "kg" } ]
Response: GET /materials/:id?lang=en
{ "id": "uuid", "name": "Aluminum", "unitOfMeasure": "kg" }
Localization on create: The optional i18n object allows seeding translations at creation time. You can later update translations via /localization/translate.
Localization on read: When lang is provided, only that language is applied as an override to the base fields. When lang is omitted, materials (and their categories) include an i18n object with available translations for name and unitOfMeasure (currently ar and en).
Favorites
Request: POST /materials/:id/favorite Requires Bearer token
// marks material as favorite for the authenticated user
// response: the created favorite link (id, user, material)
Request: GET /materials/favorites?lang=ar Requires Bearer token
[ { "id": "uuid", "name": "Aluminum" } ] // array of Material, localized when lang is provided
Request: GET /materials/favorites/user/:userId?lang=en Admin only
[ { "id": "uuid", "name": "Aluminum" } ] // array of Material favorited by the specified user, localized when lang is provided
Request: POST /materials/favorites/user/:userId Admin only
{
"materialIds": ["mat-uuid-1", "mat-uuid-2"]
}
// response: array of Materials that are now in the user's favorites
Request: DELETE /materials/:id/favorite Requires Bearer token
{ "materialId": "uuid" }
Request: POST /materials/:id/favorite/admin Admin only
{
"userId": "uuid" // user to mark this material as favorite for
}
// response: the created (or existing) favorite link (id, user, material)
Request: POST /material-categories
{
"name": "Metals",
"i18n": {
"ar": { "name": "معادن" },
"en": { "name": "Metals" }
}
}
Request: PATCH /material-categories/:id
{
"name": "Precious Metals",
"i18n": {
"ar": { "name": "معادن ثمينة" },
"en": { "name": "Precious Metals" }
}
}
Response: GET /material-categories?lang=ar
[
{
"id": "uuid",
"name": "Metals",
"materials": [
{ "id": "mat-1", "name": "Aluminum", "unitOfMeasure": "kg", "listingsCount": 12 },
{ "id": "mat-2", "name": "Copper", "unitOfMeasure": "kg", "listingsCount": 7 }
]
}
]
Query parameter: lang can be ar or en to return localized category names and material fields when available.
Localization on read: When lang is omitted, each category includes an i18n object (e.g. { ar: { name }, en: { name } }) and each material includes its own i18n object for name and unitOfMeasure (currently translations are stored for ar and en).
listingsCount: Number of listings linked to this material (count of rows in listings where listings.materialId = material.id).
Request: PATCH /carrier/vehicle
Content-Type: multipart/form-data
Fields:
dto: "{\"model\":\"...\",\"vehicleType\":\"...\",\"vehicleBrand\":\"...\",\"vehicleYear\":2024,\"vehiclePlateNumber\":\"ABC-123\",\"deletedPhotoIds\":[\"uuid\"]}" (optional, JSON string)
images: (files) (optional, image/*, multiple)
Response: Returns the updated carrier profile with photos.
Request: POST /store-profiles
Content-Type: multipart/form-data
Fields:
storeDescription: "My store description" (optional)
storeImage: (file) (optional, image/*)
storeCoverImage: (file) (optional, image/*)
Response: Returns the created store profile with storeImageUrl and storeCoverImageUrl URLs when provided.
Response: GET /store-profiles/user/:userId
{
"id": "uuid",
"storeImageUrl": "https://...",
"storeCoverImageUrl": "https://...",
"storeDescription": "...",
"user": { "id": "uuid", "email": "user@example.com" },
"listings": [ { "id": "uuid", "title": "..." } ]
}
Listings: The listings array contains listings where the user is the seller (sellerUser).
Response: GET /market-prices/material-state-changes
[
{
"materialId": "mat-uuid",
"materialsState": "crushed",
"material": {
"id": "mat-uuid",
"name": "Copper",
"unitOfMeasure": "ton"
},
"lastSoldUnitPrice": "9.00",
"previousSoldUnitPrice": "11.00",
"changePct": -18.181818181818183,
"lastSoldAt": "2026-03-11T11:12:13.000Z",
"previousSoldAt": "2026-03-01T10:00:00.000Z"
},
{
"materialId": "mat-uuid",
"materialsState": "water",
"material": {
"id": "mat-uuid",
"name": "Copper",
"unitOfMeasure": "ton"
},
"lastSoldUnitPrice": null,
"previousSoldUnitPrice": null,
"changePct": null,
"lastSoldAt": null,
"previousSoldAt": null
}
]
Definition: A combo is (materialId + materialsState). Only combos that exist in at least one listing (via listings.materialId and listings.materials_states) are returned.
Query parameter: lang is optional (ar or en). When provided, the response overrides material.name using the localization table (materials:name:<materialId>) when available.
Sold price source: lastSoldUnitPrice and previousSoldUnitPrice are derived from completed orders (OrderStatus.COMPLETED), using each order item's unitPrice.
Change formula: ((last - previous) / previous) * 100. If there is no previous sale price, changePct is null.
Response: GET /market-prices/material-state-history?materialId=mat-uuid&materialsState=crushed&period=last3month
{
"materialId": "mat-uuid",
"materialsState": "crushed",
"period": "last3month",
"from": "2026-01-17T10:00:00.000Z",
"to": "2026-03-17T10:00:00.000Z",
"material": {
"id": "mat-uuid",
"name": "Copper"
},
"summary": {
"minPrice": "9.00",
"maxPrice": "12.00",
"avgPrice": "10.50",
"lastPrice": "9.00",
"ordersCount": 2,
"totalQuantity": 150
},
"changes": [
{
"id": "uuid",
"soldAt": "2026-03-01T10:00:00.000Z",
"unitPrice": "11.00",
"previousUnitPrice": null,
"changePct": null,
"orderId": "order-uuid",
"orderItemId": "order-item-uuid",
"quantity": 100
},
{
"id": "uuid",
"soldAt": "2026-03-11T11:12:13.000Z",
"unitPrice": "9.00",
"previousUnitPrice": "11.00",
"changePct": -18.181818181818183,
"orderId": "order-uuid",
"orderItemId": "order-item-uuid",
"quantity": 50
}
]
}
Required: materialId and materialsState.
Period filter: period can be lastmonth | last3month | last6months | lastyear | all | custom. When period=custom, you must supply from and to (ISO date strings).
Request: POST /services
Content-Type: multipart/form-data
Fields:
dto: "{\"categoryId\":\"uuid\",\"materialId\":\"uuid\",\"serviceTypeId\":\"uuid\",\"description\":\"...\",\"amount\":1,\"availability\":\"on_demand\",\"area\":\"Riyadh\",\"expectedPriceKnots\":\"10.50\",\"i18n\":{\"ar\":{\"description\":\"...\"},\"en\":{\"description\":\"...\"}}}"
images: (files) (optional, image/*, multiple)
availability: 24_7 | workdays | on_demand | booking.
expectedPriceKnots: Fixed price in knots (1 knot = 100 points). Can be renegotiated later by the provider after purchase.
Response
{
"id": "uuid",
"description": "...",
"amount": 1,
"availability": "on_demand",
"area": "Riyadh",
"expectedPriceKnots": "10.50",
"provider": { "id": "uuid", "email": "provider@example.com" },
"category": { "id": "uuid", "name": "Metals" },
"material": { "id": "uuid", "name": "Aluminum" },
"serviceType": { "id": "uuid", "name": "Recycling" },
"images": [ { "id": "uuid", "url": "https://.../public/services/img.jpg" } ],
"i18n": { "ar": { "description": "..." }, "en": { "description": "..." } }
}
GET /services?favoritesOnly=true
Returns only services whose material is in the authenticated user's favorite materials.
Auth: When favoritesOnly=true, you must include Authorization: Bearer <token>.
Service Types
Localization: Optional lang (ar or en) overrides name using localization key serviceTypes:name:<serviceTypeId> when available.
Response: GET /services/types?lang=ar
[ { "id": "uuid", "name": "..." } ]
Request: POST /services/types Admin only
{
"name": "Recycling",
"i18n": {
"ar": { "name": "إعادة تدوير" },
"en": { "name": "Recycling" }
}
}
// response: { "id": "uuid", "name": "Recycling" }
Request: PATCH /services/types/:id Admin only
{
"name": "Updated",
"i18n": { "ar": { "name": "محدث" } }
}
Request: DELETE /services/types/:id Admin only
// response: { "success": true }
Ratings
Rating range: Ratings are on a scale of 1 to 5. Each user can rate a service only once. Updating an existing rating will recalculate the service's average rating.
Request: POST /services/:id/ratings Requires Bearer token
{
"rating": 5,
"comment": "Excellent service, very professional!"
}
Upsert behavior: If the user has already rated this service, the existing rating is updated. The service's averageRating is automatically recalculated.
Response
{ "success": true }
Response: GET /services/:id/ratings?page=1&limit=20 Public
{
"page": 1,
"limit": 20,
"total": 3,
"items": [
{
"id": "rating-uuid",
"rating": 5,
"comment": "Excellent service!",
"user": { "id": "user-uuid", "firstName": "John", "lastName": "Doe" },
"createdAt": "2026-01-01T10:00:00.000Z"
}
]
}
Request: DELETE /services/ratings/:ratingId Requires Bearer token
Authorization: Users can only delete their own ratings. The service's averageRating is automatically recalculated after deletion.
{ "success": true }
Query Parameters (GET /services)
page - Page number (default: 1)limit - Items per page (default: 20, max: 100)userId - Filter by provider user ID (optional)categoryId - Filter by material category ID (optional)materialId - Filter by material ID (optional)serviceTypeId - Filter by service type ID (optional)availability - Filter by availability. One of 24_7 | workdays | on_demand | booking (optional)mode - Filter by service mode. One of instant | scheduled | monthly_contract | annual_contract (optional)minRating - Filter by minimum average rating 1-5 (optional)favoritesOnly - Return only services whose material is in the authenticated user's favorites (requires Bearer token)lang - Optional language code (ar or en) for localized fieldsPOST /service-orders/request
{
"serviceId": "uuid",
"notes": "Optional notes"
}
Flow: Creates an order in requested status. Provider must accept (and set price) before buyer can pay.
PATCH /service-orders/:id/accept
{ "priceKnots": "10.50" }
POST /service-orders/:id/pay
Points charging: Only allowed after accept. Converts priceKnots into points using Math.round(knots * 100).
Platform profit: Buyer pays totalPoints = basePoints + profitPoints where profitPoints = round(basePoints * percent / 100). Default percent is 3 and can be changed by admin using POST /admin/platform-config with key service_platform_profit_percent.
Response
{
"id": "uuid",
"status": "paid",
"expectedPriceKnotsAtRequest": "10.50",
"providerPriceKnots": "10.50",
"pointsCharged": "1050",
"profitPoints": "32",
"totalPoints": "1082",
"chatId": "uuid",
"service": { "id": "uuid" },
"buyer": { "id": "uuid" },
"provider": { "id": "uuid" },
"createdAt": "2026-01-01T00:00:00.000Z"
}
Request: POST /listings
{
"title": "Aluminum Scrap",
"description": "Clean materials",
"unitOfMeasure": "ton",
"startingPrice": "2.50",
"stockAmount": 1000,
"status": "active",
"condition": "first_grade",
"materialColor": "blue",
"materials_states": ["crushed", "water"],
"expiresAt": "2025-12-31T00:00:00Z",
"isBiddable": true,
"isSampleable": true,
"minBidAmount": "5.00",
"startingBidPrice": "2.50",
"maxBidPrice": "100.00",
"auctionDocuments": ["https://cdn.example.com/auction/doc1.pdf"],
"materialId": "uuid",
// Provide either sellerCompanyId OR sellerUserId (not both)
"sellerCompanyId": "uuid",
// "sellerUserId": "uuid",
// Optional nested media and attributes (created in the same request)
"photos": [
{ "url": "https://cdn.example.com/l/123/main.jpg", "isPrimary": true, "sortOrder": 0 },
{ "url": "https://cdn.example.com/l/123/side.jpg", "sortOrder": 1 }
],
"attributes": [
{ "attrKey": "النقاء", "attrValue": "98%" },
{ "attrKey": "المصدر", "attrValue": "Local" }
],
"i18n": {
"ar": { "title": "خردة ألمنيوم", "description": "مواد نظيفة", "unitOfMeasure": "ton" },
"en": { "title": "Aluminum Scrap", "description": "Clean materials", "unitOfMeasure": "ton" }
}
}
Note: Exactly one of sellerCompanyId or sellerUserId is required.
Photos: If multiple photos are sent with isPrimary: true, only the first is kept as primary.
Attributes: attrKey must be one of the enum values listed under “Listing Media & Attributes”.
unitOfMeasure: Must be one of the enum values liter | piece | ton. When using the lang query parameter on GET endpoints, the API returns localized display strings for unitOfMeasure when available.
condition: Optional listing condition. Must be one of first_grade | second_grade | third_grade.
materialColor: Optional color tag. Must be one of the following (name → hex):
black → #000000,
blue → #dbeafe,
green → #dcfce7,
orange → #ffedd4,
purple → #f3e8ff,
red → #ffe2e2,
white → #f5f5f5,
yellow → #fef9c2.
materials_states: Optional materials states (multi-select). Send an array of values. Each item must be one of
chemicals | crushed | dehydrated_less_than_20 | dehydrated_less_than_50 | hot_water | loos | mixed | pressed_bundle | roll | water.
Status on create: When sellerUserId is used, the listing status is set automatically based on the user's role: ACTIVE if the user is admin, otherwise DRAFT. When sellerCompanyId is used, the API honors the provided status if present, otherwise defaults to DRAFT.
Notifications: After a listing is created, users who have favorited the listing's material receive an in-app notification titled "New listing for your favorite material".
Request: POST /listings/update-requests
{
"listingId": "uuid",
"title": "Updated title",
"description": "Updated description",
"stockAmount": 900,
"materials_states": ["crushed", "water"],
"i18n": {
"ar": { "title": "عنوان محدث", "description": "وصف محدث", "unitOfMeasure": "ton" },
"en": { "title": "Updated title", "description": "Updated description", "unitOfMeasure": "ton" }
}
}
How it works: This endpoint creates an update request and does not update the listing immediately. The listing is updated only after seller approval.
Allowed status values (UpdateListingRequestStatus): pending | approved | rejected | cancelled.
Response: POST /listings/update-requests
{
"id": "uuid",
"listingId": "uuid",
"requestedById": "uuid",
"payload": {
"title": "Updated title",
"description": "Updated description",
"stockAmount": 900,
"materials_states": ["crushed", "water"],
"i18n": { "ar": { "title": "عنوان محدث" }, "en": { "title": "Updated title" } }
},
"status": "pending",
"createdAt": "2026-01-11T00:00:00.000Z",
"updatedAt": "2026-01-11T00:00:00.000Z"
}
Response: GET /listings/update-requests/seller?status=pending&page=1&limit=20
[
{
"id": "uuid",
"listingId": "uuid",
"status": "pending",
"payload": { "stockAmount": 900 },
"requestedBy": { "id": "uuid", "firstName": "John", "lastName": "Doe" },
"listing": { "id": "uuid", "title": "Aluminum Scrap" }
}
]
Response: GET /listings/update-requests/listing/:listingId?status=pending&page=1&limit=20
[
{
"id": "uuid",
"listingId": "uuid",
"status": "pending",
"payload": { "stockAmount": 900 },
"requestedBy": { "id": "uuid", "firstName": "John", "lastName": "Doe" },
"listing": { "id": "uuid", "title": "Aluminum Scrap" }
}
]
Authorization: Sellers (sellerUser or company owner) can see all update requests for the listing. Other users can only see update requests they created.
Request: POST /listings/update-requests/:requestId/approve
{}
Approve behavior: Only the listing seller can approve. On approval, the API applies payload to the listing and marks the request as approved.
Request: POST /listings/update-requests/:requestId/reject
{
"reason": "Not acceptable"
}
Request: PATCH /listings/:id
{
"description": "Updated description",
"stockAmount": 900,
"i18n": {
"ar": { "title": "عنوان محدث", "description": "وصف محدث", "unitOfMeasure": "ton" },
"en": { "title": "Updated title", "description": "Updated description", "unitOfMeasure": "ton" }
}
}
Query Parameters:
page - Page number (default: 1)limit - Items per page (default: 20, max: 100)materialId - Filter by material ID (optional)companyId - Filter by company ID (optional)userId - Filter by user ID (optional)isBiddable - Filter by biddable status (true/false) (optional)condition - Filter by listing condition (optional). One of first_grade | second_grade | third_grade.materialColor - Filter by material color (optional). One of black | blue | green | orange | purple | red | white | yellow.favoritesOnly - If true, return only listings whose material is favorited by the authenticated user. Requires Bearer token when enabled.lang - Optional language code (ar or en) to return localized fields when available (listing title, description, unitOfMeasure; material name and unit; category name). Applies to /listings, /listings/:id and the /listings/me* endpoints.Localization on read: When lang is provided, only that language overrides the base fields. When lang is omitted, listings include an i18n object for title, description and unitOfMeasure, and nested material and material.category also include their own i18n objects with all stored translations (currently ar and en).
/listings/me behavior: If the authenticated user owns at least one company, the endpoint returns listings for those companies. Otherwise it returns listings created by the user (sellerUser).
Bids stats: For listings where isBiddable is true, the response includes bidsCount, bidsStartDate (earliest bid time), bidsExpiryDate (listing expiresAt), and bidsMinPrice/bidsMaxPrice (min/max bid amount).
Response: GET /listings?page=1&limit=20
[
{
"id": "uuid",
"title": "Aluminum Scrap",
"isBiddable": true,
"status": "active",
"bidsCount": 0,
"bidsStartDate": null,
"bidsExpiryDate": null,
"bidsMinPrice": null,
"bidsMaxPrice": null,
"material": { "id": "uuid", "name": "Aluminum" },
"seller": { "id": "uuid", "companyName": "Acme Inc" },
"sellerUser": null,
"photos": [],
"attributes": []
}
]
Response: GET /listings/:id?lang=en
{
"id": "uuid",
"title": "Aluminum Scrap",
"isBiddable": true,
"status": "active",
"material": { "id": "uuid", "name": "Aluminum" },
"seller": { "id": "uuid", "companyName": "Acme Inc" },
"sellerUser": null,
"photos": [],
"attributes": []
}
Localization on create: The optional i18n object allows seeding translations at creation time.
Request: POST /listings/photos
{
"listingId": "uuid",
"url": "https://cdn.example.com/pic.jpg",
"sortOrder": 0,
"isPrimary": true
}
Response: GET /listings/:listingId/photos
[ { "id": "uuid", "url": "https://cdn.example.com/pic.jpg", "isPrimary": true, "sortOrder": 0 } ]
Request: POST /listings/attributes
{
"listingId": "uuid",
"attrKey": "اللون",
"attrValue": "فضي"
}
Response: GET /listings/:listingId/attributes
[ { "id": "uuid", "attrKey": "اللون", "attrValue": "فضي" } ]
Allowed attrKey values (enum ListingAttributeKey):
النقاءالمصدرالأحجامالشوائباللونالرطوبةالحالةمتوسط وزن البالةالألوانالمعادنالمحتوىالتصنيفAuthentication: Send Authorization: Bearer <token>.
Content-Type: multipart/form-data
Field name: file
Validation: /uploads/image accepts only images (mimetype starts with image/).
Limits: Images up to 10 MB. Files up to 20 MB.
Response: Both endpoints return a JSON body containing a public URL.
{
"url": "https://<host>/public/images/<filename>" // or /public/files/<filename>
}
Example cURL
curl -H "Authorization: Bearer <jwt>" \
-F "file=@./photo.jpg" \
http://localhost:3000/uploads/image
curl -H "Authorization: Bearer <jwt>" \
-F "file=@./document.pdf" \
http://localhost:3000/uploads/file
Files are stored under ./public/images and ./public/files and served at /public.
Request: POST /advertisements
{
"title": "Homepage Banner",
"imageUrl": "https://cdn.example.com/banner.jpg",
"linkUrl": "https://example.com",
"active": true
}
Query Parameters (GET /advertisements):
activeOnly - Filter active ads only (default: true)Response: GET /advertisements
[
{ "id": "uuid", "title": "Homepage Banner", "active": true }
]
Response: GET /advertisements/:id
{ "id": "uuid", "title": "Homepage Banner", "active": true }
Request: PATCH /advertisements/:id/toggle-active
// toggles the advertisement active status
Request: POST /bids
{
"amount": "1500.00",
"listingId": "uuid",
// Provide either bidderCompanyId OR bidderUserId (or omit to auto-fill from token)
"bidderCompanyId": "uuid"
// "bidderUserId": "uuid"
}
Note: If neither bidderCompanyId nor bidderUserId is provided, the API will use the authenticated user's owned company if available, otherwise the user.
Upsert behavior: If a bid already exists for the same (listingId + bidder), the API updates that bid instead of creating a new row. The new amount must be greater than your previous bid.
Response: GET /bids/me/listings?page=1&limit=20&lang=ar Requires Bearer token
{
"page": 1,
"limit": 20,
"total": 2,
"items": [
{
"listing": { "id": "uuid", "title": "...", "isBiddable": true },
"bid": { "id": "uuid", "amount": "1500.00", "createdAt": "2026-01-01T10:00:00.000Z" }
}
]
}
Localization: Optional lang (ar or en) overrides listing.title, listing.description and listing.unitOfMeasure using localization keys listings:title:<listingId>, listings:description:<listingId>, listings:uom:<listingId> when available.
Real-time bid updates (Firebase FCM topics)
When a new bid is created, the backend publishes an FCM topic message with data.type = bid_added to the listing topic. Clients can subscribe to receive these events and update the UI immediately.
POST /bids/listing/:listingId/subscribe Requires Bearer token
Subscribe a device token to the listing bid topic.
Request
{
"deviceToken": "fcm-device-token-from-client"
}
Response
{ "success": true }
DELETE /bids/listing/:listingId/subscribe Requires Bearer token
Unsubscribe a device token from the listing bid topic.
Request
{
"deviceToken": "fcm-device-token-from-client"
}
Response
{ "success": true }
Listing topic: Devices are subscribed to listing_{listingId} (dashes replaced with underscores). On new bids, the push payload includes:
{
"notification": { "title": "New bid", "body": "A new bid was placed: 1500.00" },
"data": {
"type": "bid_added",
"listingId": "uuid",
"bid": "{\"id\":\"uuid\",\"amount\":\"1500.00\",\"createdAt\":\"2026-01-01T10:00:00.000Z\",\"listingId\":\"uuid\",\"bidderCompanyId\":\"uuid\",\"bidderUserId\":null}"
}
}
Response: GET /bids/:id
{
"id": "uuid",
"amount": "1500.00",
"listing": { "id": "uuid" },
"bidderCompany": { "id": "uuid", "companyName": "Acme Inc" },
"bidderUser": null
}
Response: GET /bids/listing/:listingId
[
{
"id": "uuid",
"amount": "1500.00",
"listing": { "id": "uuid" },
"bidderCompany": { "id": "uuid", "companyName": "Acme Inc" },
"bidderUser": null
}
]
Request: POST /orders
{
// Provide buyerCompanyId OR buyerUserId (or omit to auto-fill from token)
"buyerCompanyId": "uuid",
// "buyerUserId": "uuid",
// Provide sellerCompanyId OR sellerUserId (or omit to derive from listings)
// "sellerCompanyId": "uuid",
// "sellerUserId": "uuid",
"createdByUserId": "uuid", // optional (defaults to current user)
"order_without_driver": false, // optional (default: false). If true, no shipping tender is created.
"orderStatus": "pending",
"items": [
{ "listingId": "uuid", "quantity": 10, "unitPrice": "2.50" }
]
}
Buyer resolution: If buyer IDs are omitted, the API uses the authenticated user's owned company if available, otherwise the user.
Seller resolution: If seller IDs are omitted, the API derives the seller from the listings' seller identity. All items must belong to the same seller (either the same company or the same user).
Request: PATCH /orders/:id/status/:status
// status in path e.g. shipped, completed
Response: GET /orders?page=1&limit=20
[
{
"id": "uuid",
"status": "pending",
"buyerCompany": { "id": "uuid", "companyName": "Buyer Co" },
"buyerUser": null,
"sellerCompany": { "id": "uuid", "companyName": "Seller Co" },
"sellerUser": null,
"items": [ { "listingId": "uuid", "quantity": 10, "unitPrice": "2.50" } ]
}
]
Response: GET /orders/:id
{
"id": "uuid",
"status": "pending",
"buyerCompany": { "id": "uuid", "companyName": "Buyer Co" },
"buyerUser": null,
"sellerCompany": { "id": "uuid", "companyName": "Seller Co" },
"sellerUser": null,
"escrowHours": null, // Carrier-set escrow duration (null = 12h default)
"escrowId": null, // Set after checkout with points
"items": [ { "listingId": "uuid", "quantity": 10, "unitPrice": "2.50" } ]
}
GET /orders/reports/selling?period=yearly Seller report
{
"period": "yearly",
"from": "2026-01-01T00:00:00.000Z",
"to": "2026-03-16T12:00:00.000Z",
"totalSalesSar": 15230.5,
"totalListingsSold": 12,
"totalQuantitiesSold": 845.75,
"totalClients": 7,
"totalBuyerUsers": 5,
"totalListingsSoldByMaterial": [
{ "materialId": "mat-uuid-1", "materialName": "Copper", "totalListingsSold": 7 },
{ "materialId": "mat-uuid-2", "materialName": "Aluminum", "totalListingsSold": 5 }
]
}
Filters: Use period = yearly (default) | last3m | last6m | custom.
Custom range: When period=custom, you must pass from and to as ISO dates (e.g. 2026-01-01 or full ISO timestamp).
Completed date: Aggregates orders with orderStatus = completed and filters by updatedAt (used as completion time approximation).
Localization: Optional lang (ar or en) overrides materialName in totalListingsSoldByMaterial using localization key materials:name:<materialId> when available.
Request: PATCH /orders/:id/escrow-hours (Carrier Only)
{
"hours": 48 // 1-168 hours (1 hour to 7 days)
}
Note: Only users with isCarrier: true can set escrow hours. Must be called before checkout.
Request: POST /orders/:id/checkout/points
// No body required - uses authenticated user's wallet
Creates escrow: Points are frozen until delivery is completed. Order escrows do not expire automatically.
Request: POST /orders/:id/confirm-delivery (Buyer Only)
// No body required
// NOTE: For points-based orders (PaymentMethod = points), this endpoint returns 400
// and delivery must be confirmed via shipments (two-step):
// 1) POST /shipments/:shipmentId/confirm-delivery (carrier)
// 2) POST /shipments/:shipmentId/buyer-confirm-delivery (buyer)
Request: POST /orders/:id/report-problem (Buyer Only)
{
"message": "The shipment was damaged" // optional
}
Effect: Reporting a problem blocks the 48-hour auto-completion flow for carrier-delivered shipments.
Request: POST /orders/:id/ship (Seller Only)
// No body required - marks order as shipped
// NOTE: If order_without_driver = true, this does NOT complete the order.
// It starts a 48-hour timer from sellerNoDriverShippedAt.
// Buyer must complete via POST /orders/:id/buyer-complete or report a problem.
// If buyer does neither within 48 hours, the system auto-completes the order and releases escrow (points orders).
Request: POST /orders/:id/buyer-complete (Buyer Only)
// No body required
// Only valid for order_without_driver = true
// Completes the order (and releases escrow for points orders)
Request: POST /orders/:id/cancel
{
"reason": "Order cancelled by buyer" // optional
}
Refunds escrow: If order was paid with points, frozen points are returned to buyer.
Request: POST /payments
{
"orderId": "uuid",
"amount": "100.00",
"method": "card",
"transactionId": "txn_123"
}
Request: PATCH /payments/:id/status/:status
// status in path e.g. authorized, settled
Response: GET /payments/:id
{ "id": "uuid", "order": {"id": "uuid"}, "amount": "100.00", "status": "succeeded" }
Response: GET /payments/order/:orderId
[ { "id": "uuid", "order": {"id": "uuid"}, "amount": "100.00", "status": "succeeded" } ]
Request: POST /payments/thawani/:orderId/checkout
{
"successUrl": "https://app.example.com/pay/success",
"cancelUrl": "https://app.example.com/pay/cancel"
}
Response
{
"paymentId": "uuid",
"sessionId": "THAWANI_SESSION_ID",
"redirectUrl": "https://uatcheckout.thawani.om/pay/THAWANI_SESSION_ID?key=pk_..."
}
Webhook: POST /payments/thawani/webhook
{
"data": {
"id": "THAWANI_SESSION_ID",
"client_reference_id": "payment-uuid",
"status": "paid"
}
}
Thawani config via env: THAWANI_SECRET_KEY, THAWANI_PUBLISHABLE_KEY, THAWANI_MODE (uat|prod).
Redirect users to redirectUrl to complete payment. Webhook updates payment and order status.
Request: POST /payments/edfapay/:orderId/checkout
{
"currency": "SAR",
"description": "Order Payment",
"termUrl3ds": "https://app.example.com/pay/edfapay/return",
"payer": {
"firstName": "John",
"lastName": "Doe",
"address": "King Saud Rd 1",
"country": "SA",
"city": "Riyadh",
"zip": "12221",
"email": "john@example.com",
"phone": "966501234567",
"ip": "176.44.76.222"
}
}
Response
{
"paymentId": "uuid",
"redirectUrl": "https://pay.edfapay.com/merchant/checkout/Order.../..."
}
Webhook: POST /payments/edfapay/webhook Content-Type: form-data
action=SALE
result=SUCCESS
status=SETTLED
order_id=<paymentId>
trans_id=<platform id>
amount=100.00
currency=SAR
descriptor=... (optional)
EdfaPay config via env: EDFAPAY_API_BASE (default https://api.edfapay.com), EDFAPAY_MERCHANT_ID, EDFAPAY_PASSWORD, EDFAPAY_CURRENCY (default SAR).
The server builds the required initiate signature and redirects the user to the EdfaPay checkout page. The webhook should return plain text OK upon successful processing.
Request: POST /shipments
{
"orderId": "uuid",
"carrierUserId": "uuid-optional", // Assign carrier user on creation
"carrier": "DHL", // Optional external carrier name
"trackingNumber": "TRACK123",
"status": "in_transit",
"shippedAt": "2025-01-01T10:00:00Z"
}
Response: GET /shipments/order/:orderId
[
{
"id": "uuid",
"order": { "id": "uuid" },
"carrierUser": { "id": "uuid", "firstName": "John", "lastName": "Doe" },
"carrier": "DHL",
"status": "in_transit"
}
]
Response: GET /shipments/:id
{
"id": "uuid",
"order": { "id": "uuid" },
"carrierUser": { "id": "uuid", "firstName": "John", "lastName": "Doe", "email": "carrier@example.com" },
"carrier": "DHL",
"trackingNumber": "TRACK123",
"status": "in_transit",
"deliveredAt": null,
"carrierConfirmedDeliveredAt": null,
"buyerConfirmedDeliveredAt": null
}
POST /shipments/:id/confirm-pickup Admin or Carrier only
Carrier confirms they received the shipment from the merchant/company. Requires the authenticated user to be the assigned carrier (shipment.carrierUser) unless admin.
Request
// No body required
Response
{
"id": "uuid",
"status": "in_transit",
"pickedUpAt": "2025-01-01T10:00:00Z",
"shippedAt": "2025-01-01T10:00:00Z"
}
POST /shipments/:id/confirm-delivery Admin or Carrier only
Carrier confirms the shipment was delivered to the customer and triggers carrier shipping fee payout. For points-based orders, escrow release and order completion happen only after buyer confirmation via POST /shipments/:id/buyer-confirm-delivery.
Delivery verification image: This endpoint requires a delivery verification image uploaded as multipart/form-data under the field name file. The image is automatically sent to the order chat as an image message.
Auto-complete: If the carrier confirmed delivery and the buyer neither confirms delivery nor reports a problem within 48 hours, the system auto-completes the order and releases escrow (points orders).
Request
Content-Type: multipart/form-data
Form-data:
file: (required) image/*
Response
{
"shipment": { "id": "uuid", "status": "delivered", "deliveredAt": "2025-01-05T14:30:00Z", "carrierConfirmedDeliveredAt": "2025-01-05T14:30:00Z" },
"order": { "id": "uuid", "orderStatus": "shipped" },
"chatMessage": {
"id": "uuid",
"type": "image",
"content": "Delivery verification",
"attachmentUrl": "https://api.example.com/public/chat-attachments//images/.jpg",
"attachmentName": "delivery.jpg",
"attachmentMimeType": "image/jpeg",
"attachmentSize": 123456
}
}
POST /shipments/:id/buyer-confirm-delivery Buyer only
Buyer confirms they received the shipment. For points-based orders, this triggers escrow release to the seller and completes the order.
Request
// No body required
Response
{
"shipment": { "id": "uuid", "status": "delivered", "deliveredAt": "2025-01-05T14:30:00Z", "carrierConfirmedDeliveredAt": "2025-01-05T14:30:00Z", "buyerConfirmedDeliveredAt": "2025-01-05T15:10:00Z" },
"order": { "id": "uuid", "orderStatus": "completed" }
}
Access control: If the authenticated carrier is not the assigned carrier for the shipment, the API returns 403. If the shipment has no assigned carrier, the API returns 400.
POST /shipments/assign Admin only
Assign a carrier user to an existing shipment.
Request
{
"shipmentId": "uuid",
"carrierUserId": "uuid"
}
Response
{
"id": "uuid",
"order": { "id": "uuid" },
"carrierUser": { "id": "uuid", "firstName": "John", "lastName": "Doe" },
"carrier": null,
"trackingNumber": "TRACK123",
"status": "pending"
}
Validation: The carrierUserId must reference a user with isCarrier: true. Returns 403 if user is not a carrier.
GET /shipments/my-assignments Admin or Carrier only
List all shipments assigned to the current authenticated carrier user.
Response
[
{
"id": "uuid",
"order": { "id": "uuid" },
"carrierUser": { "id": "uuid" },
"status": "pending",
"createdAt": "2025-01-01T10:00:00Z"
}
]
GET /shipments/carrier/:carrierUserId Admin only
List all shipments assigned to a specific carrier user (admin view).
Response
[
{
"id": "uuid",
"order": { "id": "uuid" },
"carrierUser": { "id": "uuid", "firstName": "John" },
"status": "in_transit"
}
]
Request: PATCH /shipments/:id Admin or Carrier only
Update shipment status. Only users with isAdmin: true or isCarrier: true can update shipments.
{
"status": "delivered",
"deliveredAt": "2025-01-05T14:30:00Z"
}
Response
{
"id": "uuid",
"order": { "id": "uuid" },
"carrierUser": { "id": "uuid", "firstName": "John", "lastName": "Doe" },
"carrier": "DHL",
"trackingNumber": "TRACK123",
"status": "delivered",
"shippedAt": "2025-01-01T10:00:00Z",
"deliveredAt": "2025-01-05T14:30:00Z"
}
Shipment statuses: pending | in_transit | delivered | cancelled
Access control: Only admins and carriers can update shipment status. Regular users cannot modify shipments.
Carrier assignment: Shipments can be assigned to a carrier user (carrierUser) who is responsible for delivery. The carrier field is now optional and can be used for external carrier names (e.g., DHL, FedEx).
GET /shipping-tenders/order/:orderId
Fetch the shipping tender for an order (one tender per order). If none exists, returns 404.
Response
{
"id": "uuid",
"order": { "id": "uuid" },
"chat": { "id": "uuid" },
"expiresAt": "2026-01-01T22:00:00.000Z",
"status": "open",
"selectedCarrierUser": null,
"createdAt": "2026-01-01T10:00:00.000Z"
}
Status: open | awarded | expired | closed
GET /shipping-tenders/:tenderId/top-offers
List the lowest offers for a tender, ordered by amount ASC. Default limit=3, bounded to 1..10.
Response
[
{
"id": "uuid",
"tender": { "id": "uuid" },
"carrierUser": { "id": "uuid", "firstName": "John", "lastName": "Doe" },
"amount": "250.00",
"currency": "SAR",
"notes": "Can pickup same day",
"createdAt": "2026-01-01T11:00:00.000Z"
}
]
POST /shipping-tenders/:tenderId/offers Admin or Carrier only
Create or update the authenticated carrier user's offer for this tender. Each carrier can have only one offer per tender.
Request
{
"tenderId": "uuid",
"amount": "250.00",
"currency": "SAR",
"notes": "Optional notes (max 500 chars)"
}
Response
{
"id": "uuid",
"tender": { "id": "uuid" },
"carrierUser": { "id": "uuid" },
"amount": "250.00",
"currency": "SAR",
"notes": "Optional notes (max 500 chars)",
"createdAt": "2026-01-01T11:00:00.000Z"
}
Access/validation: The authenticated user must have isCarrier: true and an available carrier profile (carrierProfiles.isAvailable=true). Submitting after expiry or when tender is not open returns 400.
POST /shipping-tenders/:tenderId/select/:offerId
Select (award) a carrier offer for a tender. Only the buyer side (buyer user or buyer company owner) can select, unless admin.
Request
// No body required
Response
{
"id": "uuid",
"order": { "id": "uuid" },
"expiresAt": "2026-01-01T22:00:00.000Z",
"status": "awarded",
"selectedCarrierUser": { "id": "uuid" }
}
Side effects: If the tender is linked to a chat, the selected carrier user is linked via chat.carrierUser and the order is linked via chat.order. The selected carrier also receives a push notification/event.
POST /carrier-profiles
Create a carrier profile for the authenticated user. User must have isCarrier: true.
Request
{
"pricePerTon": 150.00,
"pricePerKm": 2.50,
"currency": "SAR",
"vehicleType": "truck",
"vehicleBrand": "Mercedes",
"vehicleModel": "Actros",
"vehicleYear": 2022,
"vehiclePlateNumber": "ABC 1234",
"vehicleCapacityTons": 25.5,
"vehicleDescription": "Heavy duty truck for bulk materials",
"licenseNumber": "DL-12345678",
"licenseExpiryDate": "2026-12-31",
"isAvailable": true
}
Response
{
"id": "uuid",
"pricePerTon": 150.00,
"pricePerKm": 2.50,
"currency": "SAR",
"vehicleType": "truck",
"vehicleBrand": "Mercedes",
"vehicleModel": "Actros",
"vehicleYear": 2022,
"vehiclePlateNumber": "ABC 1234",
"vehicleCapacityTons": 25.5,
"vehicleDescription": "Heavy duty truck for bulk materials",
"licenseNumber": "DL-12345678",
"licenseExpiryDate": "2026-12-31",
"isAvailable": true,
"user": { "id": "uuid" },
"createdAt": "2025-01-01T10:00:00Z",
"updatedAt": "2025-01-01T10:00:00Z"
}
GET /carrier-profiles/me
Get the carrier profile for the authenticated user.
Response
{
"id": "uuid",
"pricePerTon": 150.00,
"pricePerKm": 2.50,
"currency": "SAR",
"vehicleType": "truck",
"vehicleBrand": "Mercedes",
"vehicleModel": "Actros",
"vehicleYear": 2022,
"vehiclePlateNumber": "ABC 1234",
"vehicleCapacityTons": 25.5,
"vehicleDescription": "Heavy duty truck for bulk materials",
"licenseNumber": "DL-12345678",
"licenseExpiryDate": "2026-12-31",
"isAvailable": true,
"user": { "id": "uuid", "email": "carrier@example.com", "firstName": "John", "lastName": "Doe" }
}
GET /carrier-profiles Admin only
List all carrier profiles.
Response
[
{
"id": "uuid",
"pricePerTon": 150.00,
"pricePerKm": 2.50,
"currency": "SAR",
"vehicleType": "truck",
"isAvailable": true,
"user": { "id": "uuid", "email": "carrier@example.com" }
}
]
GET /carrier-profiles/available
List all available carriers (where isAvailable: true).
Response
[
{
"id": "uuid",
"pricePerTon": 150.00,
"pricePerKm": 2.50,
"currency": "SAR",
"vehicleType": "truck",
"vehicleCapacityTons": 25.5,
"isAvailable": true,
"user": { "id": "uuid", "firstName": "John", "lastName": "Doe" }
}
]
PATCH /carrier-profiles/me
Update the authenticated user's carrier profile.
Request
{
"pricePerTon": 175.00,
"isAvailable": false
}
Response
{
"id": "uuid",
"pricePerTon": 175.00,
"pricePerKm": 2.50,
"isAvailable": false,
...
}
DELETE /carrier-profiles/me
Delete the authenticated user's carrier profile.
Response
{ "success": true }
Pricing fields: pricePerTon and pricePerKm allow carriers to set their shipping rates. Clients can use these to calculate estimated shipping costs based on weight and distance.
Vehicle data: Includes vehicleType, vehicleBrand, vehicleModel, vehicleYear, vehiclePlateNumber, vehicleCapacityTons, and vehicleDescription.
License info: licenseNumber and licenseExpiryDate for carrier verification.
POST /users
Create a new user.
Request
{
"email": "user@example.com",
"password": "password123",
"firstName": "John",
"lastName": "Doe"
}
Response
{
"id": "uuid",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe"
}
GET /users/me
Get the authenticated user's profile.
Response
{
"id": "uuid",
"email": "user@example.com",
"firstName": "John",
"lastName": "Doe",
"storeProfile": {
"id": "uuid",
"storeImageUrl": "https://api.example.com/uploads/store-profiles/store-image.jpg",
"storeCoverImageUrl": "https://api.example.com/uploads/store-profiles/store-cover.jpg",
"storeDescription": "Optional description"
}
}
PATCH /users/me
Update the authenticated user's profile.
Request
{
"firstName": "Jane",
"lastName": "Doe"
}
Response
{
"id": "uuid",
"email": "user@example.com",
"firstName": "Jane",
"lastName": "Doe"
}
DELETE /users/me
Delete the authenticated user's profile.
Response
{ "success": true }
POST /businesses
Create a new business.
Request
{
"name": "Example Inc.",
"email": "business@example.com",
"address": "123 Main St, Anytown, USA"
}
Response
{
"id": "uuid",
"name": "Example Inc.",
"email": "business@example.com",
"address": "123 Main St, Anytown, USA"
}
GET /businesses/me
Get the authenticated business's profile.
Response
{
"id": "uuid",
"name": "Example Inc.",
"email": "business@example.com",
"address": "123 Main St, Anytown, USA"
}
PATCH /businesses
Update the authenticated business's profile.
Request
{
"name": "Example Corp.",
"email": "business@example.com",
"address": "456 Elm St, Anytown, USA"
}
Response
{
"id": "uuid",
"name": "Example Corp.",
"email": "business@example.com",
"address": "456 Elm St, Anytown, USA"
}
DELETE /businesses/me
Delete the authenticated business's profile.
Response
{ "success": true }
Request: POST /countries
{
"countryCode": "TR",
"countryName": "Türkiye"
}
Response: GET /countries?page=1&limit=20
[ { "id": "uuid", "countryCode": "TR", "countryName": "Türkiye" } ]
Response: GET /countries/:id
{ "id": "uuid", "countryCode": "TR", "countryName": "Türkiye" }
Request: PATCH /countries/:id
{
"countryName": "Turkey"
}
Request: POST /country-settings
{
"countryId": "uuid",
"key": "currency",
"value": "TRY"
}
Response: GET /country-settings/:countryId
[ { "id": "uuid", "country": {"id": "uuid"}, "key": "currency", "value": "TRY" } ]
The API uses message keys for localization. The backend no longer returns human-readable message strings.
Success payloads: If a response previously included a top-level message string, it now returns messageKey instead.
Error payloads: All HTTP errors return messageKey instead of message.
Success message example
Before (old behavior):
{ "success": true, "message": "Subscribed" }
Now:
{ "success": true, "messageKey": "messages.subscribed" }
Not found error example
// 404 Not Found
{
"statusCode": 404,
"messageKey": "errors.user_not_found",
"error": "Not Found"
}
Validation error example
When validation fails, messageKey is errors.validation_failed and a details array is included.
// 400 Bad Request
{
"statusCode": 400,
"messageKey": "errors.validation_failed",
"error": "Bad Request",
"details": [
"email must be an email",
"firstName must be a string"
]
}
If the backend throws/returns a key-like string (no spaces and contains a dot, e.g. errors.user_not_found), it is passed through as-is.
Otherwise, the backend generates keys by normalizing the text:
errors.<normalized_message>messages.<normalized_message>Normalization: lowercase, replace non-alphanumeric with underscores, collapse repeats, trim underscores, max 120 chars.
Recommended frontend behavior: treat messageKey as the translation lookup key. Do not display the raw key to end users unless as a fallback.
Keys generated from literal message strings found in controllers:
errors.only_image_files_are_allowed ("Only image files are allowed")errors.no_file_uploaded ("No file uploaded")errors.no_file_provided ("No file provided")errors.devicetoken_is_required ("deviceToken is required")Request: POST /localization/keys
{
"keyName": "welcome.title",
"description": "Homepage title"
}
Response: GET /localization/keys
[ { "id": "uuid", "keyName": "welcome.title", "description": "Homepage title" } ]
Request: POST /localization/translate
{
"keyName": "welcome.title",
"languageCode": "en",
"value": "Welcome"
}
Response: GET /localization/lang/:languageCode
{ "languageCode": "en", "translations": { "welcome.title": "Welcome" } }
Request: POST /tickets
{
"subject": "Issue with order",
"createdByUserId": "uuid",
"priority": "medium"
}
Response: GET /tickets?page=1&limit=20
[ { "id": "uuid", "subject": "Issue with order", "priority": "medium", "status": "open" } ]
Response: GET /tickets/:id
{ "id": "uuid", "subject": "Issue with order", "priority": "medium", "status": "open" }
Request: PATCH /tickets/:id/status
{
"status": "in_progress"
}
Request: POST /tickets/assign
{
"ticketId": "uuid",
"assignedToUserId": "uuid"
}
Request: POST /tickets/message
{
"ticketId": "uuid",
"senderUserId": "uuid",
"content": "Following up on this ticket."
}
Response: GET /tickets/:id/messages
[ { "id": "uuid", "senderUserId": "uuid", "content": "Following up on this ticket." } ]
In addition to the REST endpoints above, the backend exposes a Socket.IO gateway for real-time chat updates.
Namespace: /chat
Authentication: Provide the JWT access token either as:
{ auth: { token: "<jwt>" } }Authorization: Bearer <jwt>Rooms: Join a chat room named chat:<chatId>.
Client → Server events
// Join a chat room (only allowed for buyer/seller/carrier of the chat)
socket.emit('chat.join', { chatId: 'uuid' })
// Leave a chat room
socket.emit('chat.leave', { chatId: 'uuid' })
Server → Client events
// Fired whenever a new message is created (text/attachment/event)
socket.on('message.created', (message) => {
// message is the saved Message entity
})
Request: POST /chat
{
"topic": "Order #123 discussion",
"createdByUserId": "uuid",
"listingId": "uuid-optional",
"serviceId": "uuid-optional"
}
Listing chats: When listingId and createdByUserId are provided, the API returns an existing chat for that listing+creator if one exists.
Service chats: When serviceId and createdByUserId are provided, the API returns an existing chat for that service+creator if one exists. The service provider is automatically linked via sellerUser.
Service orders: For service chats, the API may attach a serviceOrder field to the chat response when an active service order exists for the buyer+service.
Response: GET /chat/user/:userId
[
{
"id": "uuid",
"topic": "Order #123 discussion",
"status": "open",
"createdBy": { "id": "uuid", "email": "creator@example.com", "firstName": "John", "lastName": "Doe" },
"buyerUser": { "id": "uuid", "email": "buyer@example.com" },
"sellerUser": { "id": "uuid", "email": "seller@example.com" },
"carrierUser": null,
"listing": { "id": "uuid", "title": "Aluminum Scrap" },
"shippingTender": {
"id": "uuid",
"status": "open",
"expiresAt": "2026-01-01T22:00:00.000Z",
"order": { "id": "uuid" },
"chat": { "id": "uuid" },
"selectedCarrierUser": null,
"offers": [
{
"id": "uuid",
"amount": "250.00",
"currency": "SAR",
"notes": "Optional",
"carrierUser": { "id": "uuid", "firstName": "John", "lastName": "Doe" }
}
]
},
"order": {
"id": "uuid",
"items": [
{
"id": "uuid",
"quantity": 2,
"unitPrice": "100.00",
"listing": { "id": "uuid", "title": "Aluminum Scrap" }
}
]
}
}
]
shippingTender: Present when the chat is connected to a shipping tender. Otherwise returns null.
Response: GET /chat/listing/:listingId
Returns all chats associated with a specific listing, including shipping tenders and orders. Response format is identical to GET /chat/user/:userId.
[
{
"id": "uuid",
"topic": "Order #123 discussion",
"status": "open",
"createdBy": { "id": "uuid", "email": "creator@example.com", "firstName": "John", "lastName": "Doe" },
"listing": { "id": "uuid", "title": "Aluminum Scrap" },
"buyerUser": { "id": "uuid", "email": "buyer@example.com" },
"sellerUser": { "id": "uuid", "email": "seller@example.com" },
"carrierUser": null
}
]
Request: PATCH /chat/:chatId/status
{
"status": "closed"
}
Request: POST /chat/message
Send a text message (default):
{
"chatId": "uuid",
"senderUserId": "uuid",
"content": "Hello there"
}
Send an image message:
{
"chatId": "uuid",
"senderUserId": "uuid",
"type": "image",
"content": "Check out this photo",
"attachmentUrl": "https://storage.example.com/images/photo.jpg",
"attachmentName": "photo.jpg",
"attachmentMimeType": "image/jpeg",
"attachmentSize": 245000
}
Send a voice message:
{
"chatId": "uuid",
"senderUserId": "uuid",
"type": "voice",
"attachmentUrl": "https://storage.example.com/audio/voice-note.mp3",
"attachmentName": "voice-note.mp3",
"attachmentMimeType": "audio/mpeg",
"attachmentSize": 125000,
"attachmentDuration": 15
}
Send a file attachment:
{
"chatId": "uuid",
"senderUserId": "uuid",
"type": "file",
"content": "Here is the document",
"attachmentUrl": "https://storage.example.com/files/document.pdf",
"attachmentName": "contract.pdf",
"attachmentMimeType": "application/pdf",
"attachmentSize": 1500000
}
Response: GET /chat/:chatId/messages
[
{
"id": "uuid",
"type": "text",
"content": "Hello there",
"sender": { "id": "uuid", "firstName": "John" },
"chat": {
"id": "uuid",
"status": "open",
"createdBy": { "id": "uuid", "email": "creator@example.com" },
"shippingTender": null
},
"createdAt": "2025-01-01T10:00:00Z"
},
{
"id": "uuid",
"type": "image",
"content": "Check out this photo",
"attachmentUrl": "https://storage.example.com/images/photo.jpg",
"attachmentName": "photo.jpg",
"attachmentMimeType": "image/jpeg",
"attachmentSize": 245000,
"sender": { "id": "uuid", "firstName": "Jane" },
"chat": {
"id": "uuid",
"status": "open",
"shippingTender": {
"id": "uuid",
"status": "open",
"expiresAt": "2026-01-01T22:00:00.000Z",
"order": { "id": "uuid" },
"selectedCarrierUser": null,
"offers": []
}
},
"createdAt": "2025-01-01T10:01:00Z"
},
{
"id": "uuid",
"type": "voice",
"attachmentUrl": "https://storage.example.com/audio/voice-note.mp3",
"attachmentDuration": 15,
"sender": { "id": "uuid", "firstName": "John" },
"chat": {
"id": "uuid",
"status": "open",
"shippingTender": null
},
"createdAt": "2025-01-01T10:02:00Z"
}
,
{
"id": "uuid",
"type": "event",
"eventType": "shipment_picked_up",
"payload": {
"shipmentId": "uuid",
"orderId": "uuid",
"status": "in_transit",
"pickedUpAt": "2025-01-01T10:03:00Z"
},
"sender": null,
"chat": {
"id": "uuid",
"status": "open",
"shippingTender": null
},
"createdAt": "2025-01-01T10:03:00Z"
}
]
Event messages: The system may insert chat messages with type="event". For these messages, sender may be null, and eventType + payload describe the business event.
Current eventType values: order_created, order_status_changed, order_paid, payment_failed, order_cancelled, shipping_tender_created, shipping_tender_offer_submitted, shipping_tender_awarded, shipment_created, shipment_carrier_assigned, shipment_updated, shipment_picked_up, shipment_delivered, sample_request_created, sample_request_approved, sample_request_rejected, sample_request_carrier_confirmed_delivery, sample_request_completed.
POST /chat/:chatId/attachment?type=image
Upload an attachment file for a chat. Returns metadata to use with POST /chat/message.
Request (form-data)
file: [binary file data]
Query param: type = image | video | audio | voice | file (default: file)
Response
{
"attachmentUrl": "http://localhost:3000/public/chat-attachments/chat-uuid/images/abc123.jpg",
"attachmentName": "photo.jpg",
"attachmentMimeType": "image/jpeg",
"attachmentSize": 245000,
"type": "image"
}
Usage: First upload the file using this endpoint, then use the response data to send a message with the attachment via POST /chat/message.
File storage: Files are stored in public/chat-attachments/{chatId}/{type}/ with unique filenames.
Max file size: 50MB
POST /chat/:chatId/subscribe
Subscribe a device to receive Firebase Cloud Messaging (FCM) notifications for a chat.
Request
{
"deviceToken": "fcm-device-token-from-client"
}
Response
{ "success": true }
DELETE /chat/:chatId/subscribe
Unsubscribe a device from chat notifications.
Request
{
"deviceToken": "fcm-device-token-from-client"
}
Response
{ "success": true }
Real-time notifications: When a message is sent, an FCM notification is automatically pushed to the topic chat_{chatId} (dashes replaced with underscores). Clients should subscribe to this topic to receive real-time updates.
FCM notification payload:
{
"notification": { "title": "Sender Name", "body": "Message content" },
"data": {
"type": "chat_message",
"chatId": "uuid",
"message": "{...}" // Full message entity as JSON string
}
}
// Parsed message object:
{
"id": "uuid",
"type": "text|image|video|audio|voice|file|event",
"content": "Message text (nullable)",
"eventType": "order_created|order_status_changed|order_paid|payment_failed|order_cancelled|shipping_tender_created|shipping_tender_offer_submitted|shipping_tender_awarded|shipment_created|shipment_carrier_assigned|shipment_updated|shipment_picked_up|shipment_delivered|sample_request_created|sample_request_approved|sample_request_rejected|sample_request_carrier_confirmed_delivery|sample_request_completed (nullable)",
"payload": { },
"attachmentUrl": "https://...",
"attachmentName": "file.jpg",
"attachmentMimeType": "image/jpeg",
"attachmentSize": 245000,
"attachmentDuration": null,
"createdAt": "2025-01-01T10:00:00Z",
"sender": {
"id": "uuid",
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com"
}
}
Message types: text (default) | image | video | audio | voice | file | event
Attachments: For non-text messages, provide attachmentUrl (required), attachmentName, attachmentMimeType, attachmentSize (bytes), and attachmentDuration (seconds, for audio/video/voice).
Note: Provide listingId to associate the chat with a specific listing.