Invare API Documentation

Generated from NestJS controllers under src/.

Authentication

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

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?page={page}&limit={limit}
GET /users/:id
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
PATCH /companies/:id
DELETE /companies/:id
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
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" },
      { "id": "mat-2", "name": "Copper", "unitOfMeasure": "kg" }
    ]
  }
]

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

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}&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/: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,
  "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).

Response: GET /listings?page=1&limit=20

[
  {
    "id": "uuid",
    "title": "Aluminum Scrap",
    "isBiddable": true,
    "status": "active",
    "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/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.


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/:id
PATCH /orders/:id/status/:status
DELETE /orders/:id

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 require carrier confirmation via /shipments
POST /orders/:id/ship Seller marks as shipped
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)
  "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" } ]
}

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 for carrier-set duration (or 12 hours default). Auto-refund if seller doesn't fulfill.

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 by the assigned carrier via:
// POST /shipments/:shipmentId/confirm-delivery

Request: POST /orders/:id/ship (Seller Only)

// No body required - marks order as shipped

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). Releases escrow for points orders
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"
}

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. For points-based orders, this action triggers escrow release to the seller and marks the order as completed.

Request

// No body required

Response

{
  "shipment": { "id": "uuid", "status": "delivered", "deliveredAt": "2025-01-05T14:30: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 added to the chat participants. 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.

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
Examples

Request: POST /chat

{
  "topic": "Order #123 discussion",
  "createdByUserId": "uuid",
  "listingId": "uuid-optional",
  "participantUserIds": ["uuid1", "uuid2"]
}

Response: 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" },
    "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" }
        }
      ]
    },
    "participants": [ { "user": { "id": "uuid", "email": "user@example.com" } } ]
  }
]

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 participants, 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" },
    "participants": [ 
      { "user": { "id": "uuid", "email": "seller@example.com" } },
      { "user": { "id": "uuid", "email": "buyer@example.com" } }
    ]
  }
]

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

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",
  "content": "Message text",
  "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

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.