Generated from NestJS controllers under src/.
Most endpoints are protected by a global JWT guard. Send an Authorization: Bearer <token> header.
Public endpoints are marked with @Public() in code. In particular, the following are public:
/auth/login/auth/register/user/auth/register/companyAdmin-only endpoints: Marked with @AdminOnly(). Access requires a token whose payload includes isAdmin: true.
Admin or Carrier endpoints: Marked with @AdminOrCarrier(). Access requires a token whose payload includes isAdmin: true OR isCarrier: true.
JWT payload includes: { sub: userId, email, isAdmin, isCarrier }. The guard attaches req.user = { id, email, isAdmin, isCarrier }.
Response: GET /notifications
[
{
"id": "uuid",
"title": "Payment received",
"message": "Order ORD-123 has been paid successfully",
"type": "order_paid",
"read": false,
"order": { "id": "uuid" },
"createdAt": "2025-01-01T10:00:00Z"
}
]
Request: PATCH /notifications/:id/read
{ "id": "uuid", "read": true, "readAt": "2025-01-01T11:00:00Z" }
Notification types: order_paid, payment_failed, shipment_created, shipment_updated, order_status_changed, material_listing_added.
POST /notifications/subscribe
Subscribe a device to receive Firebase Cloud Messaging (FCM) notifications for the authenticated user's topic.
Request
{
"deviceToken": "fcm-device-token-from-client"
}
Response
{ "success": true }
User topic: Devices are subscribed to the topic user_{userId} (dashes replaced with underscores). This allows the user to receive push notifications for events like new chats.
{
"message": "Hello World!"
}
Request: POST /auth/request-otp
{
"email": "user@example.com"
}
Response
{
"success": true
}
Request: POST /auth/login (OTP)
{
"email": "user@example.com",
"otp": "000000"
}
Response
{
"accessToken": "<jwt>",
"user": {
"id": "uuid",
"email": "user@example.com",
"isAdmin": false,
"isCarrier": false
}
}
Note: Call /auth/request-otp first to receive a 6-digit code via email, then submit it to /auth/login. OTPs expire after OTP_TTL_MINUTES (default 10 minutes) and can be used once.
Request: POST /auth/login/google
{
"email": "user@example.com",
"googleId": "google-oauth-sub"
}
Response
{
"accessToken": "<jwt>",
"user": {
"id": "uuid",
"email": "user@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"isAdmin": false,
"isCarrier": false
}
}
Request: POST /auth/request-otp
// 404 Not Found
{ "statusCode": 404, "message": "User not found", "error": "Not Found" }
Request: POST /auth/login
// 401 Unauthorized (invalid code)
{ "statusCode": 401, "message": "Invalid OTP", "error": "Unauthorized" }
// 401 Unauthorized (expired code)
{ "statusCode": 401, "message": "OTP expired", "error": "Unauthorized" }
Rate limiting recommendation: To prevent abuse, consider allowing up to 5 OTP requests per email per hour (rolling window) and enforce a 30s cooldown between requests. Lock or slow down after repeated invalid login attempts.
Request: POST /auth/register/user
{
"email": "new@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"countryId": "uuid-optional",
"googleId": "google-oauth-sub-optional",
"registerType": "email" // or "google", defaults to "email" if omitted
}
Request: POST /auth/register/company-user
Create a new user and a company owned by that user, then returns a JWT.
{
"email": "company.owner@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+966500000000",
"userCountryId": "uuid-optional",
"googleId": "google-oauth-sub-optional",
"registerType": "email",
"companyName": "Acme Inc",
"vatNumber": "TR1234567",
"website": "acme.com",
"companyCountryId": "uuid-optional"
}
Request: POST /auth/register/carrier-user
Create a new carrier user (isCarrier: true), then returns a JWT.
{
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966500000000",
"countryId": "uuid-optional",
"googleId": "google-oauth-sub-optional",
"registerType": "email"
}
Request: POST /auth/register/company
{
"companyName": "Acme Inc",
"vatNumber": "TR1234567",
"website": "acme.com",
"countryId": "uuid-optional"
}
Request: POST /auth/admin/register/company-user Admin only
Admin creates a new company owner user and company. Returns the created objects (no JWT).
{
"email": "company.owner@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+966500000000",
"userCountryId": "uuid-optional",
"registerType": "email",
"companyName": "Acme Inc",
"vatNumber": "TR1234567",
"website": "acme.com",
"companyCountryId": "uuid-optional"
}
Request: POST /auth/admin/register/carrier-user Admin only
Admin creates a carrier user (isCarrier: true). Returns the created user (no JWT).
{
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966500000000",
"countryId": "uuid-optional"
}
Response (register)
{
"id": "uuid",
"email": "new@example.com" // or company object depending on route
}
Response: GET /auth/me
{
"user": { "id": "uuid", "email": "user@example.com", "isAdmin": false }
}
Account status: Any endpoint protected by JWT will reject users whose accountStatus is not active (e.g. suspended or deleted).
GET /admin/dashboard
Returns high-level metrics for the platform. Requires an admin JWT token (isAdmin: true in payload).
Response
{
"totalUsers": 123,
"totalCompanies": 45,
"totalListings": 67,
"activeListings": 12,
"totalIncome": 999.99
}
Total income: Calculated as the sum of amount for all payments with status succeeded. The value is returned as a number.
GET /admin/carriers
Returns all users who have isCarrier: true.
Response
[
{
"id": "uuid",
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966501234567",
"isCarrier": true,
"accountStatus": "active"
}
]
POST /admin/welcome-users
Sends the platform welcome email to all users in the database.
Request
{}
Response
{
"attempted": 123,
"sent": 123
}
PATCH /admin/carriers
Create a carrier user (admin panel).
Request
{
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966500000000",
"countryId": "uuid-optional"
}
Response
{
"id": "uuid",
"email": "carrier@example.com",
"firstName": "John",
"lastName": "Doe",
"phone": "+966500000000",
"isCarrier": true,
"accountStatus": "active"
}
PATCH /admin/carrier-status
Set or revoke carrier role for a user.
Request
{
"userId": "uuid",
"isCarrier": true
}
Response
{
"id": "uuid",
"email": "user@example.com",
"isCarrier": true
}
Note: Setting isCarrier: true grants the user carrier privileges, allowing them to update shipment statuses. The user can then create a carrier profile via /carrier-profiles.
GET /admin/profit/reports Admin only
Returns aggregated platform profit (fees) grouped by period and by side (buyer / seller).
Query params
period - weekly | monthly | yearly (required)from - ISO date/time or date (optional)to - ISO date/time or date (optional)Example
GET /admin/profit/reports?period=monthly&from=2026-01-01&to=2026-12-31
Response
[
{
"period": "2026-01-01T00:00:00.000Z",
"side": "buyer",
"totalQuantity": 1500,
"totalSar": 15,
"totalPoints": 1500
},
{
"period": "2026-01-01T00:00:00.000Z",
"side": "seller",
"totalQuantity": 1500,
"totalSar": 15,
"totalPoints": 1500
}
]
Request: POST /users
{
"email": "user@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+90...",
"accountStatus": "active",
"subscriptionTier": "pro",
"countryId": "uuid",
"isAdmin": false,
"isCarrier": false,
"googleId": "google-oauth-sub-optional",
"registerType": "email" // or "google", defaults to "email" if omitted
}
Response: GET /users?page=1&limit=20
[
{ "id": "uuid", "email": "user@example.com", "firstName": "Ada", "lastName": "Lovelace", "isAdmin": false, "isCarrier": false, "country": null }
]
Response: GET /users/:id
{ "id": "uuid", "email": "user@example.com", "firstName": "Ada", "lastName": "Lovelace", "isAdmin": false, "isCarrier": false, "country": null }
Response (create/get)
{
"id": "uuid",
"email": "user@example.com",
"firstName": "Ada",
"lastName": "Lovelace"
}
Request: PATCH /users/:id
{
"firstName": "Grace",
"subscriptionTier": "enterprise"
}
Request: POST /companies/admin/create-company-and-user Admin only
{
// User fields
"email": "newuser@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": "+90...", // optional
"userCountryId": "uuid-optional", // optional
// Company fields
"companyName": "Acme Inc",
"vatNumber": "TR1234567", // optional
"website": "acme.com", // optional
"companyCountryId": "uuid-optional" // optional
}
Response
{
"user": {
"id": "uuid",
"email": "newuser@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"isAdmin": false
},
"company": {
"id": "uuid",
"companyName": "Acme Inc",
"verificationStatus": "verified",
"owner": { "id": "uuid" }
}
}
Request: POST /companies
{
"companyName": "Acme Inc",
"vatNumber": "TR1234567",
"website": "acme.com",
"verificationStatus": "pending",
"countryId": "uuid"
}
Response: GET /companies?page=1&limit=20
[
{ "id": "uuid", "companyName": "Acme Inc", "website": "acme.com", "owner": { "id": "uuid", "email": "owner@example.com" } }
]
Response: GET /companies/:id
{ "id": "uuid", "companyName": "Acme Inc", "website": "acme.com", "owner": { "id": "uuid", "email": "owner@example.com" } }
Response
{
"id": "uuid",
"companyName": "Acme Inc"
}
Response: GET /companies/me Requires Bearer token
[
{ "id": "uuid", "companyName": "Acme Inc", "owner": { "id": "uuid" }, "country": null }
]
Request: POST /company-addresses
{
"street": "Main St 1",
"city": "Istanbul",
"state": "",
"postalCode": "34000",
"countryId": "uuid",
"isDefault": true,
"companyId": "uuid"
}
Request: POST /user-addresses
{
"street": "Main St 1",
"city": "Istanbul",
"postalCode": "34000",
"countryId": "uuid",
"isDefault": false,
"userId": "uuid"
}
Request: POST /materials
{
"name": "Aluminum",
"unitOfMeasure": "kg",
"categoryId": "uuid",
"i18n": {
"ar": { "name": "ألمنيوم", "unitOfMeasure": "كغم" },
"en": { "name": "Aluminum", "unitOfMeasure": "kg" }
}
}
Query Parameters:
page - Page number (default: 1)limit - Items per page (default: 20, max: 100)categoryId - Filter by category ID (optional)lang - Optional language code (ar or en) to return localized name, unitOfMeasure, and category name when availableResponse: GET /materials?page=1&limit=20&lang=ar
[ { "id": "uuid", "name": "Aluminum", "unitOfMeasure": "kg" } ]
Response: GET /materials/:id?lang=en
{ "id": "uuid", "name": "Aluminum", "unitOfMeasure": "kg" }
Localization on create: The optional i18n object allows seeding translations at creation time. You can later update translations via /localization/translate.
Localization on read: When lang is provided, only that language is applied as an override to the base fields. When lang is omitted, materials (and their categories) include an i18n object with available translations for name and unitOfMeasure (currently ar and en).
Favorites
Request: POST /materials/:id/favorite Requires Bearer token
// marks material as favorite for the authenticated user
// response: the created favorite link (id, user, material)
Request: GET /materials/favorites?lang=ar Requires Bearer token
[ { "id": "uuid", "name": "Aluminum" } ] // array of Material, localized when lang is provided
Request: GET /materials/favorites/user/:userId?lang=en Admin only
[ { "id": "uuid", "name": "Aluminum" } ] // array of Material favorited by the specified user, localized when lang is provided
Request: POST /materials/favorites/user/:userId Admin only
{
"materialIds": ["mat-uuid-1", "mat-uuid-2"]
}
// response: array of Materials that are now in the user's favorites
Request: DELETE /materials/:id/favorite Requires Bearer token
{ "materialId": "uuid" }
Request: POST /materials/:id/favorite/admin Admin only
{
"userId": "uuid" // user to mark this material as favorite for
}
// response: the created (or existing) favorite link (id, user, material)
Request: POST /material-categories
{
"name": "Metals",
"i18n": {
"ar": { "name": "معادن" },
"en": { "name": "Metals" }
}
}
Request: PATCH /material-categories/:id
{
"name": "Precious Metals",
"i18n": {
"ar": { "name": "معادن ثمينة" },
"en": { "name": "Precious Metals" }
}
}
Response: GET /material-categories?lang=ar
[
{
"id": "uuid",
"name": "Metals",
"materials": [
{ "id": "mat-1", "name": "Aluminum", "unitOfMeasure": "kg" },
{ "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).
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:
page - Page number (default: 1)limit - Items per page (default: 20, max: 100)materialId - Filter by material ID (optional)companyId - Filter by company ID (optional)userId - Filter by user ID (optional)isBiddable - Filter by biddable status (true/false) (optional)condition - Filter by listing condition (optional). One of first_grade | second_grade | third_grade.materialColor - Filter by material color (optional). One of black | blue | green | orange | purple | red | white | yellow.lang - Optional language code (ar or en) to return localized fields when available (listing title, description, unitOfMeasure; material name and unit; category name). Applies to /listings, /listings/:id and the /listings/me* endpoints.Localization on read: When lang is provided, only that language overrides the base fields. When lang is omitted, listings include an i18n object for title, description and unitOfMeasure, and nested material and material.category also include their own i18n objects with all stored translations (currently ar and en).
/listings/me behavior: If the authenticated user owns at least one company, the endpoint returns listings for those companies. Otherwise it returns listings created by the user (sellerUser).
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.
Request: POST /listings/photos
{
"listingId": "uuid",
"url": "https://cdn.example.com/pic.jpg",
"sortOrder": 0,
"isPrimary": true
}
Response: GET /listings/:listingId/photos
[ { "id": "uuid", "url": "https://cdn.example.com/pic.jpg", "isPrimary": true, "sortOrder": 0 } ]
Request: POST /listings/attributes
{
"listingId": "uuid",
"attrKey": "اللون",
"attrValue": "فضي"
}
Response: GET /listings/:listingId/attributes
[ { "id": "uuid", "attrKey": "اللون", "attrValue": "فضي" } ]
Allowed attrKey values (enum ListingAttributeKey):
النقاءالمصدرالأحجامالشوائباللونالرطوبةالحالةمتوسط وزن البالةالألوانالمعادنالمحتوىالتصنيفAuthentication: Send Authorization: Bearer <token>.
Content-Type: multipart/form-data
Field name: file
Validation: /uploads/image accepts only images (mimetype starts with image/).
Limits: Images up to 10 MB. Files up to 20 MB.
Response: Both endpoints return a JSON body containing a public URL.
{
"url": "https://<host>/public/images/<filename>" // or /public/files/<filename>
}
Example cURL
curl -H "Authorization: Bearer <jwt>" \
-F "file=@./photo.jpg" \
http://localhost:3000/uploads/image
curl -H "Authorization: Bearer <jwt>" \
-F "file=@./document.pdf" \
http://localhost:3000/uploads/file
Files are stored under ./public/images and ./public/files and served at /public.
Request: POST /advertisements
{
"title": "Homepage Banner",
"imageUrl": "https://cdn.example.com/banner.jpg",
"linkUrl": "https://example.com",
"active": true
}
Query Parameters (GET /advertisements):
activeOnly - Filter active ads only (default: true)Response: GET /advertisements
[
{ "id": "uuid", "title": "Homepage Banner", "active": true }
]
Response: GET /advertisements/:id
{ "id": "uuid", "title": "Homepage Banner", "active": true }
Request: PATCH /advertisements/:id/toggle-active
// toggles the advertisement active status
Request: POST /bids
{
"amount": "1500.00",
"listingId": "uuid",
// Provide either bidderCompanyId OR bidderUserId (or omit to auto-fill from token)
"bidderCompanyId": "uuid"
// "bidderUserId": "uuid"
}
Note: If neither bidderCompanyId nor bidderUserId is provided, the API will use the authenticated user's owned company if available, otherwise the user.
Real-time bid updates (Firebase FCM topics)
When a new bid is created, the backend publishes an FCM topic message with data.type = bid_added to the listing topic. Clients can subscribe to receive these events and update the UI immediately.
POST /bids/listing/:listingId/subscribe Requires Bearer token
Subscribe a device token to the listing bid topic.
Request
{
"deviceToken": "fcm-device-token-from-client"
}
Response
{ "success": true }
DELETE /bids/listing/:listingId/subscribe Requires Bearer token
Unsubscribe a device token from the listing bid topic.
Request
{
"deviceToken": "fcm-device-token-from-client"
}
Response
{ "success": true }
Listing topic: Devices are subscribed to listing_{listingId} (dashes replaced with underscores). On new bids, the push payload includes:
{
"notification": { "title": "New bid", "body": "A new bid was placed: 1500.00" },
"data": {
"type": "bid_added",
"listingId": "uuid",
"bid": "{\"id\":\"uuid\",\"amount\":\"1500.00\",\"createdAt\":\"2026-01-01T10:00:00.000Z\",\"listingId\":\"uuid\",\"bidderCompanyId\":\"uuid\",\"bidderUserId\":null}"
}
}
Response: GET /bids/:id
{
"id": "uuid",
"amount": "1500.00",
"listing": { "id": "uuid" },
"bidderCompany": { "id": "uuid", "companyName": "Acme Inc" },
"bidderUser": null
}
Response: GET /bids/listing/:listingId
[
{
"id": "uuid",
"amount": "1500.00",
"listing": { "id": "uuid" },
"bidderCompany": { "id": "uuid", "companyName": "Acme Inc" },
"bidderUser": null
}
]
Request: POST /orders
{
// Provide buyerCompanyId OR buyerUserId (or omit to auto-fill from token)
"buyerCompanyId": "uuid",
// "buyerUserId": "uuid",
// Provide sellerCompanyId OR sellerUserId (or omit to derive from listings)
// "sellerCompanyId": "uuid",
// "sellerUserId": "uuid",
"createdByUserId": "uuid", // optional (defaults to current user)
"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" } ]
}
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.
Request: POST /payments
{
"orderId": "uuid",
"amount": "100.00",
"method": "card",
"transactionId": "txn_123"
}
Request: PATCH /payments/:id/status/:status
// status in path e.g. authorized, settled
Response: GET /payments/:id
{ "id": "uuid", "order": {"id": "uuid"}, "amount": "100.00", "status": "succeeded" }
Response: GET /payments/order/:orderId
[ { "id": "uuid", "order": {"id": "uuid"}, "amount": "100.00", "status": "succeeded" } ]
Request: POST /payments/thawani/:orderId/checkout
{
"successUrl": "https://app.example.com/pay/success",
"cancelUrl": "https://app.example.com/pay/cancel"
}
Response
{
"paymentId": "uuid",
"sessionId": "THAWANI_SESSION_ID",
"redirectUrl": "https://uatcheckout.thawani.om/pay/THAWANI_SESSION_ID?key=pk_..."
}
Webhook: POST /payments/thawani/webhook
{
"data": {
"id": "THAWANI_SESSION_ID",
"client_reference_id": "payment-uuid",
"status": "paid"
}
}
Thawani config via env: THAWANI_SECRET_KEY, THAWANI_PUBLISHABLE_KEY, THAWANI_MODE (uat|prod).
Redirect users to redirectUrl to complete payment. Webhook updates payment and order status.
Request: POST /payments/edfapay/:orderId/checkout
{
"currency": "SAR",
"description": "Order Payment",
"termUrl3ds": "https://app.example.com/pay/edfapay/return",
"payer": {
"firstName": "John",
"lastName": "Doe",
"address": "King Saud Rd 1",
"country": "SA",
"city": "Riyadh",
"zip": "12221",
"email": "john@example.com",
"phone": "966501234567",
"ip": "176.44.76.222"
}
}
Response
{
"paymentId": "uuid",
"redirectUrl": "https://pay.edfapay.com/merchant/checkout/Order.../..."
}
Webhook: POST /payments/edfapay/webhook Content-Type: form-data
action=SALE
result=SUCCESS
status=SETTLED
order_id=<paymentId>
trans_id=<platform id>
amount=100.00
currency=SAR
descriptor=... (optional)
EdfaPay config via env: EDFAPAY_API_BASE (default https://api.edfapay.com), EDFAPAY_MERCHANT_ID, EDFAPAY_PASSWORD, EDFAPAY_CURRENCY (default SAR).
The server builds the required initiate signature and redirects the user to the EdfaPay checkout page. The webhook should return plain text OK upon successful processing.
Request: POST /shipments
{
"orderId": "uuid",
"carrierUserId": "uuid-optional", // Assign carrier user on creation
"carrier": "DHL", // Optional external carrier name
"trackingNumber": "TRACK123",
"status": "in_transit",
"shippedAt": "2025-01-01T10:00:00Z"
}
Response: GET /shipments/order/:orderId
[
{
"id": "uuid",
"order": { "id": "uuid" },
"carrierUser": { "id": "uuid", "firstName": "John", "lastName": "Doe" },
"carrier": "DHL",
"status": "in_transit"
}
]
Response: GET /shipments/:id
{
"id": "uuid",
"order": { "id": "uuid" },
"carrierUser": { "id": "uuid", "firstName": "John", "lastName": "Doe", "email": "carrier@example.com" },
"carrier": "DHL",
"trackingNumber": "TRACK123",
"status": "in_transit"
}
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).
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.
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.
Request: POST /countries
{
"countryCode": "TR",
"countryName": "Türkiye"
}
Response: GET /countries?page=1&limit=20
[ { "id": "uuid", "countryCode": "TR", "countryName": "Türkiye" } ]
Response: GET /countries/:id
{ "id": "uuid", "countryCode": "TR", "countryName": "Türkiye" }
Request: PATCH /countries/:id
{
"countryName": "Turkey"
}
Request: POST /country-settings
{
"countryId": "uuid",
"key": "currency",
"value": "TRY"
}
Response: GET /country-settings/:countryId
[ { "id": "uuid", "country": {"id": "uuid"}, "key": "currency", "value": "TRY" } ]
The API uses message keys for localization. The backend no longer returns human-readable message strings.
Success payloads: If a response previously included a top-level message string, it now returns messageKey instead.
Error payloads: All HTTP errors return messageKey instead of message.
Success message example
Before (old behavior):
{ "success": true, "message": "Subscribed" }
Now:
{ "success": true, "messageKey": "messages.subscribed" }
Not found error example
// 404 Not Found
{
"statusCode": 404,
"messageKey": "errors.user_not_found",
"error": "Not Found"
}
Validation error example
When validation fails, messageKey is errors.validation_failed and a details array is included.
// 400 Bad Request
{
"statusCode": 400,
"messageKey": "errors.validation_failed",
"error": "Bad Request",
"details": [
"email must be an email",
"firstName must be a string"
]
}
If the backend throws/returns a key-like string (no spaces and contains a dot, e.g. errors.user_not_found), it is passed through as-is.
Otherwise, the backend generates keys by normalizing the text:
errors.<normalized_message>messages.<normalized_message>Normalization: lowercase, replace non-alphanumeric with underscores, collapse repeats, trim underscores, max 120 chars.
Recommended frontend behavior: treat messageKey as the translation lookup key. Do not display the raw key to end users unless as a fallback.
Keys generated from literal message strings found in controllers:
errors.only_image_files_are_allowed ("Only image files are allowed")errors.no_file_uploaded ("No file uploaded")errors.no_file_provided ("No file provided")errors.devicetoken_is_required ("deviceToken is required")Request: POST /localization/keys
{
"keyName": "welcome.title",
"description": "Homepage title"
}
Response: GET /localization/keys
[ { "id": "uuid", "keyName": "welcome.title", "description": "Homepage title" } ]
Request: POST /localization/translate
{
"keyName": "welcome.title",
"languageCode": "en",
"value": "Welcome"
}
Response: GET /localization/lang/:languageCode
{ "languageCode": "en", "translations": { "welcome.title": "Welcome" } }
Request: POST /tickets
{
"subject": "Issue with order",
"createdByUserId": "uuid",
"priority": "medium"
}
Response: GET /tickets?page=1&limit=20
[ { "id": "uuid", "subject": "Issue with order", "priority": "medium", "status": "open" } ]
Response: GET /tickets/:id
{ "id": "uuid", "subject": "Issue with order", "priority": "medium", "status": "open" }
Request: PATCH /tickets/:id/status
{
"status": "in_progress"
}
Request: POST /tickets/assign
{
"ticketId": "uuid",
"assignedToUserId": "uuid"
}
Request: POST /tickets/message
{
"ticketId": "uuid",
"senderUserId": "uuid",
"content": "Following up on this ticket."
}
Response: GET /tickets/:id/messages
[ { "id": "uuid", "senderUserId": "uuid", "content": "Following up on this ticket." } ]
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.