Invare API Documentation

Generated from NestJS controllers under src/.

Credit (الآجل) System Documentation

Authentication haha

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:

Admin-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 }.

Sample Requests /sample-requests

Flow overview:

  1. Admin config: Admin sets Sample Order Settings (carrierAccountUserId and pricePerKgSar).
  2. Buyer creates request: Buyer creates a sample request and points are held (frozen) in escrow.
  3. Seller approves/rejects: Seller approves (moves to approved) or rejects (refunds escrow).
  4. Carrier confirms delivery: Carrier (the account configured in settings) confirms delivery (moves to carrier_confirmed_delivery).
  5. Buyer confirms delivery: Buyer confirms delivery (moves to 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: pendingapprovedcarrier_confirmed_deliverycompleted (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
POST /sample-requests Requires Bearer token (buyer)
GET /sample-requests/my?page={page}&limit={limit} Requires Bearer token (buyer)
GET /sample-requests/incoming?page={page}&limit={limit} Requires Bearer token (seller)
GET /sample-requests/carrier?page={page}&limit={limit} Requires Bearer token (carrier)
PATCH /sample-requests/:id/approve Requires Bearer token (seller)
PATCH /sample-requests/:id/reject Requires Bearer token (seller)
PATCH /sample-requests/:id/carrier-confirm-delivery Requires Bearer token (carrier account configured in settings)
PATCH /sample-requests/:id/confirm-delivery Requires Bearer token (buyer)
Examples

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.

Sample Order Settings /admin/sample-order-settings

GET /admin/sample-order-settings Admin only
POST /admin/sample-order-settings Admin only
Examples

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).

Platform Config /admin/platform-config

GET /admin/platform-config Admin only
POST /admin/platform-config Admin only
Examples

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).

Notifications /notifications

GET /notifications Requires Bearer token
PATCH /notifications/:id/read Requires Bearer token
PATCH /notifications/read-all Requires Bearer token
POST /notifications/subscribe Requires Bearer token
Examples

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.

Root

GET /
Example Response
{
  "message": "Hello World!"
}

Auth /auth

POST /auth/login
POST /auth/login/google
POST /auth/request-otp
POST /auth/register/user
POST /auth/register/company-user
POST /auth/register/carrier-user
POST /auth/register/company Requires Bearer token
POST /auth/admin/register/company-user Admin only
POST /auth/admin/register/carrier-user Admin only
GET /auth/me Requires Bearer token
Examples

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
  }
}

Errors

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).

Admin /admin

GET /admin/dashboard Admin only
GET /admin/carriers Admin only
PATCH /admin/carriers Admin only
PATCH /admin/carrier-status Admin only
POST /admin/welcome-users Admin only
GET /admin/profit/reports?period={weekly|monthly|yearly}&from={YYYY-MM-DD}&to={YYYY-MM-DD} Admin only
Examples

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

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
  }
]

Users /users

POST /users
GET /users/profile/me Requires Bearer token — returns idImageUrl
POST /users/me/id-image Requires Bearer token — multipart/form-data, field: idImage
GET /users?page={page}&limit={limit}
GET /users/:id
PATCH /users/me Requires Bearer token
PATCH /users/:id
DELETE /users/:id
Examples

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"
}

Companies /companies

POST /companies
POST /companies/admin/create-company-and-user Admin only
GET /companies?page={page}&limit={limit}
GET /companies/:id
GET /companies/me Requires Bearer token — includes commercialRegFileUrl
PATCH /companies/:id
DELETE /companies/:id
POST /companies/:id/commercial-reg Requires Bearer token (owner) — multipart/form-data, field: file (image or PDF)
Examples

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 }
]

Company Addresses /company-addresses

POST /company-addresses
GET /company-addresses/company/:companyId
DELETE /company-addresses/:id
Examples

Request: POST /company-addresses

{
  "street": "Main St 1",
  "city": "Istanbul",
  "state": "",
  "postalCode": "34000",
  "countryId": "uuid",
  "isDefault": true,
  "companyId": "uuid"
}

User Addresses /user-addresses

POST /user-addresses
GET /user-addresses/user/:userId
DELETE /user-addresses/:id
Examples

Request: POST /user-addresses

{
  "street": "Main St 1",
  "city": "Istanbul",
  "postalCode": "34000",
  "countryId": "uuid",
  "isDefault": false,
  "userId": "uuid"
}

Materials /materials

POST /materials
GET /materials?page={page}&limit={limit}&categoryId={categoryId}&lang={lang}
GET /materials/:id?lang={lang}
PATCH /materials/:id
DELETE /materials/:id
GET /materials/favorites?lang={lang} Requires Bearer token
GET /materials/favorites/user/:userId?lang={lang} Admin only
POST /materials/favorites/user/:userId Admin only
POST /materials/:id/favorite Requires Bearer token
DELETE /materials/:id/favorite Requires Bearer token
POST /materials/:id/favorite/admin Admin only
GET /materials/permits/me Requires Bearer token — list my material permits
POST /materials/:id/permit Requires Bearer token — multipart/form-data, field: permitFile (image or PDF, optional), notes (optional)
DELETE /materials/:id/permit Requires Bearer token
Examples

