# Loadboard API Documentation

**Base URL:** `http://your-domain.com/api`  
**Authentication:** Token-based (`Authorization: Token <your_token>`)  
**Content-Type:** `application/json` (use `multipart/form-data` only where stated)

This reference covers **REST endpoints under `/api/`** as defined in `api/urls.py`. The Django admin site (`/admin/`) and HTML pages mounted at the site root (`accounts`, `core` apps) are outside this document.

## Table of contents

1. Standard response envelope  
2. Authentication  
3. Auth — Login, Logout, FCM token & notifications (includes `GET /api/auth/notifications/`)  
4. Shipper API summary  
5. Shipper 1 — Registration & KYC  
6. Shipper 2 — Shipments  
7. Shipper 3 — Bidding  
8. Shipper 4 — Trip lifecycle  
9. Shipper 5 — Tracking & messaging  
10. Shipper 6 — Proof of Delivery  
11. Transporter 1 — Registration, onboarding & documents  
12. Transporter 2 — Vehicle & fleet management  
13. Transporter 3 — Load discovery, filtering & bidding  
14. Transporter 4 — Trip operations  
15. Admin API  
16. Complete endpoint index  
17. Common error responses  
18. Quick start (shipper & transporter)  
19. Shipper 7 — Wallet, payments, invoices & shipment history  
20. Shipper 8 — Trip reviews & transporter ratings  
21. Shipper 9 — Saved addresses & favorite transporters  
22. Transporter 5 — Wallet, ledger, earnings, withdrawals & cash confirmation  
23. Push notifications (FCM data payload)  

---

## Standard Response Envelope

Every API response follows this consistent structure:

```json
{
  "status_code": 200,
  "message": "Human-readable result message.",
  "error": null,
  "data": {}
}
```

| Field | Type | Description |
|---|---|---|
| status_code | integer | HTTP status code mirrored in the body |
| message | string | Human-readable result description |
| error | string \| object \| null | Error detail or validation errors; `null` on success |
| data | object \| array \| null | Response payload; `null` when not applicable |

**Success example:**

```json
{
  "status_code": 201,
  "message": "Shipper registered successfully.",
  "error": null,
  "data": {
    "token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b",
    "user_id": 5,
    "email": "shipper@example.com"
  }
}
```

**Error example:**

```json
{
  "status_code": 400,
  "message": "Validation failed.",
  "error": {
    "email": ["A user with this email already exists."]
  },
  "data": null
}
```

### How examples use the envelope

Almost every handler returns the structure above. In some sections, examples show **only the `data` payload** (for example a bare JSON array of shipments). Interpret that as the contents of **`data`**; wrap it mentally with `status_code`, `message`, and `error: null` to match real responses. Subsections that show `status_code` at the top level match the wire format exactly.

---

## Authentication

**Public (no token):** `POST /api/auth/login/`, `POST /api/shipper/register/`, `POST /api/transporter/register/`.

All other `/api/...` routes expect a valid token in the header:

```
Authorization: Token <your_token>
```

Or:

```
Authorization: Bearer <your_token>
```

---

## Auth — Login, Logout & FCM token

### POST /api/auth/login/

Login with email and password. Works for all user types (Shipper, Transporter, Admin).

**Auth required:** No (public)

**Request body:**

```json
{
  "email": "user@example.com",
  "password": "your_password"
}
```

| Field | Type | Required |
|---|---|---|
| email | string | Yes |
| password | string | Yes |

**Response `200` — `data` also includes `persona_type` for client routing:** one of `SHIPPER`, `ADMIN`, `TRANSPORTER_FLEET_OWNER`, `TRANSPORTER_FLEET_DRIVER`, `TRANSPORTER_INDIVIDUAL_DRIVER`, or `TRANSPORTER` (transporter without a profile or unknown sub-type). Shippers and non-transporter roles have `account_type: null` and `tc_id` / `tc_u_id` null unless a transporter.

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Login successful.",
  "error": null,
  "data": {
    "token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b",
    "user_id": 5,
    "email": "user@example.com",
    "first_name": "Ali",
    "last_name": "Khan",
    "role": "SHIPPER",
    "persona_type": "SHIPPER",
    "account_type": null,
    "tc_id": null,
    "tc_u_id": null
  }
}
```

| Field | Notes |
|---|---|
| role | `SHIPPER`, `TRANSPORTER`, or `ADMIN`; `null` if not set |
| account_type | For transporter login: `DRIVER` or `FLEET_OWNER`; otherwise `null` |
| tc_id | Present for transporter drivers when Traccar device exists |
| tc_u_id | 10-char unique tracker ID for transporter drivers |

**Error `401`:**

```json
{
  "status_code": 401,
  "message": "Invalid email or password.",
  "error": "Invalid credentials.",
  "data": null
}
```

---

### POST /api/auth/logout/

Logout and invalidate the current token.

**Auth required:** Yes

**Request body:** None

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Logged out successfully.",
  "error": null,
  "data": null
}
```

---

### POST /api/auth/forgot-password/

Request a 6-digit numeric password reset verification code sent to the registered email address. Works for all app users on login screen: **Shippers**, **Transporters** (Fleet Owners), **Individual Drivers**, and **Transporter Drivers (Fleet Drivers)**.

**Aliases:** `POST /api/auth/password/forgot/`, `POST /api/shipper/forgot-password/`, `POST /api/transporter/forgot-password/`  
**Auth required:** No (public)

**Request body:**

```json
{
  "email": "driver@example.com"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| email | string | Yes | Registered account email address (case-insensitive) |

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Password reset code has been sent to your email.",
  "error": null,
  "data": {
    "email": "driver@example.com"
  }
}
```

---

### POST /api/auth/verify-otp/

Verify the 6-digit verification code received by email. Returns a `reset_token` that can be passed to the password reset endpoint.

**Aliases:** `POST /api/auth/password/verify-otp/`  
**Auth required:** No (public)

**Request body:**

```json
{
  "email": "driver@example.com",
  "otp": "492018"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| email | string | Yes | Registered account email address |
| otp | string | Yes | 6-digit numeric OTP verification code (also accepts `code`) |

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Verification code verified successfully.",
  "error": null,
  "data": {
    "email": "driver@example.com",
    "reset_token": "qW8sE2x90A_8k...",
    "verified": true
  }
}
```

---

### POST /api/auth/reset-password/

Reset the account password using the verified 6-digit `otp` code or `reset_token`. Updates the user's password, invalidates previous auth tokens, and returns a new session token with user info.

**Aliases:** `POST /api/auth/password/reset/`, `POST /api/shipper/reset-password/`, `POST /api/transporter/reset-password/`  
**Auth required:** No (public)

**Request body:**

```json
{
  "email": "driver@example.com",
  "otp": "492018",
  "new_password": "NewSecurePassword123!"
}
```
*(Or use `"reset_token"` instead of `"otp"`; accepts `"password"` as an alias for `"new_password"`).*

| Field | Type | Required | Notes |
|---|---|---|---|
| email | string | Yes | Registered account email address |
| otp / reset_token | string | Yes | Either the 6-digit OTP or the `reset_token` from verification |
| new_password | string | Yes | New password (minimum 8 characters) |

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Password has been reset successfully.",
  "error": null,
  "data": {
    "token": "a1b2c3d4e5f6...",
    "user_id": 12,
    "email": "driver@example.com",
    "role": "TRANSPORTER",
    "persona_type": "TRANSPORTER_INDIVIDUAL_DRIVER",
    "account_type": "DRIVER"
  }
}
```

---

### PATCH /api/auth/fcm-token/

Register or replace the **Firebase Cloud Messaging** device token for the authenticated user (shipper, transporter, or admin). One token per user; the last successful update wins.

**Auth required:** Yes (any authenticated role)

**Request body:**

```json
{
  "fcm_token": "<FCM registration token from the mobile/web client>"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| fcm_token | string | Yes | Long-lived FCM token (max 4096 chars) |

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "FCM token saved successfully.",
  "error": null,
  "data": {
    "fcm_token_updated": true
  }
}
```

**Server configuration:** set `FIREBASE_CREDENTIALS_PATH` in Django settings to the absolute path of the Firebase **service account** JSON (not the web client config). Push sends are no-ops if the path is unset or the token is missing.

---

### GET /api/auth/notifications/

Return **profile/compliance** notifications in latest-first order for a given `user_id`, paginated with `offset` and fixed `limit=20`.

Excludes trip, bidding, chat, payment, and load-discovery notifications (those are delivered via push and other app surfaces). Includes document expiry alerts (`document_expiring`), document rejection (`document_rejected`), and similar profile events.

**Auth required:** Yes (any authenticated role)

**Query params:**

| Param | Type | Required | Default | Notes |
|---|---|---|---|---|
| user_id | integer | Yes | - | Target user id |
| offset | integer | No | `0` | Zero-based record offset |

**Request payload:** None

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Notifications retrieved successfully.",
  "error": null,
  "data": {
    "user_id": 14,
    "offset": 0,
    "limit": 20,
    "total": 44,
    "count": 20,
    "results": [
      {
        "id": 44,
        "title": "Document expiring",
        "message": "Your Driver license expires in 7 days on 2026-04-22.",
        "type": "document_expiring",
        "user_id": 14,
        "data": {
          "kind": "KYC",
          "document_id": "12",
          "document_type": "DRIVER_LICENSE",
          "expiry_date": "2026-04-22",
          "days_before": "7"
        },
        "seen": false,
        "created_at": "2026-04-15T09:55:49.073000Z",
        "updated_at": "2026-04-15T09:55:49.073000Z"
      }
    ]
  }
}
```

**Error `400`:**
- `user_id is required.`
- `user_id must be a valid integer.`
- `offset must be a valid integer.`
- `offset must be zero or greater.`

**Error `403`:**
- Non-admin requesting notifications of another user.

---

## Shipper API summary

All shipper routes require a user whose role is **SHIPPER**. Responses use the [standard envelope](#standard-response-envelope) unless an example shows only the `data` payload.

| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| POST | `/api/shipper/register/` | No | Register shipper (optional passport file); returns token |
| GET | `/api/shipper/profile/` | Shipper | Profile + `kyc_verified` |
| PATCH | `/api/shipper/profile/` | Shipper | Update name, phone, account fields |
| GET | `/api/shipper/kyc-documents/` | Shipper | List uploaded KYC files |
| POST | `/api/shipper/kyc-documents/` | Shipper | Upload KYC document (JSON + base64 file) |
| GET | `/api/shipper/shipments/` | Shipper | List own shipments |
| POST | `/api/shipper/shipments/estimate-price/` | Shipper | Preview suggested price (zone km same-country; freight-route min_freight cross-border) |
| POST | `/api/shipper/shipments/` | Shipper | Create shipment (DRAFT) |
| GET | `/api/shipper/shipments/{id}/` | Shipper | Shipment detail |
| PATCH | `/api/shipper/shipments/{id}/` | Shipper | Update / publish shipment |
| DELETE | `/api/shipper/shipments/{id}/` | Shipper | Delete draft only |
| GET | `/api/shipper/shipments/{id}/bids/` | Shipper | Bids on a shipment |
| GET | `/api/shipper/bids/{id}/` | Shipper | Single bid |
| POST | `/api/shipper/bids/{id}/accept/` | Shipper | Accept bid; creates trip |
| POST | `/api/shipper/bids/{id}/reject/` | Shipper | Reject bid; notifies transporter |
| POST | `/api/shipper/bids/{id}/counter/` | Shipper | Counter-offer (if not locked) |
| GET | `/api/shipper/trips/` | Shipper | Trips for own shipments |
| GET | `/api/shipper/trips/{id}/` | Shipper | Trip + nested shipment/bid |
| GET | `/api/shipper/trips/{id}/locations/` | Shipper | GPS trail (latest 100) |
| GET | `/api/shipper/shipments/{id}/nearby-drivers/` | Shipper | Nearby verified/active transporters within fixed 5 km (via Traccar) |
| GET | `/api/shipper/transporters/{id}/current-location/` | Shipper | Latest transporter location by transporter id (via Traccar) |
| GET | `/api/chats/` | Shipper / Transporter | List participant chats |
| POST | `/api/chats/open/` | Shipper / Transporter | Open or create chat by `shipment_id` + `transporter_id` |
| GET | `/api/chats/{id}/messages/` | Shipper / Transporter | Conversation messages |
| POST | `/api/chats/{id}/messages/` | Shipper / Transporter | Send text/voice message |
| POST | `/api/chats/{id}/accept-bid/` | Shipper | Accept latest active bid in this chat; creates trip |
| POST | `/api/chats/{id}/reject-bid/` | Shipper | Reject latest active bid in this chat; notifies transporter |
| GET | `/api/shipper/trips/{id}/pod/` | Shipper | Proof of delivery |
| GET | `/api/shipper/wallet/` | Shipper | Wallet balance & currency |
| POST | `/api/shipper/trips/{id}/pay/` | Shipper | Pay for trip (`WALLET`, `CASH`, `CREDIT` — local ledger only) |
| GET | `/api/shipper/invoices/` | Shipper | List invoices |
| GET | `/api/shipper/invoices/{id}/` | Shipper | Invoice detail |
| GET | `/api/shipper/shipments/history/` | Shipper | Completed / delivered shipments |
| POST | `/api/shipper/trips/{id}/review/` | Shipper | Submit trip review |
| GET | `/api/shipper/transporters/{id}/reviews/` | Shipper | Public reviews for a transporter |
| GET, POST | `/api/shipper/addresses/` | Shipper | List / create saved address |
| GET, PATCH, DELETE | `/api/shipper/addresses/{id}/` | Shipper | Address detail / update / delete |
| GET, POST | `/api/shipper/favorites/transporters/` | Shipper | List / add favorite transporter |
| DELETE | `/api/shipper/favorites/transporters/{id}/` | Shipper | Remove favorite (favorite row id) |

---

## Shipper 1 — Registration & KYC

---

### POST /api/shipper/register/

Register a new shipper account and receive an auth token.

**Auth required:** No (public)

**Request body:**

```json
{
  "email": "shipper@example.com",
  "password": "SecurePass123",
  "first_name": "Ali",
  "last_name": "Khan",
  "phone": "03001234567",
  "account_type": "INDIVIDUAL",
  "company_name": "",
  "business_name": "",
  "tax_id": "",
  "national_id_number": "784-1990-1234567-1",
  "national_id_expiry_date": "2030-12-31",
  "national_id_file": "data:image/jpeg;base64,/9j/4AAQ...",
  "passport_number": "AB1234567",
  "passport_expiry_date": "2032-01-15",
  "passport_file": "data:image/jpeg;base64,/9j/4AAQ..."
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| email | string | Yes | Used as login username |
| password | string | Yes | Min 8 characters |
| first_name | string | No | |
| last_name | string | No | |
| phone | string | No | |
| account_type | string | Yes | `INDIVIDUAL` or `BUSINESS` |
| company_name | string | No | Required for business accounts |
| business_name | string | No | Optional business / trading name |
| tax_id | string | No | Optional tax / VAT ID |
| national_id_number | string | Conditional | Required if no passport (`passport_number` or `passport_file`) |
| national_id_expiry_date | date (YYYY-MM-DD) | No | Optional national ID expiry; also stored as KYC `expiry_date` when `national_id_file` is uploaded |
| national_id_file | string (base64) | Conditional | Counts as providing national ID. Creates an `ID` KYC document during signup |
| passport_number | string | Conditional | Required if no national ID (`national_id_number` or `national_id_file`). Unique when provided |
| passport_expiry_date | date (YYYY-MM-DD) | No | Optional; also stored as KYC `expiry_date` when `passport_file` is uploaded |
| passport_file | string (base64) | Conditional | Counts as providing passport. Creates a `PASSPORT_COPY` KYC document during signup |

**At least one of national ID or passport is required** (number and/or file). The other set remains optional.

National ID and passport files are stored as **KYC documents** (`ID` and `PASSPORT_COPY`). You can still upload more later via `POST /api/shipper/kyc-documents/`.

**Response `201`:**

```json
{
  "status_code": 201,
  "message": "Shipper registered successfully.",
  "error": null,
  "data": {
    "token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b",
    "user_id": 5,
    "email": "shipper@example.com"
  }
}
```

**Error `400`:**

```json
{
  "status_code": 400,
  "message": "Validation failed.",
  "error": {
    "email": ["A user with this email already exists."]
  },
  "data": null
}
```

---

### GET /api/shipper/profile/

Get the logged-in shipper's profile (includes user id, email, name, phone, and KYC flags).

**Auth required:** Yes (Shipper)

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Profile retrieved successfully.",
  "error": null,
  "data": {
    "user_id": 5,
    "email": "shipper@example.com",
    "first_name": "Ali",
    "last_name": "Khan",
    "phone": "03001234567",
    "account_type": "INDIVIDUAL",
    "company_name": "",
    "business_name": "",
    "tax_id": "",
    "national_id_number": "784-1990-1234567-1",
    "national_id_expiry_date": "2030-12-31",
    "passport_number": "",
    "passport_expiry_date": null,
    "kyc_verified": false,
    "kyc_submitted_at": null,
    "credit_approved": false,
    "shipment_preferences": {},
    "created_at": "2026-03-17T10:00:00Z",
    "updated_at": "2026-03-17T10:00:00Z"
  }
}
```

---

### PATCH /api/shipper/profile/

Update the logged-in shipper's profile (`first_name`, `last_name`, `phone` on user/role; `account_type`, `company_name`, `business_name`, `tax_id`, `national_id_number`, `national_id_expiry_date`, optional `passport_number` / `passport_expiry_date` on profile). You may also send **`shipment_preferences`** (JSON object) for default shipment options (e.g. vehicle type hints, notes). **`credit_approved`** is read-only (set in Django admin for business credit terms).

**Auth required:** Yes (Shipper)

**Request body (partial):**

```json
{
  "account_type": "BUSINESS",
  "company_name": "Khan Logistics",
  "business_name": "Khan Trading",
  "tax_id": "TAX-12345",
  "first_name": "Ali",
  "last_name": "Khan",
  "phone": "03001234567",
  "national_id_number": "784-1990-1234567-1",
  "national_id_expiry_date": "2030-12-31",
  "passport_number": "AB1234567",
  "passport_expiry_date": "2032-01-15",
  "shipment_preferences": {
    "default_vehicle_type": "Truck",
    "packaging_notes": "Palletized only"
  }
}
```

**Response `200`:** Same envelope as GET; `data` is the updated profile object.

---

### GET /api/shipper/kyc-documents/

List all KYC documents uploaded by the logged-in shipper.

**Auth required:** Yes (Shipper)

**Response `200`:** `data` is an array of document objects (each includes `file` path and `file_url` when available).

```json
{
  "status_code": 200,
  "message": "KYC documents retrieved successfully.",
  "error": null,
  "data": [
    {
      "id": 1,
      "document_type": "ID",
      "file": "/media/kyc/2026/03/17/passport.jpg",
      "file_url": "http://your-domain.com/media/kyc/2026/03/17/passport.jpg",
      "verified": false,
      "submitted_at": "2026-03-17T10:05:00Z",
      "reviewed_at": null
    }
  ]
}
```

---

### POST /api/shipper/kyc-documents/

Upload a KYC document.

**Auth required:** Yes (Shipper)  
**Content-Type:** `application/json`

Same pattern as transporter documents: **`file`** is a base64 string, optionally prefixed with `data:<mime>;base64,`.

**Request body:**

```json
{
  "document_type": "ID",
  "file": "data:image/jpeg;base64,/9j/4AAQSkZJRg..."
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| document_type | string | Yes | Shipper flows: `ID`, `BUSINESS_REGISTRATION`, `OTHER`, `PASSPORT_COPY` (see `KYCDocument.DocumentType` in code) |
| file | string | Yes | Base64 file payload |
| passport_number | string | Conditional | **Required** when `document_type` is `PASSPORT_COPY`. Unique across all users (normalized uppercase). |
| expiry_date | date (YYYY-MM-DD) | No | Optional; used for compliance reminders |

**Example (curl):**

```bash
curl -X POST http://your-domain.com/api/shipper/kyc-documents/ \
  -H "Authorization: Token <your_token>" \
  -H "Content-Type: application/json" \
  -d '{"document_type":"ID","file":"data:image/jpeg;base64,..."}'
```

**Response `201`:**

```json
{
  "status_code": 201,
  "message": "KYC document uploaded successfully.",
  "error": null,
  "data": {
    "id": 2,
    "document_type": "ID",
    "file": "/media/kyc/2026/03/17/passport.jpg",
    "file_url": "http://your-domain.com/media/kyc/2026/03/17/passport.jpg",
    "verified": false,
    "submitted_at": "2026-03-17T10:10:00Z",
    "reviewed_at": null
  }
}
```

---

## Shipper 2 — Shipments

**List/detail/create responses:** `data` contains a single shipment object or an array of shipments with the schema below. `pickup_company` and `dropoff_company` are included on all shipment payloads (empty string when omitted). `currency` is the pickup-country ISO 4217 code, set by the server (not accepted from the client).

---

### GET /api/shipper/shipments/

List all shipments belonging to the logged-in shipper.

**Auth required:** Yes (Shipper)

**Response `200`:** `data` is an array.

```json
[
  {
    "id": 1,
    "unique_id": "TM-00001",
    "pickup_address": "Karachi",
    "pickup_company": "Acme Warehouse",
    "pickup_lat": "24.8607",
    "pickup_lon": "67.0011",
    "pickup_country_code": "AE",
    "delivery_address": "Lahore",
    "dropoff_company": "Lahore Depot",
    "delivery_lat": "31.5497",
    "delivery_lon": "74.3436",
    "delivery_country_code": "SA",
    "cargo_type": "Electronics",
    "weight": "500 kg",
    "dimensions": "2x2x2 m",
    "vehicle_type_required": "Truck",
    "special_instructions": "",
    "distance_km": null,
    "suggested_price": null,
    "currency": "AED",
    "status": "DELIVERED",
    "pod": {
      "id": 10,
      "receiver_name": "Jane Doe",
      "receiver_signature": "/media/pod/signatures/2026/08/28/sig.jpg",
      "signature_url": "http://example.com/media/pod/signatures/2026/08/28/sig.jpg",
      "delivery_lat": "31.5497000",
      "delivery_lon": "74.3436000",
      "delivered_at": "2026-08-28T14:30:00Z",
      "pod_url": "http://example.com/media/pod/photos/2026/08/28/p1.jpg",
      "pod_urls": [
        "http://example.com/media/pod/photos/2026/08/28/p1.jpg",
        "http://example.com/media/pod/photos/2026/08/28/p2.jpg"
      ],
      "photos": [
        {
          "id": 1,
          "image": "/media/pod/photos/2026/08/28/p1.jpg",
          "image_url": "http://example.com/media/pod/photos/2026/08/28/p1.jpg",
          "url": "http://example.com/media/pod/photos/2026/08/28/p1.jpg",
          "created_at": "2026-08-28T14:30:00Z"
        }
      ]
    },
    "pod_url": "http://example.com/media/pod/photos/2026/08/28/p1.jpg",
    "pod_urls": [
      "http://example.com/media/pod/photos/2026/08/28/p1.jpg",
      "http://example.com/media/pod/photos/2026/08/28/p2.jpg"
    ],
    "pickup_scheduled_at": null,
    "created_at": "2026-03-17T10:00:00Z",
    "updated_at": "2026-03-17T10:00:00Z"
  }
]
```

---

### POST /api/shipper/shipments/estimate-price/

Preview the **suggested price** before creating a shipment. Does **not** create a shipment.

- **Same country:** `Zone.rate_per_km` × route km intersecting that country's boundary.
- **Different countries:** directional **freight route** min freight for pickup country → delivery country (the reverse pair is a different route). If that origin/destination combination does not exist, `suggested_price` is `null`. The price is **not** split across countries.

- **Currency:** always the pickup (origin) country ISO 4217 code. Not taken from the freight-route row.

Country codes are resolved from pickup/delivery coordinates (country boundaries, then reverse geocode). If either country cannot be resolved, same-country zone pricing is used.

**Auth required:** Yes (Shipper)

**Request body:**

```json
{
  "pickup_lat": "24.5000000",
  "pickup_lon": "54.5000000",
  "delivery_lat": "25.5000000",
  "delivery_lon": "55.5000000",
  "route_coords": [
    [54.5, 24.5],
    [55.0, 25.0],
    [55.5, 25.5]
  ]
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| pickup_lat | decimal | Yes | Pickup latitude |
| pickup_lon | decimal | Yes | Pickup longitude |
| delivery_lat | decimal | Yes | Delivery latitude |
| delivery_lon | decimal | Yes | Delivery longitude |
| route_coords | array | No | Driven polyline as `[lng, lat]` points (or `{lng,lat}` / `{lon,lat}` objects). If omitted, a straight pickup→delivery line is used |

**Response `200` (same country):**

```json
{
  "status_code": 200,
  "message": "Suggested price calculated successfully.",
  "error": null,
  "data": {
    "suggested_price": "125.50",
    "distance_km": "83.67",
    "currency": "AED",
    "price_breakdown": [
      {
        "country_code": "AE",
        "km": 83.67,
        "rate_per_km": 1.5,
        "price": 125.5
      }
    ]
  }
}
```

**Response `200` (cross-border, freight route exists):**

```json
{
  "status_code": 200,
  "message": "Suggested price calculated successfully.",
  "error": null,
  "data": {
    "suggested_price": "1500.00",
    "distance_km": "83.67",
    "currency": "PKR",
    "origin_country_code": "PK",
    "destination_country_code": "AF",
    "price_breakdown": []
  }
}
```

**Response `200` (cross-border, no freight route):** same envelope with `"suggested_price": null`. `currency` is still the pickup-country code.

**Error `400`:** Missing/invalid coordinates, or (same-country) route does not intersect any configured zone with `rate_per_km`.

Typical app flow: choose pickup/delivery → call estimate-price → show price → confirm → `POST /api/shipper/shipments/` with that `suggested_price` (create stores the client value; it does not recalculate).

---

### POST /api/shipper/shipments/

Create a new shipment. Default status is **DRAFT**; pass `"status": "PUBLISHED"` to create and publish in one request (same validation as PATCH publish).

Rate-request dispatch and nearby-load notifications run **asynchronously** after the response (Celery when available).

**Auth required:** Yes (Shipper)

**Request body:**

```json
{
  "pickup_address": "Karachi",
  "pickup_company": "Acme Warehouse",
  "pickup_lat": "24.8607",
  "pickup_lon": "67.0011",
  "pickup_country_code": "AE",
  "delivery_address": "Lahore",
  "dropoff_company": "Lahore Depot",
  "delivery_lat": "31.5497",
  "delivery_lon": "74.3436",
  "delivery_country_code": "SA",
  "cargo_type": "Electronics",
  "weight": "500 kg",
  "dimensions": "2x2x2 m",
  "vehicle_type_required": "Truck",
  "special_instructions": "Handle with care",
  "distance_km": null,
  "suggested_price": null,    
  "pickup_scheduled_at": null,
  "local": true,
  "country_to_country": false
}
```

| Field | Type | Required |
|---|---|---|
| pickup_address | string | Yes |
| pickup_company | string | No | Company name at pickup |
| pickup_lat / pickup_lon | decimal | No |
| pickup_country_code | string (ISO 3166-1 alpha-2) | No | Used by `GET /api/transporter/load-discovery/` (`country_code` query param must match pickup country) |
| delivery_address | string | Yes |
| dropoff_company | string | No | Company name at drop-off |
| delivery_lat / delivery_lon | decimal | No |
| delivery_country_code | string (ISO 3166-1 alpha-2) | No | Used for return-load filtering (`delivery` must match driver’s country when `return_loads_only` is used) |
| cargo_type | string | Yes |
| weight | string | Yes |
| dimensions | string | No |
| vehicle_type_required | string | Yes |
| special_instructions | string | No |
| distance_km | decimal | No |
| suggested_price | decimal | No | Stored as sent (from estimate-price); not recalculated on create |
| currency | string | — | Not accepted on create/update. Server sets ISO 4217 from pickup country |
| pickup_scheduled_at | datetime ISO 8601 | No |
| local | boolean | Yes | Exactly one of `local` or `country_to_country` must be `true` |
| country_to_country | boolean | Yes | Cross-border load flag; mutually exclusive with `local` |

**Response `201`:** Same as GET shipment detail.

---

### GET /api/shipper/shipments/{id}/

Get a single shipment.

**Auth required:** Yes (Shipper)

**Response `200`:** Same schema as list item above.

---

### PATCH /api/shipper/shipments/{id}/

Update a shipment. Also used to **publish** (change status from DRAFT to PUBLISHED).

**Auth required:** Yes (Shipper)

**Request body:** `local` and `country_to_country` are **required on every PATCH** (exactly one must be `true`). Other fields are optional.

```json
{
  "cargo_type": "Furniture",
  "status": "PUBLISHED",
  "local": true,
  "country_to_country": false
}
```

> Pass `"status": "PUBLISHED"` to publish the shipment for bidding. Can only publish from DRAFT state. Rate-request dispatch and nearby-load FCM run asynchronously after the response (Celery worker when configured).

**Response `200`:** Standard envelope; `data` is the full shipment object.

---

### DELETE /api/shipper/shipments/{id}/

Delete a shipment. Only allowed when status is DRAFT.

**Auth required:** Yes (Shipper)

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Shipment deleted successfully.",
  "error": null,
  "data": null
}
```

**Error `400`:**

```json
{
  "status_code": 400,
  "message": "Can only delete draft shipments.",
  "error": "Invalid operation.",
  "data": null
}
```

---

## Shipper 3 — Bidding

One **active** bid per transporter per shipment (`PENDING`, `COUNTERED`, or `AGREED`). Rebids update the same row (same `id`).

**Bid status values:**

| Status | Meaning | Who can act next |
|--------|---------|------------------|
| `PENDING` | Transporter’s current offer | Shipper: accept, counter, or reject. Transporter: update offer (`COUNTER`). |
| `COUNTERED` | Shipper sent a counter (`counter_amount`) | Transporter: `ACCEPT` counter → `AGREED`, or `COUNTER` with new amount → `PENDING`. Shipper **cannot** accept yet. |
| `AGREED` | Transporter accepted shipper’s counter (`amount` = agreed price) | Shipper: accept (creates trip) or reject. |
| `ACCEPTED` | Trip created | Terminal (locked). |
| `REJECTED` | Declined | Terminal. |

**Final trip price:** use `trip.agreed_price` (or accept response `agreed_price`). Do **not** use `shipment.suggested_price` after a bid is accepted — that field stays the original estimate.

### Complete negotiation flow (counter → transporter agrees → shipper accepts)

Scenario: shipment `suggested_price` = **404.43 PKR**, transporter bids **410**, shipper counters **450**, transporter accepts counter, shipper accepts → trip `agreed_price` = **450.00**.

---

**Step 1 — Transporter counter-offer (creates bid)**

`POST /api/transporter/shipments/156/bid/`

```json
{
  "action": "COUNTER",
  "amount": "410.00",
  "message": "My offer"
}
```

Response `201`:

```json
{
  "status_code": 201,
  "message": "Bid submitted successfully.",
  "error": null,
  "data": {
    "id": 234,
    "transporter": 3,
    "transporter_email": "driver@example.com",
    "transporter_name": "Ahmed Raza",
    "is_favourite": false,
    "is_favorite": false,
    "amount": "410.00",
    "currency": "PKR",
    "status": "PENDING",
    "counter_amount": null,
    "message": "My offer",
    "created_at": "2026-08-31T17:08:59.421656Z"
  }
}
```

---

**Step 2 — Shipper counter-offer**

`POST /api/shipper/bids/234/counter/`

```json
{
  "counter_amount": "450.00",
  "message": "Can you do 450?"
}
```

Response `200`:

```json
{
  "status_code": 200,
  "message": "Counter-offer sent.",
  "error": null,
  "data": {
    "id": 234,
    "transporter": 3,
    "transporter_email": "driver@example.com",
    "transporter_name": "Ahmed Raza",
    "amount": "410.00",
    "currency": "PKR",
    "status": "COUNTERED",
    "counter_amount": "450.00",
    "message": "Can you do 450?",
    "created_at": "2026-08-31T17:08:59.421656Z"
  }
}
```

Push to transporter: event `bid_countered`, payload includes `counter_amount`: `"450.00"`.

---

**Step 3 — Transporter accepts shipper counter (same bid `id`, not a new bid)**

`POST /api/transporter/shipments/156/bid/`

```json
{
  "action": "ACCEPT",
  "message": "Accepted shipper counter-offer."
}
```

Response `200` (note: **not** `201`, same `id` **234**):

```json
{
  "status_code": 200,
  "message": "Counter-offer accepted.",
  "error": null,
  "data": {
    "id": 234,
    "transporter": 3,
    "transporter_email": "driver@example.com",
    "transporter_name": "Ahmed Raza",
    "amount": "450.00",
    "currency": "PKR",
    "status": "AGREED",
    "counter_amount": null,
    "message": "Accepted shipper counter-offer.",
    "created_at": "2026-08-31T17:08:59.421656Z"
  }
}
```

Push to shipper: event `bid_agreed`, payload `agreed_price`: `"450.00"`.

---

**Step 4 — Shipper accepts bid (creates trip)**

`POST /api/shipper/bids/234/accept/`

```json
{}
```

Response `200`:

```json
{
  "status_code": 200,
  "message": "Bid accepted and trip created.",
  "error": null,
  "data": {
    "bid_id": 234,
    "trip_id": 50,
    "unique_id": "SHP-2026-00156",
    "agreed_price": "450.00",
    "currency": "PKR"
  }
}
```

Alternative: `POST /api/chats/{conversation_id}/accept-bid/` returns the same fields plus `conversation_id`.

---

**Step 5 — Trip list / detail (use `agreed_price`)**

`GET /api/shipper/trips/50/`

```json
{
  "status_code": 200,
  "message": "Trip retrieved successfully.",
  "error": null,
  "data": {
    "id": 50,
    "unique_id": "SHP-2026-00156",
    "shipment": {
      "id": 156,
      "suggested_price": "404.43",
      "currency": "PKR",
      "status": "ASSIGNED",
      "...": "..."
    },
    "agreed_price": "450.00",
    "currency": "PKR",
    "accepted_bid": {
      "id": 234,
      "amount": "450.00",
      "currency": "PKR",
      "status": "ACCEPTED",
      "counter_amount": null,
      "...": "..."
    },
    "status": "ASSIGNED",
    "...": "..."
  }
}
```

`shipment.suggested_price` remains **404.43**; settlement and UI should show **`agreed_price` 450.00**.

---

**Error — shipper tries to accept while still `COUNTERED` (before step 3)**

`POST /api/shipper/bids/234/accept/`

```json
{
  "status_code": 400,
  "message": "Transporter must respond to the counter-offer before acceptance.",
  "error": "Bid acceptance failed.",
  "data": null
}
```

---

**Transporter updates offer (same bid id)**

`POST /api/transporter/shipments/156/bid/` when bid is `PENDING`:

```json
{
  "action": "COUNTER",
  "amount": "420.00",
  "message": "Revised offer"
}
```

Response `200`, same `data.id` as before, `status`: `"PENDING"`, `amount`: `"420.00"`.

---

**Transporter accepts proposed rate (no prior bid)**

`POST /api/transporter/shipments/156/bid/`

```json
{
  "action": "ACCEPT",
  "message": "Accepted shipper proposed rate."
}
```

Response `201`, `amount` = shipment `suggested_price`, `status`: `"PENDING"` (shipper must still accept to create trip).

---


List all bids on a specific shipment.

**Auth required:** Yes (Shipper, must own the shipment)

**Response `200`:**

```json
[
  {
    "id": 1,
    "transporter": 3,
    "transporter_email": "driver@example.com",
    "transporter_name": "Ahmed Raza",
    "amount": "4500.00",
    "currency": "AED",
    "status": "PENDING",
    "counter_amount": null,
    "message": "I can do this.",
    "created_at": "2026-03-17T11:00:00Z"
  }
]
```

---

### GET /api/shipper/bids/{id}/

Get a single bid detail.

**Auth required:** Yes (Shipper)

**Response `200`:** Same schema as list item above.

---

### POST /api/shipper/bids/{id}/accept/

Accept a bid. Creates a Trip automatically.

**Auth required:** Yes (Shipper)

**Request body:** Empty JSON object `{}` is fine; body may be omitted.

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Bid accepted and trip created.",
  "error": null,
  "data": {
    "bid_id": 1,
    "trip_id": 7,
    "unique_id": "SHP-2026-00001",
    "agreed_price": "4500.00",
    "currency": "AED"
  }
}
```

Acceptable bid statuses: `PENDING` (transporter’s offer) or `AGREED` (transporter accepted shipper counter). **`COUNTERED` cannot be accepted** until the transporter responds.

**Error `400`:**

```json
{
  "status_code": 400,
  "message": "Transporter must respond to the counter-offer before acceptance.",
  "error": "Bid acceptance failed.",
  "data": null
}
```

```json
{
  "status_code": 400,
  "message": "Bid cannot be accepted.",
  "error": "Bid acceptance failed.",
  "data": null
}
```

```json
{
  "status_code": 400,
  "message": "Shipment is not in published state.",
  "error": "Invalid shipment status.",
  "data": null
}
```

```json
{
  "status_code": 400,
  "message": "Transporter has no eligible verified active vehicle for this load.",
  "error": "Vehicle eligibility requirements not met.",
  "data": null
}
```

---

### POST /api/shipper/bids/{id}/reject/

Reject a bid on a shipment. Updates the bid status to `REJECTED` and sends a `bid_rejected` push notification to the transporter.

**Auth required:** Yes (Shipper)

**Request body (optional):**

```json
{
  "reason": "Offered price exceeds our budget for this cargo."
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| reason / message | string | No | Optional explanation for rejection |

**Response `200`:** Full bid object with `status: "REJECTED"`.

```json
{
  "status_code": 200,
  "message": "Bid rejected successfully.",
  "error": null,
  "data": {
    "id": 1,
    "transporter": 3,
    "transporter_email": "driver@example.com",
    "transporter_name": "Ahmed Raza",
    "amount": "4500.00",
    "currency": "AED",
    "status": "REJECTED",
    "counter_amount": null,
    "message": "Offered price exceeds our budget for this cargo.",
    "created_at": "2026-03-17T11:00:00Z"
  }
}
```

---

### POST /api/shipper/bids/{id}/counter/

Send a counter-offer to a transporter. Only bids with `status: "PENDING"` can be countered.

**Auth required:** Yes (Shipper)

**Request body:**

```json
{
  "counter_amount": "4000.00",
  "message": "I can offer 4000."
}
```

| Field | Type | Required |
|---|---|---|
| counter_amount | decimal | Yes |
| message | string | No |

**Response `200`:** Full bid object with `status: "COUNTERED"`.

---

## Shipper 4 — Trip Lifecycle

---

### GET /api/shipper/trips/

List all trips linked to the shipper's shipments.

**Auth required:** Yes (Shipper)

**Response `200`:**

```json
[
  {
    "id": 7,
    "unique_id": "SH-20260317-0007",
    "shipment_id": 1,
    "pickup_company": "Acme Warehouse",
    "dropoff_company": "Lahore Depot",
    "pickup": {
      "address": "Karachi",
      "company": "Acme Warehouse",
      "lat": "24.8607000",
      "lon": "67.0011000",
      "country_code": "PK",
      "scheduled_at": "2026-03-18T09:00:00Z"
    },
    "dropoff": {
      "address": "Lahore",
      "company": "Lahore Depot",
      "lat": "31.5204000",
      "lon": "74.3587000",
      "country_code": "PK"
    },
    "pickup_scheduled_at": "2026-03-18T09:00:00Z",
    "cargo_type": "Electronics",
    "weight": "500 kg",
    "vehicle_type_required": "Flat Bed 12m",
    "distance_km": "1250.00",
    "currency": "AED",
    "agreed_price": "4500.00",
    "status": "ASSIGNED",
    "transporter": {
      "id": 3,
      "email": "fleet@example.com",
      "first_name": "Fleet",
      "last_name": "Owner",
      "phone": "555-0100",
      "account_type": "FLEET_OWNER",
      "company_name": "Fleet Co",
      "is_favourite": false,
      "is_favorite": false
    },
    "assigned_driver_id": 12,
    "assigned_driver": {
      "id": 12,
      "email": "driver@example.com",
      "first_name": "Ahmed",
      "last_name": "Raza",
      "phone": "555-0200",
      "account_type": "TRANSPORTER_DRIVER",
      "company_name": "",
      "is_favourite": false,
      "is_favorite": false
    },
    "individual_driver": {
      "id": 12,
      "email": "driver@example.com",
      "first_name": "Ahmed",
      "last_name": "Raza",
      "phone": "555-0200",
      "account_type": "TRANSPORTER_DRIVER",
      "company_name": "",
      "is_favourite": false,
      "is_favorite": false
    },
    "assigned_vehicle": {
      "id": 5,
      "registration_number": "ABC-123",
      "vehicle_type": "Flat Bed 12m",
      "vehicle_types": ["Flat Bed 12m"]
    },
    "is_favourite": false,
    "shipper_rating": false,
    "status_timeline": [
      {
        "status": "ASSIGNED",
        "recorded_at": "2026-03-17T11:05:00Z"
      }
    ],
    "current_lat": null,
    "current_lon": null,
    "eta": null,
    "created_at": "2026-03-17T11:05:00Z",
    "updated_at": "2026-03-17T11:05:00Z"
  }
]
```

`individual_driver` is the executing driver: `assigned_driver` when set, otherwise the transporter when they are an individual / linked driver.
**Trip status values:**

| Status | Meaning |
|---|---|
| `DRAFT` | Draft |
| `PUBLISHED` | Open for bidding |
| `ASSIGNED` | Transporter accepted |
| `EN_ROUTE` | Driver on way to pickup |
| `ARRIVED_PICKUP` | Arrived at pickup |
| `LOADED` | Cargo loaded |
| `IN_TRANSIT` | In transit |
| `ARRIVED_DELIVERY` | Arrived at delivery |
| `DELIVERED` | Delivered |
| `COMPLETED` | Completed |
| `CLOSED` | Closed |

**Status timeline:**
- `status_timeline` is an ordered array of status records for that trip.
- Each record includes:
  - `status` (trip status value)
  - `recorded_at` (timestamp when that status was recorded)

**Shipper rating flag:**
- `shipper_rating` is a boolean on trip payloads.
- `false` means the trip shipper has not been rated yet by the transporter/assigned driver.
- `true` means a `POST /api/transporter/trips/{trip_id}/shipper-review/` review already exists.

---

### GET /api/shipper/trips/{id}/

Get full trip detail including nested shipment and accepted bid.

**Auth required:** Yes (Shipper)

**Response `200`:**

```json
{
  "id": 7,
  "shipment": {
    "id": 1,
    "pickup_address": "Karachi",
    "pickup_company": "Acme Warehouse",
    "delivery_address": "Lahore",
    "dropoff_company": "Lahore Depot",
    "cargo_type": "Electronics",
    "weight": "500 kg",
    "status": "ASSIGNED",
    "...": "..."
  },
  "accepted_bid": {
    "id": 1,
    "transporter": 3,
    "transporter_email": "driver@example.com",
    "transporter_name": "Ahmed Raza",
    "amount": "4500.00",
    "currency": "AED",
    "status": "ACCEPTED",
    "...": "..."
  },
  "transporter": 3,
  "is_favourite": false,
  "assigned_driver": {
    "id": 12,
    "email": "driver@example.com",
    "first_name": "Tariq",
    "last_name": "Khan",
    "phone": "+971501234567",
    "account_type": "TRANSPORTER_DRIVER",
    "company_name": "Swift Logistics",
    "is_favourite": false
  },
  "currency": "AED",
  "agreed_price": "4500.00",
  "status": "ASSIGNED",
  "shipper_rating": false,
  "status_timeline": [
    {
      "status": "ASSIGNED",
      "recorded_at": "2026-03-17T11:05:00Z"
    }
  ],
  "current_lat": "31.5497",
  "current_lon": "74.3436",
  "eta": null,
  "created_at": "2026-03-17T11:05:00Z",
  "updated_at": "2026-03-17T11:05:00Z"
}
```

---

## Shipper 5 — Tracking & Messaging

---

### GET /api/shipper/trips/{trip_id}/locations/

Get the last 100 GPS locations for a trip (most recent first).

**Auth required:** Yes (Shipper)

**Response `200`:**

```json
[
  {
    "id": 42,
    "lat": "31.5497",
    "lon": "74.3436",
    "recorded_at": "2026-03-17T12:30:00Z"
  }
]
```

---

### GET /trips/{trip_id}/track

Open web tracking page (no auth required) for a trip, rendered as HTML with a Leaflet map.

This page shows:
- Trip info and driver info
- Driver live location from Traccar (`tc_id`)
- Pickup and destination markers
- Route behavior by status:
  - **Before pickup** (`ASSIGNED`, `EN_ROUTE`, `ARRIVED_PICKUP`): driver → pickup → destination
  - **Picked up onward** (`LOADED`, `IN_TRANSIT`, `ARRIVED_DELIVERY`, `DELIVERED`, `COMPLETED`, `CLOSED`): driver → destination

Routing source:
- OSRM public API (`https://router.project-osrm.org`)
- If OSRM is unavailable, the page draws straight-line fallback segments and shows a notice.

Dependencies:
- Traccar `tc_id` on transporter profile
- Shipment pickup and delivery coordinates

**Response type:** `text/html` (not JSON)

---

### GET /api/shipper/shipments/{shipment_id}/nearby-drivers/

Return nearby **verified + active** transporters within a fixed **5 km** radius of the shipment pickup point, using live Traccar positions.

Reference point:
- shipment pickup coordinates

**Auth required:** Yes (Shipper; must own the shipment)

**Filters enforced by server:**
- `TransporterProfile.documents_verified = true`
- `User.is_active = true`
- at least one linked `Vehicle` with `is_verified = true` and `is_active = true`
- distance `<= 5.0 km`

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Nearby verified active transporters retrieved successfully.",
  "error": null,
  "data": {
    "shipment_id": 21,
    "radius_km": 5.0,
    "reference": {
      "lat": 24.8607,
      "lon": 67.0011
    },
    "count": 1,
    "drivers": [
      {
        "transporter_id": 45,
        "email": "driver@example.com",
        "first_name": "Ali",
        "last_name": "Khan",
        "phone": "03001234567",
        "company_name": "Fast Fleet",
        "tc_id": "111",
        "distance_km": 0.234,
        "position": {
          "lat": 24.861,
          "lon": 67.002
        }
      }
    ]
  }
}
```

**Error `400`:**
- Missing trip reference coordinates (neither current trip nor pickup coordinates are available)
- Traccar credentials/connection/response issue

**Error `404`:**
- Trip not found for this shipper

---

### GET /api/shipper/transporters/{transporter_id}/current-location/

Return the latest Traccar location for a transporter by `transporter_id`.

**Auth required:** Yes (Shipper)

**Path params:**

| Param | Type | Required | Notes |
|---|---|---|---|
| transporter_id | integer | Yes | User id of transporter |

**Request payload:** None

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Transporter current location retrieved successfully.",
  "error": null,
  "data": {
    "transporter_id": 32,
    "tc_id": "5",
    "position": {
      "lat": 31.4531291,
      "lon": 74.2527963
    },
    "raw": {
      "id": 1696,
      "deviceId": 5,
      "valid": true,
      "latitude": 31.4531291,
      "longitude": 74.2527963
    }
  }
}
```