Request: POST /materials

{
  "name": "Aluminum",
  "unitOfMeasure": "kg",
  "categoryId": "uuid",
  "i18n": {
    "ar": { "name": "ألمنيوم", "unitOfMeasure": "كغم" },
    "en": { "name": "Aluminum", "unitOfMeasure": "kg" }
  }
}

Query Parameters:

Response: 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)

Material Categories /material-categories

POST /material-categories
GET /material-categories?lang={lang}
PATCH /material-categories/:id
DELETE /material-categories/:id
Examples

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).

Carrier Vehicle Profile /carrier

PATCH /carrier/vehicle Requires Bearer token (multipart/form-data)
Examples

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.

Store Profiles /store-profiles

POST /store-profiles Requires Bearer token (multipart/form-data)
GET /store-profiles/user/:userId
Examples

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).

Market Prices /market-prices

GET /market-prices/material-state-changes?lang={lang} Public
GET /market-prices/material-state-history?materialId={materialId}&materialsState={materialsState}&period={period}&from={from}&to={to}&lang={lang} Public
Examples

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).

Services /services

POST /services Requires Bearer token (multipart/form-data)
PATCH /services/:id Requires Bearer token (multipart/form-data)
GET /services?page={page}&limit={limit}&userId={userId}&categoryId={categoryId}&materialId={materialId}&serviceTypeId={serviceTypeId}&availability={availability}&mode={mode}&minRating={minRating}&favoritesOnly={favoritesOnly}&lang={lang}
GET /services/me?page={page}&limit={limit}&lang={lang} Requires Bearer token
GET /services/:id?lang={lang}
GET /services/types?lang={lang} Public
GET /services/types/:id?lang={lang} Public
POST /services/types Admin only
PATCH /services/types/:id Admin only
DELETE /services/types/:id Admin only
POST /services/:id/ratings Requires Bearer token
GET /services/:id/ratings?page={page}&limit={limit} Public
DELETE /services/ratings/:ratingId Requires Bearer token
Examples

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)

Service Orders /service-orders

POST /service-orders/request Requires Bearer token
PATCH /service-orders/:id/accept Requires Bearer token (provider)
PATCH /service-orders/:id/reject Requires Bearer token (provider)
POST /service-orders/:id/pay Requires Bearer token (buyer)
PATCH /service-orders/:id/done Requires Bearer token (buyer/provider)
PATCH /service-orders/:id/cancel Requires Bearer token (buyer)
GET /service-orders/my-purchases?page={page}&limit={limit} Requires Bearer token
GET /service-orders/my-sales?page={page}&limit={limit} Requires Bearer token
Examples

POST /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"
}

Listings /listings

POST /listings
POST /listings/update-requests Requires Bearer token
GET /listings/update-requests/seller?page={page}&limit={limit}&status={status} Requires Bearer token (seller only)
GET /listings/update-requests/listing/:listingId?page={page}&limit={limit}&status={status} Requires Bearer token
POST /listings/update-requests/:requestId/approve Requires Bearer token (seller only)
POST /listings/update-requests/:requestId/reject Requires Bearer token (seller only)
GET /listings?page={page}&limit={limit}&materialId={materialId}&companyId={companyId}&userId={userId}&isBiddable={isBiddable}&condition={condition}&materialColor={materialColor}&favoritesOnly={favoritesOnly}&lang={lang}
GET /listings/me?page={page}&limit={limit}&status={status}&lang={lang} Requires Bearer token
GET /listings/me/user?page={page}&limit={limit}&status={status}&lang={lang} Requires Bearer token
GET /listings/me/company?page={page}&limit={limit}&status={status}&lang={lang} Requires Bearer token
GET /listings/user/:userId?page={page}&limit={limit}&status={status}&lang={lang} Public — paginated listings for a specific seller user
GET /listings/:id?lang={lang}
PATCH /listings/:id
DELETE /listings/:id
Examples

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:

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.

Listing Media & Attributes /listings

POST /listings/photos
GET /listings/:listingId/photos
PATCH /listings/photos/:id/primary
DELETE /listings/photos/:id
POST /listings/attributes
GET /listings/:listingId/attributes
DELETE /listings/attributes/:id
Examples

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):

Uploads /uploads

POST /uploads/image Requires Bearer token
POST /uploads/file Requires Bearer token
Usage

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.

Advertisements /advertisements

POST /advertisements Admin only
GET /advertisements?activeOnly={true|false} Public
GET /advertisements/:id Public
PATCH /advertisements/:id/toggle-active Admin only
DELETE /advertisements/:id Admin only
Examples

Request: POST /advertisements

{
  "title": "Homepage Banner",
  "imageUrl": "https://cdn.example.com/banner.jpg",
  "linkUrl": "https://example.com",
  "active": true
}

Query Parameters (GET /advertisements):

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

Bids /bids

POST /bids
GET /bids/me/listings?page={page}&limit={limit}&lang={lang?} Requires Bearer token
GET /bids/listing/:listingId
POST /bids/listing/:listingId/subscribe Requires Bearer token
DELETE /bids/listing/:listingId/subscribe Requires Bearer token
GET /bids/:id
DELETE /bids/:id
Examples

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
  }
]