**Error `400`:**
- `Driver tracking device is not configured.` (`Missing tc_id on transporter profile.`)
- `Unable to fetch driver location from Traccar.`
- `Driver location is invalid.` (`Missing latitude/longitude.`)

**Error `404`:**
- `Transporter not found.`
- `Transporter profile not found.`

---

### GET /api/chats/

List chats where the authenticated user is the shipper, the transporter (fleet owner / individual driver), or the **assigned fleet driver** on the linked trip (`trip.assigned_driver`).

**Auth required:** Yes (Shipper or Transporter)

Each row includes: `id`, `shipment_id`, optional `trip_id`, `shipper_id`, `transporter_id`, `last_message_at`, `created_at`.

---

### POST /api/chats/open/

Open (or create) a unified negotiation/lifecycle chat by participant pair.

**Auth required:** Yes (Shipper or Transporter)

```json
{
  "shipment_id": 10,
  "transporter_id": 22
}
```

Rules:
- Caller must be either the shipment shipper or that transporter.
- A bid or trip must exist for this shipment/transporter pair.
- Returns existing chat when already present.

---

### GET /api/chats/{conversation_id}/messages/

List all messages in the unified chat.

**Auth required:** Yes (participants only)

**Response `200`:** Message items include `conversation_id`, `shipment_id`, and `trip_id` metadata in addition to sender/content fields.

---

### POST /api/chats/{conversation_id}/messages/

Send a text or voice note in the unified chat.

**Developer guide (implementation, tests, ops):** [docs/VOICE_CHAT_API.md](docs/VOICE_CHAT_API.md)

**Auth required:** Yes (participants only)

**Validation**

- **`TEXT`:** Non-empty `text` (after trimming). Do not send `voice_file`.
- **`VOICE`:** `voice_file` is required. `text` is optional (caption). Use `multipart/form-data` (not JSON with a raw file).

**Text message (`application/json`)**

```json
{
  "text": "Please be on time.",
  "message_type": "TEXT"
}
```

Omitting `message_type` defaults to `TEXT` (same rules: body text required).

**Voice message (`multipart/form-data`)**

| Field | Type | Required | Notes |
|---|---|---|---|
| message_type | string | Yes | Must be `VOICE` |
| voice_file | file | Yes | Audio file (e.g. m4a, AAC) |
| text | string | No | Optional caption |

**Example: curl (voice)**

```bash
curl -X POST "http://your-domain.com/api/chats/12/messages/" \
  -H "Authorization: Token YOUR_TOKEN" \
  -F "message_type=VOICE" \
  -F "voice_file=@/path/to/note.m4a;type=audio/mp4"
```

**Response `201` (text):** `data` includes `message_type`, `text`, `voice_url` (null), ids, timestamps.

**Response `201` (voice):** `data` includes `message_type` `VOICE`, `voice_file` path, and **`voice_url`** (absolute URL for playback). Clients should use `voice_url` in the chat UI.

```json
{
  "status_code": 201,
  "message": "Message sent successfully.",
  "error": null,
  "data": {
    "id": 55,
    "conversation_id": 12,
    "shipment_id": 46,
    "trip_id": null,
    "sender": 14,
    "sender_email": "shipper@example.com",
    "sender_name": "Ali Khan",
    "sender_account_type": "INDIVIDUAL",
    "text": "",
    "message_type": "VOICE",
    "voice_file": "/media/messages/voice/2026/04/15/note.m4a",
    "voice_url": "http://your-domain.com/media/messages/voice/2026/04/15/note.m4a",
    "created_at": "2026-04-15T12:00:00Z"
  }
}
```

**Push notification (`new_message`):** FCM includes both a visible `notification` and a `data` map:

```json
{
  "notification": {
    "title": "New Message",
    "body": "message text or voice preview"
  },
  "data": {
    "event_type": "new_message",
    "type": "New Message",
    "tag": "{\"conversation_id\":\"12\",\"shipment_id\":\"46\",\"trip_id\":\"\",\"message_id\":\"55\",\"sender_id\":\"14\",\"chat_message_type\":\"TEXT\",\"event_type\":\"new_message\"}",
    "conversation_id": "12",
    "shipment_id": "46",
    "trip_id": "",
    "message_id": "55",
    "sender_id": "14",
    "chat_message_type": "TEXT",
    "title": "New Message",
    "body": "message text or voice preview"
  }
}
```

Use `data.type` / `data.tag` for client routing (same shape as other apps expecting `type` + JSON `tag`). Flat keys remain for clients that read `conversation_id` directly.

**Media hosting:** Uploaded files are stored under `MEDIA_ROOT` and served at `MEDIA_URL` when configured (e.g. `/media/`). In production, configure the reverse proxy **upload size** (e.g. nginx `client_max_body_size`) to match Django’s `DATA_UPLOAD_MAX_MEMORY_SIZE` (default **25 MB** in this project unless overridden by env). See `Loadboard/settings.py`.

---

### POST /api/chats/{conversation_id}/accept-bid/

Shipper accepts the **active** bid (`PENDING` or `AGREED`) for this chat's shipment/transporter pair.
Creates trip and links the same conversation to that trip (no new thread).

**Auth required:** Yes (Shipper participant only)

**Request body:** Empty `{}` or omitted.

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Bid accepted and trip created.",
  "error": null,
  "data": {
    "bid_id": 234,
    "trip_id": 50,
    "unique_id": "SHP-2026-00156",
    "agreed_price": "450.00",
    "currency": "PKR",
    "conversation_id": 12
  }
}
```

**Error `400`:** Same as `POST /api/shipper/bids/{id}/accept/` (e.g. bid still `COUNTERED`).

### POST /api/chats/{conversation_id}/reject-bid/

Shipper rejects the active bid (`PENDING`, `COUNTERED`, or `AGREED`) for this chat's shipment/transporter pair.
Updates the bid status to `REJECTED` and sends a `bid_rejected` notification to the transporter.

**Auth required:** Yes (Shipper participant only)

**Request body (optional):**

```json
{
  "message": "Declined offer."
}
```

**Response `200`:** Standard envelope with rejected bid details.

---

## Shipper 6 — Proof of Delivery

---

### GET /api/shipper/trips/{trip_id}/pod/

Get the Proof of Delivery for a completed trip.

**Auth required:** Yes (Shipper)

**Response `200`:**

```json
{
  "id": 1,
  "receiver_name": "Usman Tariq",
  "receiver_signature": "/media/pod/signatures/2026/03/17/sig.png",
  "signature_url": "http://your-domain.com/media/pod/signatures/2026/03/17/sig.png",
  "delivery_lat": "31.5497",
  "delivery_lon": "74.3436",
  "delivered_at": "2026-03-17T15:00:00Z",
  "photos": [
    {
      "id": 1,
      "image": "/media/pod/photos/2026/03/17/photo.jpg",
      "image_url": "http://your-domain.com/media/pod/photos/2026/03/17/photo.jpg",
      "created_at": "2026-03-17T15:00:00Z"
    }
  ],
  "created_at": "2026-03-17T15:00:00Z"
}
```

**Error `404`:**

```json
{
  "status_code": 404,
  "message": "POD not yet submitted.",
  "error": "Not found.",
  "data": null
}
```

---

## Shipper 7 — Wallet, payments, invoices & shipment history

All balances and movements are stored in the **database** (`WalletAccount`, `LedgerEntry`, `Payment`, `Invoice`). There is **no** external card processor; clients top up or adjust shipper wallets through your own operational tools (e.g. Django admin / future admin API) if needed.

Settlement amount for a trip is **`trip.agreed_price`** (set when the bid is accepted). For legacy rows, it matches the accepted bid: `counter_amount` if set on the bid at accept time, otherwise `amount`. Do not use `shipment.suggested_price` for payment after a trip exists.

**Idempotency:** send header `Idempotency-Key: <unique string>` on `POST .../pay/` to safely retry the same logical payment. If omitted, the server uses a deterministic key per trip and method (`server-{tripId}-{method}`).

**Payment methods**

| Method | Code | Behaviour |
|--------|------|-------------|
| Wallet | `WALLET` | Debits shipper wallet, credits transporter; invoice `PAID` |
| Card | `CARD` | Payment `REQUIRES_ACTION`; trip transporter confirms with `POST /api/transporter/trips/{id}/confirm-card/` (shipper cannot confirm) |
| Cash | `CASH` | Payment `PENDING_COD`; transporter confirms collection with `POST /api/transporter/trips/{id}/confirm-cash/` |
| Credit | `CREDIT` | Only if profile `account_type` is `BUSINESS` **and** admin has set `credit_approved=true`; credits transporter wallet only (no shipper wallet debit); invoice `PAID` |

Only **one active payment** per trip is allowed (`CAPTURED`, `PENDING_COD`, `REQUIRES_ACTION`, or `PROCESSING`).

### GET /api/shipper/wallet/

**Auth required:** Yes (Shipper)

**Request body:** None

**Response `200` (`data`):**

```json
{
  "balance": "1500.00",
  "currency": "USD",
  "updated_at": "2026-04-12T12:00:00Z"
}
```

---

### POST /api/shipper/trips/{trip_id}/pay/

Create or resume payment for the given trip (must belong to the authenticated shipper).

**Auth required:** Yes (Shipper)

**Headers (optional):**

```
Idempotency-Key: <client-generated-uuid>
```

**Request body:**

```json
{
  "method": "WALLET"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| method | string | Yes | One of `WALLET`, `CARD`, `CASH`, `CREDIT` |

**Response `200` (`data`) — WALLET or CREDIT (captured immediately):**

```json
{
  "payment": {
    "id": 10,
    "trip": 3,
    "payer": 5,
    "payee": 8,
    "method": "WALLET",
    "amount": "4200.00",
    "currency": "USD",
    "status": "CAPTURED",
    "provider_ref": "",
    "metadata": {},
    "created_at": "2026-04-12T12:00:00Z",
    "updated_at": "2026-04-12T12:00:01Z"
  },
  "invoice": {
    "id": 4,
    "number": "INV-20260412-A1B2C3D4E5",
    "trip": 3,
    "shipper": 5,
    "line_items": [
      {
        "description": "Shipment #12 / Trip #3",
        "amount": "4200.00"
      }
    ],
    "tax": "0.00",
    "total": "4200.00",
    "status": "PAID",
    "payment": 10,
    "due_date": null,
    "issued_at": "2026-04-12T12:00:01Z",
    "created_at": "2026-04-12T12:00:01Z"
  }
}
```

**Response `200` (`data`) — CASH (COD pending):** includes `payment` with `status` `PENDING_COD` and `invoice` with `status` `ISSUED`.

**Response `200` (`data`) — CARD (pending transporter confirmation):** includes `payment` with `status` `REQUIRES_ACTION` and `invoice` with `status` `ISSUED`. Only `trip.transporter` (bid winner / fleet owner or individual driver) may call `POST /api/transporter/trips/{id}/confirm-card/` to capture.

**Error `400`:** Insufficient wallet balance, credit not allowed, duplicate payment, trip not found, or invalid `method`.

---

### GET /api/shipper/invoices/

**Auth required:** Yes (Shipper)

**Request body:** None

**Response `200`:** `data` is an array of invoice objects (same shape as in pay response), newest first (up to 200).

---

### GET /api/shipper/invoices/{id}/

**Auth required:** Yes (Shipper)

**Request body:** None

**Response `200`:** `data` is a single invoice object.

**Error `404`:** Invoice not owned by this shipper.

---

### GET /api/shipper/shipments/history/

Returns shipments in terminal states: `DELIVERED`, `COMPLETED`, or `CLOSED`.

**Auth required:** Yes (Shipper)

**Request body:** None

**Response `200`:** `data` is an array of shipment objects (same fields as `GET /api/shipper/shipments/`).

---

## Shipper 8 — Trip reviews & transporter ratings

### POST /api/shipper/trips/{trip_id}/review/

Submit a one-time review after the trip is at least **DELIVERED** (or completed/closed).

**Auth required:** Yes (Shipper)

**Request body:**

```json
{
  "transporter_rating": 5,
  "driver_rating": 4,
  "driver_id": 8,
  "comment": "On time and professional."
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| transporter_rating | integer | Yes | 1–5 |
| driver_rating | integer | No | 1–5; omit if not rating driver separately |
| driver_id | integer | No | User id of the driver if different from transporter |
| comment | string | No | Free text |

**Response `201` (`data`):** review object:

```json
{
  "id": 1,
  "trip": 3,
  "shipper": 5,
  "transporter": 8,
  "driver": 8,
  "transporter_rating": 5,
  "driver_rating": 4,
  "comment": "On time and professional.",
  "created_at": "2026-04-12T12:00:00Z"
}
```

**Error `400`:** Trip not in allowed status, duplicate review, validation errors.

---

### GET /api/shipper/transporters/{transporter_id}/reviews/

List recent reviews where the given user is the rated **transporter** (public-style snippet for shippers).

**Auth required:** Yes (Shipper)

**Response `200`:** `data` is an array of review objects (up to 50).

**Error `400`:** User is not a transporter.

---

## Shipper 9 — Saved addresses & favorite transporters

### GET /api/shipper/addresses/

**Auth required:** Yes (Shipper)

**Response `200`:** `data` is an array of address objects.

### POST /api/shipper/addresses/

**Auth required:** Yes (Shipper)

**Request body:**

```json
{
  "label": "Main warehouse",
  "address_line": "Plot 12, Industrial Area, Lahore",
  "lat": "31.5204",
  "lon": "74.3587",
  "country_code": "PK",
  "is_default": true
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| label | string | Yes | Short name |
| address_line | string | Yes | Full address text |
| lat | decimal | No | |
| lon | decimal | No | |
| country_code | string | No | ISO 3166-1 alpha-2 |
| is_default | boolean | No | If `true`, clears default on other addresses |

**Response `201`:** `data` is the created address.

---

### GET /api/shipper/addresses/{id}/

### PATCH /api/shipper/addresses/{id}/

### DELETE /api/shipper/addresses/{id}/

**Auth required:** Yes (Shipper). Address must belong to the logged-in user.

**PATCH body:** same fields as POST (partial allowed).

**Response `200` / `204`-style:** Standard envelope; DELETE returns success message with `data: null`.

---

### GET /api/shipper/favorites/transporters/

**Auth required:** Yes (Shipper)

**Response `200`:** `data` is an array of:

```json
{
  "id": 1,
  "transporter": 8,
  "transporter_email": "driver@example.com",
  "created_at": "2026-04-12T12:00:00Z"
}
```

---

### POST /api/shipper/favorites/transporters/

**Auth required:** Yes (Shipper)

**Request body:**

```json
{
  "transporter": 8
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| transporter | integer | Yes | User id of the transporter to favorite |

**Response `201`:** Created favorite (or `200` if it already existed).

---

### DELETE /api/shipper/favorites/transporters/{id}/

**Auth required:** Yes (Shipper)

**Request body:** None

**Response `200`:** Favorite removed.

**Error `404`:** Favorite id not found for this shipper.

---

## Transporter 1 — Registration, Onboarding & Document Verification

These endpoints allow drivers and fleet owners to register, manage their profile, and upload required documents. **API enforcement:** `GET/PATCH /api/transporter/profile/`, document upload, and fleet/vehicle CRUD use transporter auth only so onboarding works before approval. **Load discovery, bidding, bid history, trip location, trip status updates, and POD** require `documents_verified=true` on the transporter profile (same rule as the product requirement that operational use stays restricted until admin approval).

### Account Types

| Type | Description | When to Use |
|------|-------------|-------------|
| **DRIVER** | Individual driver operating a single vehicle | Self-employed drivers, owner-operators |
| **FLEET_OWNER** | Transporter / fleet owner managing multiple vehicles or drivers | Logistics companies, transport businesses |

**Differences:**

| | DRIVER | FLEET_OWNER |
|---|--------|-------------|
| **company_name** | Usually empty | Typically the company/fleet name |
| **Document flow** | Same for both (license, vehicle reg, insurance, permit) | Same for both |

### Transporter API Summary

| Method | Endpoint | Auth | Payload | Response |
|--------|----------|------|---------|----------|
| POST | `/api/transporter/register/` | No | `email`, `password`, `account_type`, optional `avatar` (base64), etc. | `token`, `user_id`, `email` |
| GET | `/api/transporter/profile/` | Yes | None | Full profile + `documents_verified`, `account_type`, `avatar_url` |
| PATCH | `/api/transporter/profile/` | Yes | `first_name`, `last_name`, `phone`, `company_name`, `language`, optional `avatar` | Updated profile |
| GET | `/api/transporter/documents/` | Yes | None | Array of documents |
| POST | `/api/transporter/documents/` | Yes | `document_type`, `file` (base64) | Created document |
| GET | `/api/transporter/vehicles/` | Yes | None | Array of transporter-owned vehicles |
| POST | `/api/transporter/vehicles/` | Yes | `vehicle_type`, `registration_number`, `load_capacity`, optional `avatar`, `max_length_m`, `max_width_m`, `max_height_m`, `special_features` | Created vehicle (`is_active=false` , `is_verified=false`) |
| GET | `/api/transporter/vehicles/{id}/` | Yes | None | Vehicle detail |
| PATCH | `/api/transporter/vehicles/{id}/` | Yes | Partial vehicle fields including optional `avatar` | Updated vehicle |
| POST | `/api/transporter/vehicles/{id}/assign-driver/` | Yes | `driver_id` (or `null` to unassign) | Updated vehicle assignment |
| GET | `/api/transporter/vehicles/{id}/documents/` | Yes | None | Vehicle documents |
| POST | `/api/transporter/vehicles/{id}/documents/` | Yes | `document_type`, `file` (base64) | Uploaded vehicle document |
| GET, POST | `/api/transporter/drivers/` | Yes (fleet owner) | POST: driver fields + optional `avatar`, `documents[]` (same KYC types/expiry as individual driver) | Fleet drivers list / created driver |
| GET, PATCH, DELETE | `/api/transporter/drivers/{id}/` | Yes (fleet owner) | PATCH: partial fields + optional `documents[]` | Driver detail / update / unlink |
| GET, POST | `/api/transporter/drivers/{id}/documents/` | Yes (fleet owner) | POST: same types as individual driver KYC (`DRIVER_LICENSE`, `PASSPORT_COPY`, `PERMIT`, `COUNTRY_GCC`, optional `NOC`); `passport_number` required for `PASSPORT_COPY`. `VEHICLE_REGISTRATION` not accepted. | Driver KYC documents |
| GET | `/api/transporter/drivers/{id}/current-location/` | Yes (fleet owner) | None | Latest Traccar location for linked fleet driver (`tc_id` from driver profile) |
| GET | `/api/transporter/load-discovery/` | Yes (verified transporter) | Query: required `country_code`; optional `load_type` (`local` \| `country_to_country`) | Nearby published loads in pickup country and zone radius (caller `tc_id`) |
| GET | `/api/transporter/available-shipments/` | Yes (verified transporter) | Query: `country_code` required for individual drivers; optional for fleet owners; optional `load_type` | Individual: GPS zone + vehicle type. Fleet owner: linked-driver GPS country + zone |
| POST | `/api/transporter/shipments/{shipment_id}/bid/` | Yes (verified) | `action` + optional `amount`,`message` | Bid submit (accept/counter) |
| GET | `/api/transporter/shipments/{shipment_id}/bids/` | Yes (verified) | None | Logged-in transporter bid history for load |
| POST | `/api/transporter/trips/{id}/location/` | Yes (verified) | `lat`, `lon` | Success message |
| PATCH | `/api/transporter/trips/{id}/status/` | Yes (verified) | `status`, optional `lat`, `lon`, `recorded_at` | Sequential trip status advance |
| POST | `/api/transporter/trips/{id}/pod/` | Yes (verified) | `receiver_name`, `receiver_signature`, etc. (multipart) | POD object |
| POST | `/api/transporter/trips/{id}/shipper-review/` | Yes (transporter) | `shipper_rating`, optional `comment` | Submit one-time shipper review |
| GET | `/api/transporter/wallet/` | Yes | None | Wallet balance |
| GET | `/api/transporter/ledger/` | Yes | None | Ledger entries (up to 500) |
| GET | `/api/transporter/earnings/` | Yes | None | Earning credits + `total_credited` |
| GET, POST | `/api/transporter/withdrawals/` | Yes | POST: `amount`, optional `note` | Withdrawal requests |
| POST | `/api/transporter/trips/{id}/confirm-cash/` | Yes (transporter) | None | Confirm COD collected; credit transporter wallet |
| POST | `/api/transporter/trips/{id}/confirm-card/` | Yes (trip transporter only) | None | Confirm in-person card payment; credit transporter wallet |
| GET | `/api/transporter/ratings/summary/` | Yes | None | Average rating + count |
| GET | `/api/transporter/ratings/received/` | Yes | None | Reviews received |

Staff-facing **`/api/admin/...`** routes (platform settings, vehicle verification, document review) are listed in the **Admin API** section and in the **Complete endpoint index**.

---

### POST /api/transporter/register/

Register a new transporter (driver or fleet owner) and receive an auth token.

**Auth required:** No (public)

**Request body (DRIVER example):**

```json
{
  "email": "driver@example.com",
  "password": "SecurePass123",
  "first_name": "Ahmed",
  "last_name": "Raza",
  "phone": "03001234567",
  "account_type": "DRIVER",
  "company_name": "",
  "local": true,
  "country_to_country": false,
  "language": "en",
  "avatar": "data:image/jpeg;base64,/9j/4AAQ..."
}
```

**Request body (FLEET_OWNER example):**

```json
{
  "email": "fleet@logistics.com",
  "password": "SecurePass123",
  "first_name": "Ahmed",
  "last_name": "Khan",
  "phone": "03001234567",
  "account_type": "FLEET_OWNER",
  "company_name": "Khan Logistics Pvt Ltd",
  "local": false,
  "country_to_country": true,
  "language": "en"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| email | string | Yes | Used as login username |
| password | string | Yes | Min 8 characters |
| first_name | string | No | |
| last_name | string | No | |
| phone | string | No | |
| account_type | string | Yes | `DRIVER` or `FLEET_OWNER` |
| company_name | string | Conditional | **Required** for `FLEET_OWNER`; optional for `DRIVER` |
| local | boolean | Yes | Load preference; exactly one of `local` or `country_to_country` must be `true` |
| country_to_country | boolean | Yes | Cross-border load preference; `country_to_country=true` transporters can see/bid on both load types |
| language | string | No | Default `en`; used for app localization |
| avatar | string | No | Base64 image (optional `data:image/...;base64,` prefix). Decoded and stored; profile returns absolute `avatar_url` |

**Response `201`:**

```json
{
  "status_code": 201,
  "message": "Transporter registered successfully.",
  "error": null,
  "data": {
    "token": "9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b",
    "user_id": 8,
    "email": "driver@example.com"
  }
}
```

**Error `400`:**

```json
{
  "status_code": 400,
  "message": "Validation failed.",
  "error": {
    "email": ["A user with this email already exists."]
  },
  "data": null
}
```

---

### GET /api/transporter/profile/

Get the logged-in transporter's profile, including verification status and account type.

**Auth required:** Yes (Transporter)

**Request:** None (GET)

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Profile retrieved successfully.",
  "error": null,
  "data": {
    "user_id": 8,
    "email": "driver@example.com",
    "first_name": "Ahmed",
    "last_name": "Raza",
    "phone": "03001234567",
    "language": "en",
    "account_type": "DRIVER",
    "company_name": "",
    "local": true,
    "country_to_country": false,
    "documents_verified": false,
    "tc_id": "123",
    "tc_u_id": "A1B2C3D4E5",
    "avatar_url": "https://api.example.com/media/avatars/user/2026/07/20/abc123.jpg",
    "created_at": "2026-03-19T10:00:00Z",
    "updated_at": "2026-03-19T10:00:00Z"
  }
}
```

| Field | Notes |
|---|---|
| documents_verified | `true` once admin has verified documents; required for load discovery, bidding, trip location/status, and POD |
| account_type | `DRIVER` or `FLEET_OWNER` |
| local / country_to_country | Transporter load preference (XOR). `local=true` → local shipments only; `country_to_country=true` → local and cross-border shipments |
| tc_id | Traccar device ID (driver accounts) |
| tc_u_id | Traccar unique tracker ID (driver accounts) |
| avatar_url | Absolute URL of profile photo when set; empty string otherwise |

---

### PATCH /api/transporter/profile/

Update the logged-in transporter's profile.

**Auth required:** Yes (Transporter)

**Request body (partial):**

```json
{
  "first_name": "Ahmed",
  "last_name": "Raza",
  "phone": "03009876543",
  "company_name": "Raza Transport",
  "language": "ur",
  "local": false,
  "country_to_country": true,
  "avatar": "data:image/jpeg;base64,/9j/4AAQ..."
}
```

When updating load type, **both** `local` and `country_to_country` must be sent (exactly one `true`).

| Field | Type | Required | Notes |
|---|---|---|---|
| first_name | string | No | |
| last_name | string | No | |
| phone | string | No | |
| company_name | string | No | For `FLEET_OWNER` accounts |
| language | string | No | e.g. `en`, `ur` |
| avatar | string | No | Base64 image; stored as absolute `avatar_url` on the profile |

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Profile updated successfully.",
  "error": null,
  "data": {
    "user_id": 8,
    "email": "driver@example.com",
    "first_name": "Ahmed",
    "last_name": "Raza",
    "phone": "03009876543",
    "language": "ur",
    "account_type": "DRIVER",
    "company_name": "Raza Transport",
    "documents_verified": false,
    "tc_id": "123",
    "tc_u_id": "A1B2C3D4E5",
    "created_at": "2026-03-19T10:00:00Z",
    "updated_at": "2026-03-19T10:30:00Z"
  }
}
```

---

### GET /api/transporter/documents/

List KYC documents for the logged-in transporter. Allowed types depend on `account_type`.

**Auth required:** Yes (Transporter)

**Request:** None (GET)

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Documents retrieved successfully.",
  "error": null,
  "data": [
    {
      "id": 1,
      "document_type": "DRIVER_LICENSE",
      "file": "/media/kyc/2026/03/19/abc123.png",
      "file_url": "http://your-domain.com/media/kyc/2026/03/19/abc123.png",
      "file_back": "/media/kyc/2026/03/19/abc124.png",
      "file_back_url": "http://your-domain.com/media/kyc/2026/03/19/abc124.png",
      "verified": false,
      "review_status": "PENDING",
      "review_notes": "",
      "expiry_date": null,
      "submitted_at": "2026-03-19T10:15:00Z",
      "reviewed_at": null
    }
  ]
}
```

`review_status` is `PENDING`, `APPROVED`, or `REJECTED`. When `REJECTED`, `review_notes` contains the admin reason. Re-uploading a file for the same `document_type` resets status to `PENDING` and clears notes. On reject, the user also receives an in-app/FCM notification with `event_type=DOCUMENT_REJECTED` (`kind=KYC`, `document_type`, `reason`).

**Document types by account:**

| Account type | Allowed `document_type` values |
|---|---|
| `DRIVER` (individual) | `DRIVER_LICENSE`, `PASSPORT_COPY`, `PERMIT`, `COUNTRY_GCC`, optional `NOC` |
| `FLEET_OWNER` | `COMPANY_REGISTRATION`, `COMPANY_OWNERSHIP`, `COMPANY_ADDRESS_PROOF`, `OTHER_COMPLIANCE`, `TRADE_LICENSE`, `PASSPORT_COPY`, `COUNTRY_GCC` |

Two-sided types (`DRIVER_LICENSE`, `COUNTRY_GCC`) use `file` for the front and `file_back` for the back. Single-sided types (`PASSPORT_COPY`, `PERMIT`, `NOC`, `TRADE_LICENSE`, and other fleet-owner company types) use `file` only. Vehicle `NOC` is uploaded under vehicle documents, not KYC.

For `PASSPORT_COPY`, `passport_number` is required and must be unique across shippers, fleet owners, individual drivers, and fleet drivers. List/detail responses include `passport_number` when set.

---

  ### POST /api/transporter/documents/

  Upload a transporter document (one row per `document_type`; re-posting the same type updates that document).

  **Auth required:** Yes (Transporter)  
  **Content-Type:** `application/json`

  **Request body (two-sided example):**

  ```json
  {
    "document_type": "DRIVER_LICENSE",
    "file": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
    "file_back": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
  }
  ```

  | Field | Type | Required | Notes |
  |---|---|---|---|
  | document_type | string | Yes | Depends on account type. Individual driver: `DRIVER_LICENSE`, `PASSPORT_COPY`, `PERMIT`, `COUNTRY_GCC`, optional `NOC`. Fleet owner: `COMPANY_REGISTRATION`, `COMPANY_OWNERSHIP`, `COMPANY_ADDRESS_PROOF`, `OTHER_COMPLIANCE`, `TRADE_LICENSE`, `PASSPORT_COPY`, `COUNTRY_GCC`. |
  | file | string | No | Front side (or sole file for single-sided types). **Required on first upload** for a given `document_type`; optional when updating an existing document. |
  | file_back | string | No | Back side for two-sided types. **Required on first upload** for `DRIVER_LICENSE`, `COUNTRY_GCC`; optional on later updates. Not used for fleet-owner company docs. |
  | passport_number | string | Conditional | **Required** for `PASSPORT_COPY` (first upload and whenever replacing passport). Unique across all users. |
  | expiry_date | date (YYYY-MM-DD) | No | Optional anytime; may be sent alone to update expiry on an existing document (`null` clears it). |

  **Example (curl):**

  ```bash
  curl -X POST http://your-domain.com/api/transporter/documents/ \
    -H "Authorization: Token <your_token>" \
    -H "Content-Type: application/json" \
    -d '{"document_type":"DRIVER_LICENSE","file":"data:image/png;base64,iVBORw0KGgo...","file_back":"data:image/png;base64,iVBORw0KGgo..."}'
  ```

  **Response `201`:**

  ```json
  {
    "status_code": 201,
    "message": "Document uploaded successfully.",
    "error": null,
    "data": {
      "id": 2,
      "document_type": "DRIVER_LICENSE",
      "file": "/media/kyc/2026/03/19/xyz789.png",
      "file_url": "http://your-domain.com/media/kyc/2026/03/19/xyz789.png",
      "verified": false,
      "review_status": "PENDING",
      "review_notes": "",
      "submitted_at": "2026-03-19T10:20:00Z",
      "reviewed_at": null
    }
  }
  ```

  **Error `400`:**

  ```json
  {
    "status_code": 400,
    "message": "Validation failed.",
    "error": {
      "document_type": ["Invalid choice."]
    },
    "data": null
  }
  ```

---

## Transporter 2 - Vehicle & Fleet Management

Vehicles are transporter-owned resources used for load eligibility checks. A vehicle is created as inactive/unverified and becomes eligible only after admin verification. A vehicle may declare **multiple types**; load matching succeeds when the shipment’s `vehicle_type_required` matches **any** of those types.

### Operational Flow

1. Transporter creates vehicle.
2. Transporter uploads vehicle documents.
3. Admin reviews documents and verifies vehicle.
4. Verified + active vehicle becomes eligible for load matching.

### POST /api/transporter/vehicles/

Create a vehicle under the logged-in transporter.

**Auth required:** Yes (Transporter)

**Request body (preferred — multiple types):**

```json
{
  "vehicle_types": ["Container 40 Feet / 20 Feet", "Flat Bed 12m"],
  "registration_number": "ABC-123",
  "load_capacity": "15000.00",
  "max_length_m": "6.500",
  "max_width_m": "2.450",
  "max_height_m": "2.600",
  "special_features": "refrigeration, container",
  "avatar": "data:image/jpeg;base64,/9j/4AAQ..."
}
```

**Request body (legacy — single type still accepted):**

```json
{
  "vehicle_type": "Flat Bed 12m",
  "registration_number": "ABC-123",
  "load_capacity": "15000.00"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| vehicle_types | string[] | Preferred | One or more types. Load matching uses **any** entry vs shipment `vehicle_type_required`. Deduped case-insensitively. |
| vehicle_type | string | Legacy | Single type; stored as `vehicle_types: [vehicle_type]`. Response `vehicle_type` is always the first of `vehicle_types`. Provide `vehicle_types` **or** `vehicle_type` on create. |
| registration_number | string | Yes | Unique per owner |
| load_capacity | decimal | Yes | Numeric capacity used vs parsed shipment weight |
| max_length_m / max_width_m / max_height_m | decimal | No | Optional; all three enable dimension filtering in load discovery |
| special_features | string | No | |
| avatar | string | No | Base64 image; decoded and stored as absolute `avatar_url` on the vehicle |
| noc_file | string | No | Optional NOC document (base64). **Not required** to create a vehicle. When set, stores a `VehicleDocument` with `document_type=NOC`. |
| noc_expiry_date | date (YYYY-MM-DD) | No | Optional; only with `noc_file` on create (or to update expiry on an existing NOC via PATCH) |

**Response `201`:** Standard envelope; `data` is the created vehicle (`is_verified: false`, `is_active: false`, includes `avatar_url` when set, plus `vehicle_types` array and primary `vehicle_type`).

### GET /api/transporter/vehicles/

List all vehicles owned by the logged-in transporter.

### GET /api/transporter/vehicles/{id}/

Get one transporter-owned vehicle.

### PATCH /api/transporter/vehicles/{id}/

Update transporter-owned vehicle details.

### POST /api/transporter/vehicles/{id}/assign-driver/

Assign or unassign a driver to a vehicle.

**Request body (assign):**

```json
{
  "driver_id": 14
}
```

**Request body (unassign):**

```json
{
  "driver_id": null
}
```

### GET /api/transporter/vehicles/{id}/documents/

List documents for a specific vehicle.

### POST /api/transporter/vehicles/{id}/documents/

Upload a vehicle document.

**Request body:**

```json
{
  "document_type": "NOC",
  "file": "data:application/pdf;base64,JVBERi0xLjcKJc..."
}
```

**Document types:** `NOC`, `INSURANCE`, `PERMIT`, `FITNESS`, `OTHER`

`NOC` may also be attached **optionally** on vehicle create/update via `noc_file` + optional `noc_expiry_date` (see vehicle create above). Creating a vehicle without NOC is allowed.

Optional JSON field **`expiry_date`** (YYYY-MM-DD) may be sent with the upload for compliance tracking.

**Vehicle request body (create / patch):** include optional `max_length_m`, `max_width_m`, `max_height_m` (meters, decimals) for dimension-based load matching when shipment `dimensions` contains three parseable numbers. Optional **`avatar`** (base64 image) is decoded and stored as absolute **`avatar_url`** on the vehicle.

**Document expiry reminders:** Celery beat runs every minute (`DOCUMENT_EXPIRY_REMINDER_BEAT_MINUTES`, default `1`) via `accounts.tasks.send_document_expiry_reminders` and sends `DOCUMENT_EXPIRING` FCM to **fleet owners** and **individual drivers** at **31**, **7**, and **1** day(s) before each document’s `expiry_date`, plus **hourly on expiry day and every day after** until `expiry_date` is updated (**max once per local hour per day**, even though Celery runs every minute). Idempotent per `(document, expiry_date, days_before, reminder_hour, reminder_on)` via `DocumentExpiryReminderLog`. Configure day windows with `DOCUMENT_EXPIRY_REMINDER_DAYS_BEFORE=31,7,1`. Fallback without Celery: `python manage.py send_document_expiry_reminders`. Requires Redis (`CELERY_BROKER_URL`) and `celery -A Loadboard worker` + `celery -A Loadboard beat`.

**Responses:** `GET`/`POST`/`PATCH` vehicle endpoints return the standard envelope; `data` is a vehicle object or array.

---

### Load matching and bid acceptance (reference)

For discovery, the transporter must have at least one **verified** and **active** vehicle matching `vehicle_type_required`, with enough **weight** capacity and (when configured) **dimension** fit: shipment `dimensions` parses to three numbers; sorted values must fit within sorted `max_length_m`, `max_width_m`, `max_height_m` when all three are set on a candidate vehicle.

Bid acceptance uses the same vehicle eligibility rules, counting vehicles the transporter **owns** or that are **assigned** to them as `assigned_driver`.

---

## Transporter 3 - Load Discovery, Filtering, and Bidding

These endpoints implement:
- Location-based load discovery from Traccar device position
- **Required `country_code`:** only loads whose `pickup_country_code` matches the query param are returned
- **Zone radius:** **local** drivers only see pickups within the zone `radius_km` of their Traccar position (for `country_code`). **C2C** drivers use that radius for local loads; cross-border C2C loads need pickup in `country_code` and drop-off in another country (not radius-gated).
- **Vehicle-type filter:** individual / fleet-linked drivers match verified active vehicles they own or are assigned to. A vehicle may list multiple `vehicle_types`; a load matches if `vehicle_type_required` equals **any** of those types. On `available-shipments` a verified vehicle is **required** (no match → empty list). `load-discovery` still lists nearby loads when the caller has no verified vehicle.
- **Load type visibility:** `local=true` on the transporter profile shows only local shipments. `country_to_country=true` shows nearby local shipments and C2C shipments (pickup in `country_code`, delivery in another country). Applies to `GET /api/transporter/load-discovery/` and bid submission (`403` if mismatched). `available-shipments` still uses load type and `PUBLISHED` (individual drivers also apply country/radius/vehicle there).
- **Optional `load_type` query filter:** on both `load-discovery` and `available-shipments`, pass `load_type=local` or `load_type=country_to_country` for a **strict** shipment-type filter. `country_to_country` returns only cross-border C2C loads (`local=false`, pickup country ≠ delivery country) — never local/same-country loads. Omit to keep profile-based visibility. Invalid values → `400`.
- Each load in `loads` includes **`shipper`** (public summary: id, email, name, phone, account_type, company_name), **`suggested_price`**, **`distance_from_driver_km`**, **`matched_vehicle_type`**, **`favorite_for_this_shipper`**, and **`nearby_drivers`** (`[]` on this path)
- Driver/transporter bidding (accept proposed rate or counter-offer)
- Bid history and bid locking after acceptance

### GET /api/transporter/load-discovery/

Fetch nearby loads for an **individual driver**. Query params: required `country_code`, optional `load_type`. Every individual driver must have `tc_id`.

- Live Traccar from the caller's `tc_id`. Missing `tc_id` → `400`.
- Pickup country is the query `country_code`.
- **Local** operation type (`local=true`): only **local** loads whose pickup is within that country's zone radius of the driver GPS.
- **C2C** operation type (`country_to_country=true`): nearby **local** loads in that zone, plus **C2C** loads with pickup in `country_code` and drop-off in a **different** country.
- Optional **`load_type`:** `local` → only local loads; `country_to_country` → only C2C loads (strict; does not include local). Omit for profile-based visibility above.

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Query params:**

| Param | Type | Required | Notes |
|---|---|---|---|
| country_code | string | **Yes** | ISO 3166-1 alpha-2. Pickup country. Local: zone radius. C2C: pickup country; drop-off must be another country. |
| load_type | string | No | `local` or `country_to_country`. Strict shipment-type filter. Invalid → `400`. |

Loads must match a verified active vehicle the caller owns or is assigned to when any such vehicles exist (`vehicle_type_required`). If the caller has no verified vehicle, nearby loads in the zone are still returned.

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Nearby loads retrieved successfully.",
  "error": null,
  "data": {
    "driver_location": { "lat": 24.8607, "lon": 67.0011 },
    "country_code": "AE",
    "radius_km": 50.0,
    "radius_source": "zone",
    "count": 1,
    "loads": [
      {
        "id": 15,
        "pickup_address": "Dubai",
        "pickup_country_code": "AE",
        "delivery_address": "Riyadh",
        "delivery_country_code": "SA",
        "vehicle_type_required": "Flat Bed 12m",
        "weight": "500 kg",
        "suggested_price": "4200.00",
        "currency": "AED",
        "status": "PUBLISHED",
        "shipper": {
          "id": 14,
          "email": "shipper@example.com",
          "first_name": "Ali",
          "last_name": "Khan",
          "phone": "03001234567",
          "account_type": "INDIVIDUAL",
          "company_name": ""
        },
        "distance_from_driver_km": 12.42,
        "matched_vehicle_type": "Flat Bed 12m",
        "max_capacity_for_type": 5.0,
        "favorite_for_this_shipper": false,
        "nearby_drivers": []
      }
    ]
  }
}
```

### GET /api/transporter/available-shipments/

**Individual / fleet-linked driver:** nearby published loads for the caller's **live** Traccar GPS (fetched on every request).

- `country_code` is **required** (pickup country).
- Driver GPS must be in that country; otherwise `200` with empty `shipments` (still includes `driver_location`).
- Pickup must be within that country's zone radius of the driver.
- A verified+active vehicle is required; `vehicle_type_required` must match that vehicle. No matching vehicle → empty list.
- Missing `tc_id` or Traccar failure → `400`.

**Fleet owner:** lists published loads **near linked drivers' live GPS**. Pickup must be in that driver's GPS country and (for local loads) within the country's zone radius. C2C owners also see C2C loads with pickup in the driver's country and drop-off in another country. A driver in Dubai sees UAE local + UAE-pickup C2C loads even if the fleet GCC/profile country is Pakistan. `country_code` is **ignored** (do not send the owner's registered country).

If the fleet owner has **no approved driver** (`documents_verified`) or **no verified active vehicle**, `shipments`, `driver_location`, and `driver_locations` are `null`.

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Query params:**

| Param | Type | Required | Notes |
|---|---|---|---|
| country_code | string | **Yes for individual / fleet-linked drivers** | ISO 3166-1 alpha-2. Ignored for fleet owners. |
| load_type | string | No | `local` or `country_to_country`. Strict shipment-type filter (C2C → only C2C loads). Invalid → `400`. |

**Response `200` (excerpt):**

```json
{
  "driver_location": { "lat": 24.8607, "lon": 67.0011 },
  "count": 1,
  "shipments": [
    {
      "id": 100,
      "pickup_country_code": "AE",
      "vehicle_type_required": "Flatbed",
      "suggested_price": "1500.00",
      "currency": "AED",
      "nearest_distance_km": null,
      "nearby_drivers": []
    }
  ]
}
```

### POST /api/transporter/shipments/{shipment_id}/bid/

Submit or update the transporter’s **single active bid** on a load: accept shipper proposed rate, counter-offer, or accept shipper’s counter.

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Bid gates:**
- Load type must match transporter profile (`403` / `Load type mismatch`).
- **Fleet owner:** must have at least one active linked driver (`400` / `Missing fleet driver`).
- Verified active vehicle matching the load’s vehicle type (`400` / `Missing verified active vehicle` or `Vehicle type mismatch`).

**One active bid:** rebids update the same `data.id`. Response is `201` on first create, `200` on update.

| `action` | Active bid state | Result |
|----------|------------------|--------|
| `COUNTER` | none | Create `PENDING` bid with `amount` |
| `COUNTER` | `PENDING` / `COUNTERED` | Update `amount`, `PENDING` |
| `ACCEPT` | none | Create `PENDING` at `suggested_price` |
| `ACCEPT` | `PENDING` | Update `amount` to `suggested_price`, `PENDING` |
| `ACCEPT` | `COUNTERED` | Set `amount` = `counter_amount`, `AGREED` |
| `ACCEPT` | `AGREED` | `400` — shipper must accept trip |
| any | `AGREED` + `COUNTER` | `400` — bid already agreed |

**Request body (accept proposed rate):**

```json
{
  "action": "ACCEPT",
  "message": "Accepted shipper proposed rate."
}
```

**Request body (accept shipper counter — only when bid is `COUNTERED`):**

```json
{
  "action": "ACCEPT",
  "message": "Accepted shipper counter-offer."
}
```

**Request body (counter-offer):**

```json
{
  "action": "COUNTER",
  "amount": "4200.00",
  "message": "Can do this at 4200."
}
```

**Response `201` (new bid):**

```json
{
  "status_code": 201,
  "message": "Bid submitted successfully.",
  "error": null,
  "data": {
    "id": 31,
    "transporter": 8,
    "transporter_email": "driver@example.com",
    "transporter_name": "Ahmed Raza",
    "amount": "4200.00",
    "currency": "AED",
    "status": "PENDING",
    "counter_amount": null,
    "message": "Can do this at 4200.",
    "created_at": "2026-03-24T12:30:00Z"
  }
}
```

**Response `200` (update existing bid or accept counter → `AGREED`):**

```json
{
  "status_code": 200,
  "message": "Counter-offer accepted.",
  "error": null,
  "data": {
    "id": 31,
    "transporter": 8,
    "transporter_email": "driver@example.com",
    "transporter_name": "Ahmed Raza",
    "amount": "4500.00",
    "currency": "AED",
    "status": "AGREED",
    "counter_amount": null,
    "message": "Accepted shipper counter-offer.",
    "created_at": "2026-03-24T12:30:00Z"
  }
}
```

**Error `400` (locked):**

```json
{
  "status_code": 400,
  "message": "Bidding is locked after load acceptance.",
  "error": "Bid locked.",
  "data": null
}
```

### GET /api/transporter/shipments/{shipment_id}/bids/

View logged-in transporter's bid history for a specific load.

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Bid history retrieved successfully.",
  "error": null,
  "data": [
    {
      "id": 31,
      "transporter": 8,
      "transporter_email": "driver@example.com",
      "transporter_name": "Ahmed Raza",
      "amount": "4200.00",
      "currency": "AED",
      "status": "PENDING",
      "counter_amount": null,
      "message": "Can do this at 4200.",
      "created_at": "2026-03-24T12:30:00Z"
    }
  ]
}
```

---

### GET /api/transporter/rate-requests/

List incoming rate requests sent to the authenticated transporter/driver (supports optional `?status=PENDING` query parameter).

**Auth required:** Yes (Transporter)

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Rate requests retrieved successfully.",
  "error": null,
  "data": {
    "count": 1,
    "results": [
      {
        "id": 12,
        "shipment_id": 45,
        "shipment": {
          "id": 45,
          "unique_id": "SHP-0045",
          "pickup_address": "Dubai Industrial City",
          "delivery_address": "Riyadh Logistics Park",
          "cargo_type": "Electronics",
          "weight": "12 tons",
          "vehicle_type_required": "Flatbed",
          "suggested_price": "3500.00"
        },
        "driver_id": 8,
        "driver_email": "driver@example.com",
        "driver_name": "Ahmed Raza",
        "batch_number": 1,
        "status": "PENDING",
        "distance_km": "4.25",
        "offered_price": "3500.00",
        "currency": "AED",
        "rejection_reason": "",
        "created_at": "2026-08-29T10:00:00Z",
        "responded_at": null
      }
    ]
  }
}
```

---

### POST /api/transporter/rate-requests/{id}/accept/

Accept a pending load rate request. Automatically cancels remaining pending requests in the batch and creates the assigned Trip.

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Rate request accepted and trip created successfully.",
  "error": null,
  "data": {
    "rate_request": {
      "id": 12,
      "shipment_id": 45,
      "status": "ACCEPTED",
      "offered_price": "3500.00",
      "currency": "AED"
    },
    "trip_id": 18,
    "shipment_id": 45,
    "unique_id": "SHP-0045"
  }
}
```