Orders /orders

POST /orders
GET /orders?page={page}&limit={limit}
GET /orders/my?role={buyer|seller|all}&page={page}&limit={limit}
GET /orders/reports/selling?period={yearly|last3m|last6m|custom}&from={ISO?}&to={ISO?}&lang={lang?} Requires Bearer token (seller)
GET /orders/:id
PATCH /orders/:id/status/:status
DELETE /orders/:id
POST /orders/:id/report-problem Buyer only. Blocks auto-completion

Point Checkout & Escrow

PATCH /orders/:id/escrow-hours Carrier Only - Set escrow duration (1-168 hours)
POST /orders/:id/checkout/points Pay with wallet points
POST /orders/:id/confirm-delivery Buyer confirms (non-points only). Points orders use two-step confirmation via /shipments (carrier + buyer)
POST /orders/:id/ship Seller marks as shipped
POST /orders/:id/buyer-complete Buyer completes no-driver orders
POST /orders/:id/cancel Cancel order & refund escrow
Examples

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.

Point Checkout & Escrow Examples

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.

Payments /payments

POST /payments
GET /payments/order/:orderId
GET /payments/:id
PATCH /payments/:id/status/:status
DELETE /payments/:id
POST /payments/thawani/:orderId/checkout
POST /payments/thawani/webhook Public (Thawani)
POST /payments/edfapay/:orderId/checkout
POST /payments/edfapay/webhook Public (EdfaPay)
Examples

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.

Shipments /shipments

POST /shipments
POST /shipments/assign Admin only
GET /shipments/order/:orderId
GET /shipments/my-assignments Admin or Carrier only
GET /shipments/carrier/:carrierUserId Admin only
GET /shipments/:id
POST /shipments/:id/confirm-pickup Admin or Carrier only (must be assigned carrier)
POST /shipments/:id/confirm-delivery Admin or Carrier only (must be assigned carrier). Pays carrier. Does not release escrow or complete order
POST /shipments/:id/buyer-confirm-delivery Buyer only. Completes order
PATCH /shipments/:id Admin or Carrier only
DELETE /shipments/:id Admin only
Examples

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).

Shipping Tenders /shipping-tenders

GET /shipping-tenders/order/:orderId Requires Bearer token
GET /shipping-tenders/:tenderId/top-offers?limit={1..10} Requires Bearer token
POST /shipping-tenders/:tenderId/offers Admin or Carrier only
POST /shipping-tenders/:tenderId/select/:offerId Requires Bearer token (buyer only unless admin)
Examples

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.

Carrier Profiles /carrier-profiles

POST /carrier-profiles Requires Bearer token (carrier)
GET /carrier-profiles/me Requires Bearer token
GET /carrier-profiles Admin only
GET /carrier-profiles/available Requires Bearer token
PATCH /carrier-profiles/me Requires Bearer token (carrier)
DELETE /carrier-profiles/me Requires Bearer token (carrier)
Examples

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.

Users /users

POST /users
GET /users/me Requires Bearer token
PATCH /users/me Requires Bearer token
DELETE /users/me Requires Bearer token
Examples

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 }

Businesses /businesses

POST /businesses Requires Bearer token (company/factory)
GET /businesses/me Requires Bearer token
PATCH /businesses Requires Bearer token (company/factory)
DELETE /businesses/me Requires Bearer token (company/factory)
Examples

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 }

Countries /countries

POST /countries
GET /countries?page={page}&limit={limit}
GET /countries/:id
PATCH /countries/:id
DELETE /countries/:id
Examples

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"
}

Country Settings /country-settings

POST /country-settings
GET /country-settings/:countryId
DELETE /country-settings/:id
Examples

Request: POST /country-settings

{
  "countryId": "uuid",
  "key": "currency",
  "value": "TRY"
}

Response: GET /country-settings/:countryId

[ { "id": "uuid", "country": {"id": "uuid"}, "key": "currency", "value": "TRY" } ]

Localization Message Keys

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.

Examples

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"
  ]
}
Key naming rules

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:

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.

Controller-derived keys

Keys generated from literal message strings found in controllers:

Localization /localization

POST /localization/keys
GET /localization/keys
POST /localization/translate
GET /localization/lang/:languageCode
DELETE /localization/keys/:id
Examples

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" } }

Tickets /tickets

POST /tickets
GET /tickets?page={page}&limit={limit}
GET /tickets/:id
PATCH /tickets/:id/status
POST /tickets/assign
POST /tickets/message
GET /tickets/:id/messages
Examples

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." } ]

Chat /chat

POST /chat
GET /chat/user/:userId
GET /chat/listing/:listingId
PATCH /chat/:chatId/status
POST /chat/message
POST /chat/:chatId/attachment?type=image|video|audio|voice|file
POST /chat/:chatId/subscribe Subscribe to FCM topic
DELETE /chat/:chatId/subscribe Unsubscribe from FCM topic
GET /chat/:chatId/messages

Realtime Chat (Socket.IO)

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:

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
})
Examples

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.