---

### POST /api/transporter/rate-requests/{id}/reject/

Reject a pending rate request with an optional reason. If all drivers in the current batch reject, the system automatically dispatches the next batch.

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Request body:**

```json
{
  "reason": "Currently undergoing vehicle maintenance."
}
```

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Rate request rejected successfully.",
  "error": null,
  "data": {
    "id": 12,
    "shipment_id": 45,
    "status": "REJECTED",
    "rejection_reason": "Currently undergoing vehicle maintenance.",
    "responded_at": "2026-08-29T10:05:00Z"
  }
}
```

---

## Transporter 4 — Trip Operations

These endpoints are for drivers to advance trip status sequentially, push location, and submit Proof of Delivery during active trips.

**Auth required:** Transporter with `documents_verified=true` for location, status, and POD.

**Persona-specific docs:**
- Individual Driver: `docs/TRANSPORTER_INDIVIDUAL_DRIVER_API.md`
- Fleet Owner (Transporter): `docs/TRANSPORTER_FLEET_OWNER_API.md`
- Fleet Driver (Transporter’s Driver): `docs/TRANSPORTER_FLEET_DRIVER_API.md`

**Recommended status sequence (driver):** `ASSIGNED` → `EN_ROUTE` → `ARRIVED_PICKUP` → `LOADED` → `IN_TRANSIT` → `ARRIVED_DELIVERY` → submit POD (`DELIVERED`) → `PATCH` to `COMPLETED`.

`DELIVERED` is set by POD submission, not by the status `PATCH` endpoint.

Optional server checks (see project `settings.py` / environment):

- `TRIP_STATUS_REQUIRE_GPS`: when `True`, each `PATCH .../status/` must include `lat` and `lon`.
- `TRIP_STATUS_MAX_LOCATION_AGE_SECONDS`: when `recorded_at` is sent, it must not be older than this many seconds.
- `POD_MAX_DELIVERY_DISTANCE_KM`: when set to a value `> 0`, POD submission validates `delivery_lat` / `delivery_lon` (if provided) against the shipment’s delivery coordinates (reject if farther than this many km).

### GPS §7 — Traccar + trip payload (no server-side routing)

This backend does **not** run a routing engine (no OSRM/Mapbox polyline here). **Live position and maps** should use **Traccar** with the driver’s device (`tc_id`, `tc_u_id` on the transporter profile). Trip APIs expose **pickup/delivery addresses and coordinates** on the nested `shipment` object.

- **`GET /api/transporter/trips/{trip_id}/`** — same trip payload as shipper-facing detail, plus `tc_id` and `tc_u_id` for the assigned transporter (this user).
- **`GET /api/transporter/trips/{trip_id}/navigation/`** — read-only bundle: pickup/delivery coords, optional `google_maps_directions_url`, `traccar_device_url_hint` (built from `TRACCAR_URL` env if set), straight-line `naive_route_distance_km`, and `naive_eta_minutes` using `NAVIGATION_NAIVE_SPEED_KPH` (default 45 km/h; illustrative only).

**Nearby loads without push:** poll `GET /api/transporter/load-discovery/`. When Traccar credentials are configured, transitioning a shipment from **DRAFT** to **PUBLISHED** may send **`NEARBY_LOAD`** FCM to other transporters whose last Traccar position is within `NEARBY_LOAD_NOTIFY_RADIUS_KM` (capped by `NEARBY_PUBLISH_NOTIFY_MAX`).

---

### GET /api/transporter/trips/{trip_id}/

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Response `200`:** `data` is the trip with nested `shipment`, `accepted_bid`, a top-level `shipper` object (`id`, `email`, `first_name`, `last_name`, `phone`, `account_type`, `company_name`), optional `assigned_driver` (same shape as `shipper`, or `null`), optional `assigned_vehicle` (`VehicleSerializer` for the vehicle assigned to the trip driver, or `null`), and extra fields `tc_id`, `tc_u_id` for Traccar.

---

### GET /api/transporter/trips/{trip_id}/navigation/

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Response `200`:** `data` includes `pickup`, `delivery`, `transporter_profile` (`tc_id`, `tc_u_id`), optional `google_maps_directions_url`, `traccar_device_url_hint`, `naive_route_distance_km`, `naive_eta_minutes`.

---

### POST /api/transporter/trips/{trip_id}/assign-driver/

Assign or unassign a **fleet driver** to a trip (fleet owner only).

**Auth required:** Yes (Transporter with `documents_verified=true` and `account_type=FLEET_OWNER`)

**Request body:**

```json
{
  "driver_id": 123
}
```

To **unassign**, send:

```json
{
  "driver_id": null
}
```

**Response `200`:** updated trip detail payload.

Sends **`TRIP_ASSIGNED`** FCM + in-app notification to the assigned driver (`trip_id`, `shipment_id`, `assigned_by`). Unassign (`driver_id: null`) does not send a notification.

---

### GET /api/transporter/trips/my/

Fleet owner: trips where `Trip.transporter = me`.

---

### GET /api/transporter/trips/assigned/

Fleet owner: trips where `Trip.transporter = me` and `assigned_driver != null`.

---

### GET /api/transporter/trips/active/

Fleet owner: trips where `Trip.transporter = me` and status is in progress.

---

### GET /api/transporter/trips/completed/

Fleet owner: trips where `Trip.transporter = me` and status = `COMPLETED`.

Same response shape as **`GET /api/transporter/my-trips/completed/`** (individual driver): `data.count`, `data.trips[]`, and each trip includes a **`payment`** object (`status`, `raw_status`, `method`, `amount`, `payment_id`, `can_confirm_cash`, `can_confirm_card`, `awaiting_shipper_payment`) for confirm-cash/card UI.

**Response `200` — `data`:**

```json
{
  "count": 1,
  "trips": [
    {
      "id": 7,
      "status": "COMPLETED",
      "shipment": { "id": 100, "pickup_address": "Warehouse A" },
      "accepted_bid": { "id": 55, "amount": "4200.00" },
      "shipper": { "id": 5, "email": "shipper@example.com" },
      "assigned_driver": null,
      "payment": {
        "status": "pending",
        "raw_status": "PENDING_COD",
        "method": "CASH",
        "amount": "4200.00",
        "payment_id": 91,
        "can_confirm_cash": true,
        "can_confirm_card": false,
        "awaiting_shipper_payment": false
      }
    }
  ]
}
```

---

### GET /api/transporter/driver/trips/assigned/

Fleet driver: trips where `assigned_driver = me` and status = `ASSIGNED`.

---

### GET /api/transporter/driver/trips/active/

Fleet driver: trips where `assigned_driver = me` and status is in progress.

---

### GET /api/transporter/driver/trips/completed/

Fleet driver: trips where `assigned_driver = me` and status = `COMPLETED`.

---

### POST /api/transporter/trips/{trip_id}/location/

Push current GPS location for a trip (called periodically by driver app).

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Request body:**

```json
{
  "lat": "31.5497",
  "lon": "74.3436"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| lat | string/decimal | Yes | GPS latitude |
| lon | string/decimal | Yes | GPS longitude |

**Response `201`:**

```json
{
  "status_code": 201,
  "message": "Location updated successfully.",
  "error": null,
  "data": null
}
```

**Error `400`:**

```json
{
  "status_code": 400,
  "message": "lat and lon are required.",
  "error": "Missing fields.",
  "data": null
}
```

**Error `404`:**

```json
{
  "status_code": 404,
  "message": "Trip not found.",
  "error": "Not found.",
  "data": null
}
```

---

### PATCH /api/transporter/trips/{trip_id}/status/

Advance the trip to the **next** allowed status in the PDF workflow. Body is JSON.

**Auth required:** Yes (Transporter with `documents_verified=true`)

**Request body:**

```json
{
  "status": "EN_ROUTE",
  "lat": 25.2048,
  "lon": 55.2708,
  "recorded_at": "2026-03-28T10:00:00Z"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| status | string | Yes | Must be exactly one allowed successor of the trip’s current status |
| lat | number | If `TRIP_STATUS_REQUIRE_GPS` | GPS latitude |
| lon | number | If `TRIP_STATUS_REQUIRE_GPS` | GPS longitude |
| recorded_at | string (ISO 8601) | No | Client timestamp for freshness checks |

**Response `200`:** Standard envelope; `data` matches the shipper trip list item shape (`id`, `shipment_id`, `status`, `shipper_rating`, `current_lat`, `current_lon`, `eta`, `created_at`, `updated_at`). The related **shipment** status is updated when the new trip status maps to a milestone (`EN_ROUTE`, `LOADED`, `IN_TRANSIT`, `DELIVERED`, `COMPLETED`).

**Example:**

```json
{
  "status_code": 200,
  "message": "Trip status updated successfully.",
  "error": null,
  "data": {
    "id": 7,
    "shipment_id": 1,
    "status": "EN_ROUTE",
    "shipper_rating": false,
    "current_lat": "25.2048000",
    "current_lon": "55.2708000",
    "eta": null,
    "created_at": "2026-03-17T11:05:00Z",
    "updated_at": "2026-03-28T10:00:00Z"
  }
}
```

**Error `400`:** Invalid transition, missing GPS when required, or stale `recorded_at`.

---

### POST /api/transporter/trips/{trip_id}/pod/

Submit Proof of Delivery after delivering cargo. Marks trip and shipment as `DELIVERED`. Supports uploading multiple POD photos/files in a single request or in subsequent calls to add more POD documents to the trip.

**Auth required:** Yes (Transporter with `documents_verified=true`)  
**Content-Type:** `multipart/form-data`

Trip must be in `IN_TRANSIT` or `ARRIVED_DELIVERY` before the initial POD is accepted. Additional POD photos can be uploaded while in `IN_TRANSIT`, `ARRIVED_DELIVERY`, or `DELIVERED`.

**Request fields:**

| Field | Type | Required | Notes |
|---|---|---|---|
| receiver_name | string | Yes (initial submission) | Name of person who received goods |
| receiver_signature | file | No | Image file of receiver's signature |
| delivery_lat | decimal | No | GPS latitude at delivery |
| delivery_lon | decimal | No | GPS longitude at delivery |
| delivered_at | datetime | No | ISO 8601; defaults to now |
| photos / pod_photos / pods | file (multiple) | No | Delivery photos/PODs (send multiple files under `photos`, `photos[]`, `pod_photos`, `pods`, or individual fields) |
| delivery_photo | file | No | Single delivery photo (alternative) |

**Example (curl):**

```bash
curl -X POST http://your-domain.com/api/transporter/trips/7/pod/ \
  -H "Authorization: Token <driver_token>" \
  -F "receiver_name=Usman Tariq" \
  -F "delivery_lat=31.5497" \
  -F "delivery_lon=74.3436" \
  -F "receiver_signature=@signature.png" \
  -F "photos=@photo1.jpg" \
  -F "photos=@photo2.jpg"
```

**Response `201`:**

```json
{
  "status_code": 201,
  "message": "POD submitted successfully.",
  "error": null,
  "data": {
    "id": 1,
    "receiver_name": "Usman Tariq",
    "receiver_signature": "/media/pod/signatures/2026/03/19/sig.png",
    "signature_url": "http://your-domain.com/media/pod/signatures/2026/03/19/sig.png",
    "delivery_lat": "31.5497",
    "delivery_lon": "74.3436",
    "delivered_at": "2026-03-17T15:00:00Z",
    "photos": [
      {
        "id": 1,
        "image": "/media/pod/photos/2026/03/19/photo.jpg",
        "image_url": "http://your-domain.com/media/pod/photos/2026/03/19/photo.jpg",
        "created_at": "2026-03-17T15:00:00Z"
      }
    ],
    "created_at": "2026-03-17T15:00:00Z"
  }
}
```

**Error `400`:**

```json
{
  "status_code": 400,
  "message": "POD already submitted.",
  "error": "Duplicate submission.",
  "data": null
}
```

```json
{
  "status_code": 400,
  "message": "receiver_name is required.",
  "error": "Missing field.",
  "data": null
}
```

**Error `404`:**

```json
{
  "status_code": 404,
  "message": "Trip not found.",
  "error": "Not found.",
  "data": null
}
```

---

## Transporter 5 — Wallet, ledger, earnings, withdrawals & cash confirmation

Wallet and ledger rows are shared with shipper settlement: **credits** use entry types `WALLET_CREDIT_EARNING` (wallet/card/cash settlement) or `CREDIT_EARNING` (business credit terms).

### GET /api/transporter/wallet/

**Auth required:** Yes (Transporter)

**Response `200` (`data`):** Same shape as shipper wallet (`balance`, `currency`, `updated_at`).

---

### GET /api/transporter/ledger/

**Auth required:** Yes (Transporter)

**Response `200`:** `data` is an array of ledger entries:

```json
{
  "id": 20,
  "amount": "4200.00",
  "entry_type": "WALLET_CREDIT_EARNING",
  "trip": 3,
  "payment": 10,
  "note": "Trip earning",
  "idempotency_key": "pay-credit-10-server-3-WALLET",
  "created_at": "2026-04-12T12:00:01Z"
}
```

Negative `amount` values are debits. Up to **500** newest entries.

---

### GET /api/transporter/earnings/

**Auth required:** Yes (Transporter)

**Response `200` (`data`):**

```json
{
  "total_credited": "8400.00",
  "entries": []
}
```

`entries` contains up to **200** ledger rows whose `entry_type` is `WALLET_CREDIT_EARNING` or `CREDIT_EARNING`.

---

### GET /api/transporter/earnings/by-trip/

**Auth required:** Yes (Transporter)

**Response `200` (`data`):**

```json
{
  "by_trip": [
    { "trip_id": 12, "total_credited": "4200.00" }
  ]
}
```

Sums `WALLET_CREDIT_EARNING` and `CREDIT_EARNING` ledger rows **per `trip_id`** (up to **300** most recent trips with earnings).

---

### GET /api/transporter/trips/{trip_id}/payment-summary/

**Auth required:** Yes (Transporter)

**Response `200`:** `data` contains `trip_id`, `payments` (where you are payer or payee), and shipper `invoices` for that trip (read-only transparency).

---

### GET /api/transporter/withdrawals/

**Auth required:** Yes (Transporter)

**Response `200`:** `data` is an array of withdrawal requests (`id`, `amount`, `currency`, `status`, `note`, `created_at`, `updated_at`).  
**Note:** Creating a request does **not** automatically debit the wallet; admin / payout processing is out of band.

---

### POST /api/transporter/withdrawals/

**Auth required:** Yes (Transporter)

**Request body:**

```json
{
  "amount": "500.00",
  "note": "Bank transfer preferred"
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| amount | decimal | Yes | Must be ≤ current wallet `balance` |
| note | string | No | |

**Response `201`:** `data` is the created withdrawal request (`status` starts as `PENDING`).

**Error `400`:** Amount invalid or insufficient balance.

---

### POST /api/transporter/trips/{trip_id}/confirm-cash/

Call after the shipper chose **CASH** payment and you have collected **physical cash**. Credits the transporter platform wallet only (shipper wallet is **not** debited). Marks the invoice **PAID** when applicable.

**Auth required:** Yes (trip `transporter` only — fleet owner or individual driver on the bid; not `assigned_driver`)

**Request body:** None

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Cash collection confirmed.",
  "error": null,
  "data": {}
}
```

**Error `400`:** No pending `PENDING_COD` cash payment for this trip.

---

### POST /api/transporter/trips/{trip_id}/confirm-card/

Call after the shipper chose **CARD** payment and you have collected **in-person card payment** (no external processor; confirmation is recorded in the platform ledger). Credits the transporter platform wallet only (shipper wallet is **not** debited). Marks the invoice **PAID** when applicable.

**Auth required:** Yes (trip `transporter` only — fleet owner or individual driver on the bid; not `assigned_driver`)

**Request body:** None

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Card payment confirmed.",
  "error": null,
  "data": {}
}
```

**Error `400`:** No pending `REQUIRES_ACTION` / `PROCESSING` card payment for this trip.

---

### POST /api/transporter/trips/{trip_id}/shipper-review/

Submit a one-time shipper review for a delivered/completed/closed trip. The authenticated user must be the trip transporter or the assigned driver.

**Auth required:** Yes (Transporter)

**Request body:**

```json
{
  "shipper_rating": 5,
  "comment": "Clear instructions and quick payment."
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| shipper_rating | integer | Yes | 1–5 |
| comment | string | No | Free text |

**Response `201` (`data`):**

```json
{
  "id": 1,
  "trip": 7,
  "shipper": 3,
  "transporter": 12,
  "reviewer": 12,
  "shipper_rating": 5,
  "comment": "Clear instructions and quick payment.",
  "created_at": "2026-04-27T10:00:00Z"
}
```

**Error `400`:** Trip not in allowed status, duplicate review, validation errors.

---

### GET /api/transporter/ratings/summary/

**Auth required:** Yes (Transporter)

**Response `200` (`data`):**

```json
{
  "average_transporter_rating": 4.6,
  "review_count": 12
}
```

---

### GET /api/transporter/ratings/received/

**Auth required:** Yes (Transporter)

**Response `200`:** `data` is an array of review objects (same fields as shipper trip review, up to 100).

---

### GET /api/transporter/analytics/

**Auth required:** Yes (Transporter)

**Response `200` (`data`):** `average_transporter_rating`, `review_count`, `acceptance_rate` (accepted bids / submitted bids in `ANALYTICS_TRANSPORTER_BID_WINDOW_DAYS`, default 90), `on_time_delivery_rate` (see `on_time_definition` in the payload), counts for the on-time denominator and trips missing `pickup_scheduled_at`.

---

### GET /api/transporter/compliance/summary/

**Auth required:** Yes (Transporter)

**Response `200`:** Counts of KYC and vehicle documents that are **expiring** within `DOCUMENT_REMINDER_DAYS`, **expired**, or have **no `expiry_date` set**.

---

## Push notifications (FCM data payload)

After `PATCH /api/auth/fcm-token/`, the server may send **Firebase Cloud Messaging** messages on business events. Payloads use **string keys and string values** in the `data` map, plus optional `notification` title/body.

Common **`event_type`** values:

| event_type | When | Useful `data` keys |
|------------|------|---------------------|
| `NEW_BID` | Transporter submitted a bid | `shipment_id`, `bid_id` |
| `BID_ACCEPTED` | Shipper accepted a bid | `trip_id`, `shipment_id`, `bid_id` |
| `TRIP_STATUS` | Trip status changed | `trip_id`, `shipment_id`, `status` |
| `TRIP_ASSIGNED` | Fleet owner assigned trip to driver | `trip_id`, `shipment_id`, `assigned_by` |
| `POD_SUBMITTED` | Driver uploaded POD | `trip_id`, `shipment_id` |
| `PAYMENT_RECEIVED` | Wallet/credit captured | `trip_id`, `payment_id`, `amount` |
| `PAYMENT_PENDING_COD` | Cash payment created | `trip_id`, `payment_id`, `amount` |
| `PAYMENT_PENDING_CARD` | Card payment selected by shipper | `trip_id`, `payment_id`, `amount` |
| `PAYMENT_CAPTURED` | COD or card confirmed by trip transporter | `trip_id`, `shipment_id` |
| `DOCUMENT_EXPIRING` | Celery beat / `send_document_expiry_reminders` | `kind` (`KYC` / `VEHICLE`), `document_id`, `expiry_date`, `days_before`, optional `vehicle_id`, `document_type` |
| `NEARBY_LOAD` | Shipment published; Traccar position within radius | `shipment_id`, `pickup_lat`, `pickup_lon` |
| `new_message` | Chat message sent | `type`=`New Message`, `tag` (JSON string of routing fields), plus flat `conversation_id`, `shipment_id`, `trip_id`, `message_id`, `sender_id`, `chat_message_type` (`TEXT` or `VOICE`). Also has FCM `notification` title/body. |

**FCM token storage:** `PATCH /api/auth/fcm-token/` persists the device token on `UserPushDevice` (one row per user). There is no `fcm_token` column on Django’s default `auth_user` table in this deployment.

---

## Admin API

Base path: `/api/admin/`. **Auth:** staff user (`is_staff` / superuser) **or** a user whose role is **ADMIN** (`IsPlatformAdmin`).

All responses use the [standard envelope](#standard-response-envelope).

### GET /api/admin/platform-settings/

Read default load-discovery radius and the cap applied to client `radius_km`.

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Platform settings retrieved successfully.",
  "error": null,
  "data": {
    "load_visibility_radius_km": "50.00",
    "load_visibility_max_radius_km": "500.00",
    "bidding_rate_distribution_count": 1
  }
}
```

### PATCH /api/admin/platform-settings/

Update the singleton platform settings (partial body allowed).

**Request body (example):**

```json
{
  "load_visibility_radius_km": "75.00",
  "load_visibility_max_radius_km": "300.00",
  "bidding_rate_distribution_count": 5
}
```

**Response `200`:** Same shape as GET; `data` reflects saved values.

### PATCH /api/admin/vehicle-documents/{id}/review/

Approve or reject a vehicle document uploaded by a transporter.

**Request body:**

```json
{
  "review_status": "APPROVED",
  "review_notes": "Document is valid."
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| review_status | string | Yes | `APPROVED` or `REJECTED` |
| review_notes | string | No | |
| expiry_date | date (YYYY-MM-DD) | No | Optional compliance field |

**Response `200`:** `data` is the vehicle document record (includes `review_status`, `reviewed_at`, `expiry_date`, etc.).

### PATCH /api/admin/vehicles/{id}/verify/

Set verification and activation flags on any vehicle (fleet-wide, not scoped to one owner in the URL).

**Request body (verify + activate):**

```json
{
  "is_verified": true,
  "is_active": true
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| is_verified | boolean | Yes | |
| is_active | boolean | No | Defaults to `true` when verifying if omitted |

**Request body (revoke):**

```json
{
  "is_verified": false,
  "is_active": false
}
```

**Response `200`:** `data` is the updated vehicle object (same fields as transporter vehicle API).

### GET /api/admin/dashboard/

Aggregated KPIs for an admin home screen (DB snapshot; refresh on demand).

**Query parameters:**

| Name | Type | Required | Notes |
|---|---|---|---|
| since | ISO-8601 datetime | No | Restricts **revenue** aggregates to `Payment` rows with `created_at >= since` |

**Response `200` — `data` shape (abbreviated):**

- `trips.active_count` — trips whose status is not `COMPLETED` or `CLOSED`.
- `trips.stale_trips_count` — trips in `EN_ROUTE` or `IN_TRANSIT` whose `updated_at` is older than `ADMIN_STALE_TRIP_HOURS` (default **48**; override with env `ADMIN_STALE_TRIP_HOURS`).
- `trips.by_status` — object mapping each `Trip.status` value to a count.
- `shipments.by_status` — object mapping each `Shipment.status` value to a count.
- `revenue.captured_payments_total` — string decimal: sum of `Payment` with `status=CAPTURED` (optionally since `since`).
- `revenue.captured_payments_count` — number of such payments.
- `commission` — always `null` until a commission model exists.
- `availability` — counts for vehicles (`total`, `verified`, `active_and_verified`) and transporters with `documents_verified=true`.

### GET /api/admin/users/

Paginated directory of accounts.

**Query parameters:**

| Name | Type | Required | Notes |
|---|---|---|---|
| page | int | No | Default `1` |
| page_size | int | No | Default `20`, max `100` |
| role | string | No | `SHIPPER`, `TRANSPORTER`, or `ADMIN` |
| is_active | bool | No | `true` / `false` |
| search | string | No | Substring match on `email` or `username` |
| kyc_verified | bool | No | Filters on `shipper_profile.kyc_verified` (users without a shipper profile are excluded when this filter is set) |
| documents_verified | bool | No | Filters on `transporter_profile.documents_verified` |

**Response `200`:** `data` has `results` (list of user summaries: `id`, `email`, `username`, names, `is_active`, `is_staff`, `date_joined`, `role`), plus `page`, `page_size`, `total`.

### GET /api/admin/users/{id}/

Full user summary for admin review: core fields, nested `shipper_profile` / `transporter_profile` when present, `counts`, and `recent_shipments` or `recent_trips` (last 10) depending on role.

### PATCH /api/admin/users/{id}/

Partial updates (JSON object). Only supplied keys are applied.

| Field | Type | Notes |
|---|---|---|
| is_active | boolean | Suspend / restore login (`is_active` on Django user). Cannot set `false` on **your own** user id. |
| role | string | Must be a `UserRole.Role` value. Assigning `ADMIN` is **only** allowed if the caller is a Django **superuser** (`is_superuser`). |
| kyc_verified | boolean | Shipper profile only; 400 if the user is not a shipper. |
| credit_approved | boolean | Shipper profile only. |
| documents_verified | boolean | Transporter profile only; 400 if not a transporter. |

**Response `200`:** Same shape as GET detail.

### GET /api/admin/shipments/

Paginated shipments (includes `shipper` id on each row).

**Query parameters:** `status`, `shipper_id`, `created_from`, `created_to` (ISO-8601 datetimes), `page`, `page_size`.

### GET /api/admin/shipments/{id}/

Shipment detail (`AdminShipmentSerializer` fields) plus `trip_id` when a trip exists.

### GET /api/admin/trips/

Paginated trips (`TripListSerializer` rows: `id`, `shipment_id`, `status`, `shipper_rating`, coordinates, timestamps).

**Query parameters:** `status`, `transporter_id`, `shipment_id`, `page`, `page_size`.

### GET /api/admin/trips/{id}/

Trip detail (`TripDetailSerializer`: nested `shipment`, `accepted_bid`, `transporter`, etc.) plus `has_pod` (boolean).

### GET /api/admin/trips/{id}/locations/

Paginated `TripLocation` rows (same fields as shipper locations: `id`, `lat`, `lon`, `recorded_at`). Query: `page`, `page_size`.

### GET /api/admin/trips/{id}/pod/

Same payload as shipper `GET /api/shipper/trips/{id}/pod/` (read-only `PODSerializer`), without shipper ownership checks.

### POST /api/admin/trips/{id}/status-override/

Manual lifecycle correction with audit trail.

**Request body:**

```json
{
  "new_status": "IN_TRANSIT",
  "justification": "Ops corrected GPS outage; driver confirmed by phone."
}
```

| Field | Type | Required | Notes |
|---|---|---|---|
| new_status | string | Yes | Any value from `Trip.Status` |
| justification | string | Yes | Non-empty; stored in `core.AdminTripAction` |

**Behavior:** Updates `Trip.status`, appends a row to **`AdminTripAction`** (`previous_status`, `new_status`, `performed_by`, `justification`, `created_at`), then aligns **`Shipment.status`** using the same mapping as driver updates (`_sync_shipment_status_from_trip`) **plus** mapping **`CLOSED` trip → `CLOSED` shipment** (driver flow does not set shipment closed). Trip statuses without a shipment mapping leave the shipment unchanged.

Sends an FCM data notification (`TRIP_STATUS`, `admin_override: true`) to shipper and transporter, like other trip updates.

**Response `200`:** `TripListSerializer` data for the trip.

### GET /api/admin/vehicle-documents/

Paginated list for review queues. Each item is `VehicleDocumentSerializer` data plus `vehicle_owner_id` and `vehicle_owner_email`.

**Query parameters:** `review_status` (e.g. `PENDING`), `vehicle_id`, `page`, `page_size`.

### GET /api/admin/vehicles/

Paginated `VehicleSerializer` list.

**Query parameters:** `is_verified`, `is_active`, `owner_id`, `page`, `page_size`.

### GET /api/admin/kyc-documents/

Paginated `KYCDocumentSerializer` list (shipper and transporter uploads).

**Query parameters:** `verified` (`true`/`false`), `user_id`, `page`, `page_size`.

### GET /api/admin/countries/

List countries for freight-route dropdowns from **`core/countries.py`** (`COUNTRY_CHOICES`) — not a database table.

**Query:** optional `search` (code or name substring).

**Response `200` — `data`:** array of `{code, name}`.

### GET /api/admin/freight-routes/

Paginated directional minimum freight rates (`origin_country_code → destination_country_code`).

**Query:** `origin` / `destination` (ISO country codes), `search` (code or name via `core.countries`), `ordering` (`updated_at`, `-updated_at`, `min_freight`, `-min_freight`, `origin_country_code`, …), `page`, `page_size`.

**Response `200` — `data`:** `{results: [...], page, page_size, total}` where each result includes `origin_country_code`, `destination_country_code`, `origin_country_name`, `destination_country_name` (names from `country_name_for_code`), `min_freight`, `currency`, `updated_at`.

### POST /api/admin/freight-routes/

**Body:**

```json
{
  "origin_country_code": "PK",
  "destination_country_code": "CN",
  "min_freight": "1200.00",
  "currency": "USD"
}
```

Rejects same origin/destination and duplicate pairs with **400**. Codes must be present in `COUNTRY_BY_CODE`.

### GET, PATCH, PUT, DELETE /api/admin/freight-routes/{id}/

Retrieve / update / delete one route. PATCH/PUT accept the same writable fields as create.

### POST /api/admin/freight-routes/bulk-import/

Multipart form: `file` (CSV or `.xlsx`).

CSV columns: `origin_code`, `destination_code`, `min_freight`, `currency` (optional, default USD).

Codes are validated with `normalize_country_code` / `is_valid_country_code`. Upserts on `(origin_country_code, destination_country_code)`. Bad rows are skipped and reported.

**Response `200` — `data`:**

```json
{
  "created": 2,
  "updated": 1,
  "errors": [{"row": 4, "message": "Unknown or invalid country code: ZZ."}]
}
```

Staff UI: `/freight-routes` (DataTables + modals; dropdowns from `COUNTRY_CHOICES`).

---

### GET /api/admin/conversations/

List / filter driver–shipper mirrored conversations for admin monitoring.

**Query parameters:**
- `shipment_id`: Filter by shipment ID or unique code (e.g. `12` or `TM-00123`).
- `trip_id`: Filter by trip ID.
- `shipper_id`: Filter by shipper user ID.
- `transporter_id`: Filter by transporter user ID.
- `status`: Filter by trip status (`PRE_TRIP`, `ASSIGNED`, `IN_TRANSIT`, `DELIVERED`, `COMPLETED`, `CLOSED`, etc.).
- `search`: Case-insensitive text search across shipment, routes, user names/emails, and message content.
- `page`, `page_size`: Pagination parameters (default page 1, page_size 20).

**Response `200` — `data`:**

```json
{
  "total": 1,
  "page": 1,
  "page_size": 20,
  "conversations": [
    {
      "id": 1,
      "shipment": 10,
      "shipment_unique_id": "TM-00010",
      "pickup_address": "Dubai Industrial City, UAE",
      "delivery_address": "Riyadh Logistics Park, KSA",
      "cargo_type": "Steel Pipes",
      "trip": 5,
      "trip_status": "IN_TRANSIT",
      "shipper": 2,
      "shipper_email": "shipper@example.com",
      "shipper_name": "Ali Hassan",
      "transporter": 3,
      "transporter_email": "transporter@example.com",
      "transporter_name": "Swift Logistics",
      "assigned_driver": {
        "id": 12,
        "email": "driver@example.com",
        "name": "Tariq Khan"
      },
      "messages_count": 8,
      "latest_message": {
        "id": 18,
        "sender_id": 12,
        "message_type": "TEXT",
        "text": "Passed the border checkpoint smoothly.",
        "created_at": "2026-08-29T10:30:00Z"
      },
      "created_at": "2026-08-28T14:20:00Z"
    }
  ]
}
```

---

### GET /api/admin/conversations/{id}/messages/

Retrieve complete conversation audit log and message history including text and voice audio URLs for dispute evidence review.

**Response `200` — `data`:**

```json
{
  "id": 1,
  "shipment": 10,
  "shipment_unique_id": "TM-00010",
  "pickup_address": "Dubai Industrial City, UAE",
  "delivery_address": "Riyadh Logistics Park, KSA",
  "cargo_type": "Steel Pipes",
  "trip": 5,
  "trip_status": "IN_TRANSIT",
  "shipper": 2,
  "shipper_email": "shipper@example.com",
  "shipper_name": "Ali Hassan",
  "transporter": 3,
  "transporter_email": "transporter@example.com",
  "transporter_name": "Swift Logistics",
  "assigned_driver": {
    "id": 12,
    "email": "driver@example.com",
    "name": "Tariq Khan"
  },
  "messages_count": 2,
  "latest_message": {
    "id": 2,
    "sender_id": 12,
    "message_type": "VOICE",
    "text": "",
    "created_at": "2026-08-28T14:25:00Z"
  },
  "created_at": "2026-08-28T14:20:00Z",
  "messages": [
    {
      "id": 1,
      "conversation_id": 1,
      "shipment_id": 10,
      "trip_id": 5,
      "sender": 2,
      "sender_email": "shipper@example.com",
      "sender_name": "Ali Hassan",
      "sender_account_type": "INDIVIDUAL",
      "text": "Please confirm arrival at loading dock.",
      "message_type": "TEXT",
      "voice_file": null,
      "voice_url": null,
      "created_at": "2026-08-28T14:21:00Z"
    },
    {
      "id": 2,
      "conversation_id": 1,
      "shipment_id": 10,
      "trip_id": 5,
      "sender": 12,
      "sender_email": "driver@example.com",
      "sender_name": "Tariq Khan",
      "sender_account_type": "TRANSPORTER_DRIVER",
      "text": "",
      "message_type": "VOICE",
      "voice_file": "/media/messages/voice/2026/08/28/voice_01.mp3",
      "voice_url": "http://localhost:8000/media/messages/voice/2026/08/28/voice_01.mp3",
      "created_at": "2026-08-28T14:25:00Z"
    }
  ]
}
```

---

### GET /api/admin/shipments/{id}/rate-requests/

Retrieve audit log of all rate requests distributed for a specific shipment across all batches.

**Auth required:** Yes (`IsPlatformAdmin`)

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Rate requests retrieved successfully.",
  "error": null,
  "data": {
    "shipment_id": 10,
    "unique_id": "SHP-0010",
    "shipment_status": "PUBLISHED",
    "count": 2,
    "rate_requests": [
      {
        "id": 1,
        "shipment_id": 10,
        "driver_id": 8,
        "driver_email": "driver1@example.com",
        "driver_name": "Driver One",
        "batch_number": 1,
        "status": "REJECTED",
        "distance_km": "3.50",
        "offered_price": "2500.00",
        "currency": "AED",
        "rejection_reason": "Busy",
        "created_at": "2026-08-29T10:00:00Z",
        "responded_at": "2026-08-29T10:05:00Z"
      },
      {
        "id": 2,
        "shipment_id": 10,
        "driver_id": 9,
        "driver_email": "driver2@example.com",
        "driver_name": "Driver Two",
        "batch_number": 2,
        "status": "PENDING",
        "distance_km": "6.20",
        "offered_price": "2500.00",
        "currency": "AED",
        "rejection_reason": "",
        "created_at": "2026-08-29T10:05:00Z",
        "responded_at": null
      }
    ]
  }
}
```

---

### POST /api/admin/shipments/{id}/dispatch-next-batch/

Manually trigger the dispatch of the next batch of rate requests for a published shipment.

**Auth required:** Yes (`IsPlatformAdmin`)

**Response `200`:**

```json
{
  "status_code": 200,
  "message": "Dispatched 2 rate requests.",
  "error": null,
  "data": {
    "shipment_id": 10,
    "dispatched_count": 2,
    "rate_requests": [...]
  }
}
```

---

## Complete endpoint index

| Method | Path |
|--------|------|
| POST | `/api/auth/login/` |
| POST | `/api/auth/logout/` |
| PATCH | `/api/auth/fcm-token/` |
| POST | `/api/shipper/register/` |
| GET | `/api/shipper/profile/` |
| PATCH | `/api/shipper/profile/` |
| GET | `/api/shipper/kyc-documents/` |
| POST | `/api/shipper/kyc-documents/` |
| GET | `/api/shipper/shipments/` |
| POST | `/api/shipper/shipments/` |
| GET | `/api/shipper/shipments/{id}/` |
| PATCH | `/api/shipper/shipments/{id}/` |
| DELETE | `/api/shipper/shipments/{id}/` |
| GET | `/api/shipper/shipments/{id}/bids/` |
| GET | `/api/shipper/bids/{id}/` |
| POST | `/api/shipper/bids/{id}/accept/` |
| POST | `/api/shipper/bids/{id}/counter/` |
| GET | `/api/shipper/trips/` |
| GET | `/api/shipper/trips/{id}/` |
| GET | `/api/shipper/trips/{id}/locations/` |
| GET | `/api/shipper/shipments/{id}/nearby-drivers/` |
| GET | `/api/shipper/trips/{id}/pod/` |
| GET | `/api/shipper/wallet/` |
| POST | `/api/shipper/trips/{id}/pay/` |
| GET | `/api/shipper/invoices/` |
| GET | `/api/shipper/invoices/{id}/` |
| GET | `/api/shipper/shipments/history/` |
| POST | `/api/shipper/trips/{id}/review/` |
| GET | `/api/shipper/transporters/{id}/reviews/` |
| GET | `/api/shipper/addresses/` |
| POST | `/api/shipper/addresses/` |
| GET | `/api/shipper/addresses/{id}/` |
| PATCH | `/api/shipper/addresses/{id}/` |
| DELETE | `/api/shipper/addresses/{id}/` |
| GET | `/api/shipper/favorites/transporters/` |
| POST | `/api/shipper/favorites/transporters/` |
| DELETE | `/api/shipper/favorites/transporters/{id}/` |
| POST | `/api/transporter/register/` |
| GET | `/api/transporter/profile/` |
| PATCH | `/api/transporter/profile/` |
| GET | `/api/transporter/documents/` |
| POST | `/api/transporter/documents/` |
| GET | `/api/transporter/vehicles/` |
| POST | `/api/transporter/vehicles/` |
| GET | `/api/transporter/vehicles/{id}/` |
| PATCH | `/api/transporter/vehicles/{id}/` |
| POST | `/api/transporter/vehicles/{id}/assign-driver/` |
| GET | `/api/transporter/vehicles/{id}/documents/` |
| POST | `/api/transporter/vehicles/{id}/documents/` |
| GET | `/api/transporter/drivers/` |
| POST | `/api/transporter/drivers/` |
| GET | `/api/transporter/drivers/{id}/` |
| PATCH | `/api/transporter/drivers/{id}/` |
| DELETE | `/api/transporter/drivers/{id}/` |
| GET | `/api/transporter/drivers/{id}/documents/` |
| POST | `/api/transporter/drivers/{id}/documents/` |
| GET | `/api/transporter/drivers/{id}/current-location/` |
| GET | `/api/transporter/load-discovery/` |
| GET | `/api/transporter/available-shipments/` |
| GET | `/api/transporter/rate-requests/` |
| POST | `/api/transporter/rate-requests/{id}/accept/` |
| POST | `/api/transporter/rate-requests/{id}/reject/` |
| POST | `/api/transporter/shipments/{id}/bid/` |
| GET | `/api/transporter/shipments/{id}/bids/` |
| GET | `/api/transporter/trips/{id}/` |
| GET | `/api/transporter/trips/{id}/navigation/` |
| POST | `/api/transporter/trips/{id}/location/` |
| PATCH | `/api/transporter/trips/{id}/status/` |
| POST | `/api/transporter/trips/{id}/pod/` |
| POST | `/api/transporter/trips/{id}/shipper-review/` |
| GET | `/api/transporter/wallet/` |
| GET | `/api/transporter/ledger/` |
| GET | `/api/transporter/earnings/` |
| GET | `/api/transporter/earnings/by-trip/` |
| GET | `/api/transporter/trips/{id}/payment-summary/` |
| GET | `/api/transporter/withdrawals/` |
| POST | `/api/transporter/withdrawals/` |
| POST | `/api/transporter/trips/{id}/confirm-cash/` |
| POST | `/api/transporter/trips/{id}/confirm-card/` |
| GET | `/api/transporter/ratings/summary/` |
| GET | `/api/transporter/ratings/received/` |
| GET | `/api/transporter/analytics/` |
| GET | `/api/transporter/compliance/summary/` |
| GET | `/api/chats/` |
| POST | `/api/chats/open/` |
| GET | `/api/chats/{id}/messages/` |
| POST | `/api/chats/{id}/messages/` |
| POST | `/api/chats/{id}/accept-bid/` |
| GET | `/api/admin/platform-settings/` |
| PATCH | `/api/admin/platform-settings/` |
| GET | `/api/admin/dashboard/` |
| GET | `/api/admin/users/` |
| GET | `/api/admin/users/{id}/` |
| PATCH | `/api/admin/users/{id}/` |
| GET | `/api/admin/shipments/` |
| GET | `/api/admin/shipments/{id}/` |
| GET | `/api/admin/shipments/{id}/rate-requests/` |
| POST | `/api/admin/shipments/{id}/dispatch-next-batch/` |
| GET | `/api/admin/trips/` |
| GET | `/api/admin/trips/{id}/` |
| GET | `/api/admin/trips/{id}/locations/` |
| GET | `/api/admin/trips/{id}/pod/` |
| POST | `/api/admin/trips/{id}/status-override/` |
| GET | `/api/admin/vehicle-documents/` |
| PATCH | `/api/admin/vehicle-documents/{id}/review/` |
| GET | `/api/admin/vehicles/` |
| PATCH | `/api/admin/vehicles/{id}/verify/` |
| GET | `/api/admin/kyc-documents/` |
| GET | `/api/admin/countries/` |
| GET | `/api/admin/freight-routes/` |
| POST | `/api/admin/freight-routes/` |
| GET | `/api/admin/freight-routes/{id}/` |
| PATCH | `/api/admin/freight-routes/{id}/` |
| PUT | `/api/admin/freight-routes/{id}/` |
| DELETE | `/api/admin/freight-routes/{id}/` |
| POST | `/api/admin/freight-routes/bulk-import/` |
| GET | `/api/admin/conversations/` |
| GET | `/api/admin/conversations/{id}/messages/` |

---

## Common Error Responses

Most errors are returned with HTTP status **X** and body:

```json
{
  "status_code": X,
  "message": "Human-readable summary.",
  "error": null,
  "data": null
}
```

For validation, `error` is often an object of field names to string lists. DRF permission/auth failures are normalized the same way by the API exception handler.

| Code | Meaning |
|---|---|
| `400` | Bad request / validation error |
| `401` | Missing or invalid auth token |
| `403` | Authenticated but wrong role or not verified where required |
| `404` | Resource not found |
| `405` | HTTP method not allowed |
| `500` | Unhandled server error (`error` may contain a technical message in development) |

**401 example (no token):**

```json
{
  "status_code": 401,
  "message": "Authentication credentials were not provided.",
  "error": null,
  "data": null
}
```

**403 example (wrong role):**

```json
{
  "status_code": 403,
  "message": "You do not have permission to perform this action.",
  "error": null,
  "data": null
}
```

---

## Quick Start

```bash
# 1. Register as shipper
curl -X POST http://your-domain.com/api/shipper/register/ \
  -H "Content-Type: application/json" \
  -d '{"email":"shipper@example.com","password":"Pass1234!","account_type":"INDIVIDUAL"}'

# 2. Use the returned token in all subsequent requests
TOKEN="9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"

# 3. Create a shipment
curl -X POST http://your-domain.com/api/shipper/shipments/ \
  -H "Authorization: Token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"pickup_address":"Karachi","delivery_address":"Lahore","cargo_type":"Electronics","weight":"500 kg","vehicle_type_required":"Truck"}'

# 4. Publish the shipment (open for bidding)
curl -X PATCH http://your-domain.com/api/shipper/shipments/1/ \
  -H "Authorization: Token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"PUBLISHED"}'

# 5. Accept a bid (creates trip automatically)
curl -X POST http://your-domain.com/api/shipper/bids/1/accept/ \
  -H "Authorization: Token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'

# 6. Track the trip
curl http://your-domain.com/api/shipper/trips/7/locations/ \
  -H "Authorization: Token $TOKEN"
```

---

## Transporter Quick Start

```bash
# 1. Register as transporter (driver)
curl -X POST http://your-domain.com/api/transporter/register/ \
  -H "Content-Type: application/json" \
  -d '{"email":"driver@example.com","password":"Pass1234!","account_type":"DRIVER","language":"en"}'

# 2. Use the returned token
TOKEN="9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b"

# 3. Get profile (check documents_verified — must be true for discovery / bids / trips)
curl http://your-domain.com/api/transporter/profile/ \
  -H "Authorization: Token $TOKEN"

# 4. Upload driver license (base64 JSON)
curl -X POST http://your-domain.com/api/transporter/documents/ \
  -H "Authorization: Token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"document_type":"DRIVER_LICENSE","file":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==","file_back":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}'

# 5. After admin sets documents_verified and you have a verified active vehicle + Traccar tc_id:
curl "http://your-domain.com/api/transporter/load-discovery/?country_code=AE" \
  -H "Authorization: Token $TOKEN"

# 6. Advance trip status (example: first step after assignment)
curl -X PATCH http://your-domain.com/api/transporter/trips/7/status/ \
  -H "Authorization: Token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"EN_ROUTE","lat":25.2048,"lon":55.2708}'

# 7. Push location during trip
curl -X POST http://your-domain.com/api/transporter/trips/7/location/ \
  -H "Authorization: Token $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"lat":"31.5497","lon":"74.3436"}'

# 8. Submit POD after delivery (multipart/form-data; trip must be IN_TRANSIT or ARRIVED_DELIVERY)
curl -X POST http://your-domain.com/api/transporter/trips/7/pod/ \
  -H "Authorization: Token $TOKEN" \
  -F "receiver_name=Usman Tariq" \
  -F "delivery_lat=31.5497" \
  -F "delivery_lon=74.3436"
```
