# PCS — Port Community System: API Reference

**Version:** 1.0  
**Last updated:** 2026-05-29  
**Port authority:** King Abdulaziz Port, Dammam (UN LOCODE: SADAN)  
**Regulated under:** MAWANI port regulations · ZATCA customs law · Saudi PDPL

---

## Table of Contents

1. [Overview](#1-overview)
2. [Authentication Guide](#2-authentication-guide)
3. [Vessel Calls API](#3-vessel-calls-api)
4. [Cargo Manifest API](#4-cargo-manifest-api)
5. [Container Tracking API](#5-container-tracking-api)
6. [Integration Guide for Shipping Lines](#6-integration-guide-for-shipping-lines)

---

## 1. Overview

### What is PCS?

The **Port Community System** is the single electronic integration point for all parties operating at King Abdulaziz Port, Dammam. It replaces paper-based and email-based coordination with a real-time REST API.

**Who connects to PCS:**

| Stakeholder | What they do in PCS |
|---|---|
| **Shipping Lines** | Submit pre-arrival notifications (vessel calls), upload cargo manifests |
| **Freight Forwarders** | File customs declarations, track container clearance status |
| **Customs Officers** | Review pending manifests, place holds, trigger inspections |
| **Port Authority Staff** | Manage berth assignments, update vessel operational status |
| **Trucking Companies** | Poll container availability, receive pickup notifications |

**Why it exists:** Saudi Customs law requires cargo manifest submission 24–48 hours before vessel arrival. ZATCA's Fasah Single Window requires electronic customs declarations. Without PCS, each party was maintaining bilateral EDI feeds — 12+ point-to-point integrations per shipping line. PCS collapses that into one authenticated API.

---

### Base URLs

| Environment | Base URL | Purpose |
|---|---|---|
| **Production** | `https://api.pcs.portauthority.sa` | Live port operations |
| **Staging** | `https://staging-api.pcs.portauthority.sa` | Integration testing — uses synthetic vessel/cargo data |

All endpoints are HTTPS-only. HTTP requests are redirected to HTTPS by the ALB. The `Strict-Transport-Security` header is present on all production responses.

**All timestamps** are ISO 8601 with UTC timezone (`2026-01-15T08:30:00Z`). The port operates on Arabia Standard Time (UTC+3) — convert for display, but always submit UTC.

---

### Authentication Methods

| Method | Header | Used By |
|---|---|---|
| **JWT Bearer Token** | `Authorization: Bearer <token>` | Human users via web portal; short-lived (60 min) |
| **API Key** | `X-API-Key: pcs_<64 hex chars>` | System-to-system integrations; long-lived (1 year) |

Both methods can be used on any endpoint. API keys identify a stakeholder organisation, not an individual user. See [Section 2](#2-authentication-guide) for the full flow.

---

### Rate Limits

| Endpoint | Limit | Key | Reason |
|---|---|---|---|
| `POST /api/v1/auth/login` | **5 / minute** | Per IP address | Brute-force protection |
| `GET/POST /api/v1/vessels/*` | **100 / minute** | Per API key | System integration throttle |
| `GET /api/v1/containers/{number}` | **60 / minute** | Per IP address | High-frequency public polling |
| `POST /api/v1/customs/submit/*` | **20 / minute** | Per stakeholder | ZATCA submission throttle |
| All other endpoints | **1000 / minute** | Per user | Global safety net |

When a rate limit is exceeded the server responds `429 Too Many Requests` with a `Retry-After` header indicating how many seconds to wait.

```json
{
  "error": "rate_limit_exceeded",
  "message": "Rate limit of 5 requests per 60s exceeded.",
  "retry_after_seconds": 47
}
```

---

### Common Error Codes

All errors follow the same envelope format:

```json
{
  "error": "machine_readable_code",
  "message": "Human-readable explanation. شرح بالعربية عند الاقتضاء",
  "request_id": "a1b2c3d4-..."
}
```

The `request_id` matches the `X-Request-ID` response header. Include it in all support requests.

| HTTP Status | `error` field | Port Operations Meaning |
|---|---|---|
| `400 Bad Request` | `invalid_container_number` | ISO 6346 format violation — vessel call or manifest submission will be rejected |
| `400` | `invalid_transition` | Attempted illegal vessel status change (e.g., BERTHED → EXPECTED) |
| `400` | `manifest_not_accepted` | Tried to submit to ZATCA before PCS has accepted the manifest |
| `401 Unauthorized` | `token_expired` | Access token is older than 60 minutes — call `/auth/refresh` |
| `401` | `token_invalid` | Token signature does not match — likely a wrong environment or tampered token |
| `401` | `invalid_credentials` | Wrong username or password on login |
| `403 Forbidden` | `forbidden` | Authenticated but wrong role for this endpoint |
| `404 Not Found` | `vessel_call_not_found` | No record with this UUID — check you are in the right environment |
| `422 Unprocessable Entity` | `validation_error` | Request JSON failed Pydantic validation — see `details` array for field-level errors |
| `423 Locked` | `account_locked` | 5 consecutive failed logins — locked for 15 minutes |
| `429 Too Many Requests` | `rate_limit_exceeded` | See rate limits table above |
| `502 Bad Gateway` | `zatca_unavailable` | ZATCA Fasah is unreachable — manifest queued for next batch run |
| `503 Service Unavailable` | `service_unavailable` | PCS is starting up or a backing service (DB/Kafka/Redis) is unhealthy |

---

## 2. Authentication Guide

### 2.1 User Roles

| Role | Who | Permissions |
|---|---|---|
| `ADMIN` | Port IT, PCS administrators | Full access; can cancel vessel calls, manage users |
| `OPERATOR` | Port operations staff | Update vessel status, record container transitions |
| `AGENT` | Shipping line / freight forwarder staff | Submit vessel calls and manifests for their own stakeholder only |
| `CUSTOMS_OFFICER` | ZATCA-seconded customs staff | View and manage pending customs clearances |
| `VIEWER` | Read-only partners (surveyors, P&I clubs) | Read access only; no write operations |

---

### 2.2 JWT Token Flow (Web Portal / Interactive Clients)

```
Client                                    PCS API
  │                                          │
  │  POST /api/v1/auth/login                 │
  │  { username, password }                  │
  │─────────────────────────────────────────►│
  │                                          │ Verify credentials
  │  200 OK                                  │ Issue tokens
  │  { access_token, refresh_token, ... }    │
  │◄─────────────────────────────────────────│
  │                                          │
  │  GET /api/v1/vessels/upcoming            │
  │  Authorization: Bearer <access_token>    │
  │─────────────────────────────────────────►│
  │  200 OK  { items: [...] }                │
  │◄─────────────────────────────────────────│
  │                                          │
  │  [60 minutes later — token expired]      │
  │                                          │
  │  POST /api/v1/auth/refresh               │
  │  { refresh_token }                       │
  │─────────────────────────────────────────►│
  │  200 OK  { access_token }                │
  │◄─────────────────────────────────────────│
  │                                          │
  │  POST /api/v1/auth/logout                │
  │  Authorization: Bearer <access_token>    │
  │─────────────────────────────────────────►│
  │  204 No Content                          │
  │◄─────────────────────────────────────────│
```

- **Access token TTL:** 60 minutes
- **Refresh token TTL:** 7 days
- **Logout** blacklists the access token in Redis — the JTI is stored until the token would have naturally expired, then deleted automatically. The refresh token is invalidated separately if needed.

---

### 2.3 API Key Flow (System Integration)

API keys are issued to a **stakeholder organisation**, not an individual user. They identify Orient Star Shipping or Desert Logistics, not a specific employee. Use API keys for:

- Automated manifest submission from your TMS/ERP
- AIS position polling scripts
- Customs status monitoring services

**Getting an API key:**

1. Log in to the PCS web portal with an `ADMIN` or `OPERATOR` account for your stakeholder.
2. Navigate to **Settings → API Access → Generate New Key**.
3. The key is shown **once** — copy it to AWS Secrets Manager immediately.
4. The key begins with `pcs_` followed by 64 hexadecimal characters.

**Using an API key:**

```
GET /api/v1/vessels/upcoming
X-API-Key: pcs_a3f8b2c1d4e5f6...
```

No JWT login is needed. The server looks up the SHA-256 hash of the key in Redis and resolves the stakeholder automatically.

---

### 2.4 Code Examples

#### Login and get a token (curl)

```bash
curl -s -X POST https://api.pcs.portauthority.sa/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "orient_agent_1",
    "password": "Str0ng!Pass#2024"
  }'
```

**Response:**

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 3600,
  "user": {
    "id": "550e8400-e29b-41d4-a716-446655440001",
    "username": "orient_agent_1",
    "email": "agent1@orient-star.sa",
    "role": "AGENT",
    "stakeholder_id": "11000000-0000-0000-0000-000000000001"
  }
}
```

#### Full authenticated session (Python / httpx)

```python
import httpx
import os

BASE_URL = "https://api.pcs.portauthority.sa"
API_KEY  = os.environ["PCS_API_KEY"]  # pcs_a3f8b2c1...

class PCSClient:
    def __init__(self):
        self._client = httpx.AsyncClient(
            base_url=BASE_URL,
            headers={"X-API-Key": API_KEY},
            timeout=30.0,
        )

    async def get_upcoming_vessels(self) -> list[dict]:
        r = await self._client.get("/api/v1/vessels/upcoming")
        r.raise_for_status()
        return r.json()

    async def submit_vessel_call(self, payload: dict) -> dict:
        r = await self._client.post("/api/v1/vessels/", json=payload)
        r.raise_for_status()
        return r.json()

    async def aclose(self):
        await self._client.aclose()


# --- JWT-based session (interactive users) ---

async def login(username: str, password: str) -> tuple[str, str]:
    async with httpx.AsyncClient(base_url=BASE_URL) as client:
        r = await client.post(
            "/api/v1/auth/login",
            json={"username": username, "password": password},
        )
        r.raise_for_status()
        data = r.json()
        return data["access_token"], data["refresh_token"]


async def refresh_token(refresh_token: str) -> str:
    async with httpx.AsyncClient(base_url=BASE_URL) as client:
        r = await client.post(
            "/api/v1/auth/refresh",
            json={"refresh_token": refresh_token},
        )
        r.raise_for_status()
        return r.json()["access_token"]
```

#### Authenticated request (JavaScript / fetch)

```javascript
const BASE_URL = "https://api.pcs.portauthority.sa";

async function pcsRequest(path, options = {}) {
  const apiKey = process.env.PCS_API_KEY;
  const response = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      "X-API-Key": apiKey,
      ...options.headers,
    },
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`PCS API error ${response.status}: ${error.message} (${error.error})`);
  }
  return response.status === 204 ? null : response.json();
}

// List upcoming arrivals
const upcoming = await pcsRequest("/api/v1/vessels/upcoming");

// Submit a vessel call
const vesselCall = await pcsRequest("/api/v1/vessels/", {
  method: "POST",
  body: JSON.stringify({ imo_number: "9876543", /* ... */ }),
});
```

---

## 3. Vessel Calls API

A **vessel call** represents a single port visit by a named vessel: from the pre-arrival notification through to the vessel's departure. One physical ship (one IMO number) will have many vessel call records over its lifetime.

### 3.1 Vessel Status Lifecycle

```
                         ┌──────────┐
                         │ EXPECTED │   ← Initial state when agent registers call
                         └────┬─────┘     (ETA notification; 72 h minimum lead time)
                              │
                     Vessel arrives in
                     Saudi territorial waters
                              │
                              ▼
                     ┌───────────────┐
                     │ AT_ANCHORAGE  │   ← Awaiting berth assignment / tug availability
                     └──────┬────────┘     VTS records ATA (Actual Time of Arrival)
                            │
                    Berth allocated;
                    lines made fast
                            │
                            ▼
                      ┌─────────┐
                      │ BERTHED │   ← Cargo operations in progress (loading / discharge)
                      └────┬────┘     Agent updates draft if changed during ops
                           │
                  All cargo ops complete;
                  customs clearance done;
                  departure clearance issued
                           │
                           ▼
                     ┌──────────┐
                     │ DEPARTED │   ← Vessel call closed; ATD recorded by VTS
                     └──────────┘

    CANCELLED can be reached from EXPECTED or AT_ANCHORAGE:

    EXPECTED ──────────────────────────────────► CANCELLED
    AT_ANCHORAGE ──────────────────────────────► CANCELLED

    Note: DEPARTED calls cannot be cancelled.
    Note: Status transitions can only be made by OPERATOR or ADMIN role users.
```

### 3.2 Field Reference

| Field | Type | Required | Description |
|---|---|---|---|
| `imo_number` | string (7 digits) | ✅ | IMO vessel identifier — permanent, assigned by Lloyd's Register. Validated by PCS and the database. |
| `vessel_name` | string (≤ 100) | ✅ | Name normalised to uppercase. Displayed on berth schedule and pilot orders. |
| `vessel_type` | enum | ✅ | `CONTAINER`, `BULK`, `TANKER`, `RORO`, `GENERAL_CARGO`, `PASSENGER` |
| `flag_state` | string (2 chars) | ✅ | ISO 3166-1 alpha-2 flag state. Used for port state control inspection scheduling. |
| `loa` | decimal (metres) | ✅ | Length Overall. Berth planner checks this against berth length. |
| `beam` | decimal (metres) | ✅ | Breadth moulded. Must be less than LOA (validated). |
| `draft_max` | decimal (metres) | ✅ | Structural maximum draft from classification certificate. |
| `draft_arrival` | decimal (metres) | ✅ | Actual laden draft at time of arrival. Must not exceed `draft_max`. Port control will verify against channel and berth depth limits. |
| `gross_tonnage` | integer | ✅ | GT from Suez/Panama Canal certificate. Used for port dues calculation. |
| `net_tonnage` | integer | ✅ | NT. Also used in dues calculation. |
| `shipping_agent_id` | UUID | ✅ | The registered PCS stakeholder acting as agent for this call. AGENT-role users may only specify their own `stakeholder_id`. |
| `eta` | datetime (UTC) | ✅ | Must be ≥ 24 hours from submission time. Notify VTS immediately for deviations > 2 hours. |
| `etd` | datetime (UTC) | ✅ | Must be later than `eta`. |
| `voyage_number` | string (≤ 20) | ✅ | Carrier-assigned voyage identifier. Combined with IMO it identifies a specific sailing. |
| `last_port` | string | ✅ | Previous port of call — UN LOCODE preferred (e.g., `AEDXB`). |
| `next_port` | string | ✅ | Next port after departure — UN LOCODE preferred. |
| `purpose` | enum | ✅ | `IMPORT`, `EXPORT`, `TRANSIT`, `BUNKERING`, `REPAIR`. Determines ZATCA declaration type. |

**Response-only fields** (set by PCS, not writable by the agent):

| Field | Type | Set when |
|---|---|---|
| `id` | UUID | On creation |
| `ata` | datetime | Port VTS records actual arrival (status → `AT_ANCHORAGE`) |
| `atd` | datetime | Port VTS records actual departure (status → `DEPARTED`) |
| `berth_assigned` | string | Port planner assigns a berth code |
| `status` | enum | Changes via status transition endpoint |
| `created_at` | datetime | On creation |
| `updated_at` | datetime | On any field change |

---

### 3.3 POST /api/v1/vessels/ — Register Pre-Arrival Notification

**Role required:** `AGENT`, `OPERATOR`, or `ADMIN`

**Rate limit:** 100 / minute per API key

```bash
curl -X POST https://api.pcs.portauthority.sa/api/v1/vessels/ \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "imo_number": "9876543",
    "vessel_name": "AL-KHAFJI",
    "vessel_type": "CONTAINER",
    "flag_state": "SA",
    "loa": "294.10",
    "beam": "32.20",
    "draft_max": "13.60",
    "draft_arrival": "12.40",
    "gross_tonnage": 52000,
    "net_tonnage": 30000,
    "shipping_agent_id": "11000000-0000-0000-0000-000000000001",
    "eta": "2026-06-15T06:00:00Z",
    "etd": "2026-06-17T18:00:00Z",
    "voyage_number": "AKH-2026-W24A",
    "last_port": "CNSHA",
    "next_port": "AEDXB",
    "purpose": "IMPORT"
  }'
```

**201 Created — success:**

```json
{
  "id": "22000000-0000-0000-0000-000000000099",
  "imo_number": "9876543",
  "vessel_name": "AL-KHAFJI",
  "vessel_type": "CONTAINER",
  "flag_state": "SA",
  "loa": "294.10",
  "beam": "32.20",
  "draft_max": "13.60",
  "draft_arrival": "12.40",
  "gross_tonnage": 52000,
  "net_tonnage": 30000,
  "shipping_agent_id": "11000000-0000-0000-0000-000000000001",
  "eta": "2026-06-15T06:00:00Z",
  "ata": null,
  "etd": "2026-06-17T18:00:00Z",
  "atd": null,
  "berth_assigned": null,
  "status": "EXPECTED",
  "voyage_number": "AKH-2026-W24A",
  "last_port": "CNSHA",
  "next_port": "AEDXB",
  "purpose": "IMPORT",
  "created_at": "2026-06-12T09:14:32Z",
  "updated_at": "2026-06-12T09:14:32Z"
}
```

A `VESSEL_CALL_CREATED` event is published to the `vessel.arrivals` Kafka topic immediately. The notification service sends an SMS/email to the registered port agent contact.

**409 Conflict** — duplicate active call for this IMO + voyage:

```json
{
  "error": "duplicate_vessel_call",
  "message": "An active vessel call already exists for IMO 9876543 voyage AKH-2026-W24A.",
  "request_id": "c8a2e4f1-..."
}
```

---

### 3.4 GET /api/v1/vessels/upcoming — Next 48-Hour Arrival Schedule

**Role required:** Any authenticated role  
**Caching:** Redis, 60-second TTL

Returns vessels in `EXPECTED` or `AT_ANCHORAGE` status with ETA in the next 48 hours, sorted by ETA ascending. This is the primary data source for the berth planning dashboard.

```bash
curl https://api.pcs.portauthority.sa/api/v1/vessels/upcoming \
  -H "X-API-Key: pcs_a3f8b2c1..."
```

**Response:** Array of `VesselCallResponse` objects (same schema as POST response above).

---

### 3.5 GET /api/v1/vessels/ — Paginated Vessel Call List

**Role required:** Any authenticated role

```
GET /api/v1/vessels/?status=EXPECTED&page=1&size=20
GET /api/v1/vessels/?date_from=2026-06-01T00:00:00Z&date_to=2026-06-30T23:59:59Z
```

**Query parameters:**

| Parameter | Type | Description |
|---|---|---|
| `status` | enum | Filter by: `EXPECTED`, `AT_ANCHORAGE`, `BERTHED`, `DEPARTED`, `CANCELLED` |
| `date_from` | datetime (UTC) | Include calls with ETA on or after this time |
| `date_to` | datetime (UTC) | Include calls with ETA on or before this time |
| `page` | integer (≥ 1) | Page number; default 1 |
| `size` | integer (1–100) | Results per page; default 20 |

**Response:**

```json
{
  "items": [ /* array of VesselCallResponse */ ],
  "total": 47,
  "page": 1,
  "size": 20,
  "pages": 3
}
```

---

### 3.6 GET /api/v1/vessels/{vessel_call_id}

**Role required:** Any authenticated role  
**Caching:** Redis, 300-second TTL. Cache is invalidated on any status change or field update.

```bash
curl https://api.pcs.portauthority.sa/api/v1/vessels/22000000-0000-0000-0000-000000000099 \
  -H "X-API-Key: pcs_a3f8b2c1..."
```

---

### 3.7 PUT /api/v1/vessels/{vessel_call_id} — Update Vessel Details

**Role required:** `AGENT` (own stakeholder only), `OPERATOR`, `ADMIN`

Only the fields you provide are updated. A vessel call in `DEPARTED` or `CANCELLED` status cannot be updated.

```bash
curl -X PUT \
  https://api.pcs.portauthority.sa/api/v1/vessels/22000000-0000-0000-0000-000000000099 \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "eta": "2026-06-15T08:30:00Z",
    "draft_arrival": "12.80"
  }'
```

**ETA change best practices:** Saudi port regulations require shipping agents to notify VTS by VHF Channel 16 for ETA deviations greater than 2 hours, in addition to updating PCS.

---

### 3.8 PATCH /api/v1/vessels/{vessel_call_id}/status — Status Transition

**Role required:** `OPERATOR`, `ADMIN` only

Agents cannot update vessel status — only port staff can record physical events.

```bash
curl -X PATCH \
  https://api.pcs.portauthority.sa/api/v1/vessels/22000000-0000-0000-0000-000000000099/status \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "AT_ANCHORAGE",
    "timestamp": "2026-06-15T08:22:00Z",
    "changed_by": "port_operator_1",
    "notes": "Vessel arrived outer anchorage. Pilot boat dispatched. Est. berth time 11:00."
  }'
```

**Valid transitions:**

```
EXPECTED      → AT_ANCHORAGE, CANCELLED
AT_ANCHORAGE  → BERTHED, CANCELLED
BERTHED       → DEPARTED, CANCELLED
DEPARTED      → (no further transitions)
CANCELLED     → (no further transitions)
```

**422 on invalid transition:**

```json
{
  "error": "invalid_transition",
  "message": "Cannot transition from BERTHED to EXPECTED. Valid next statuses: DEPARTED, CANCELLED",
  "details": {
    "current_status": "BERTHED",
    "requested_status": "EXPECTED",
    "valid_transitions": ["DEPARTED", "CANCELLED"]
  }
}
```

---

### 3.9 DELETE /api/v1/vessels/{vessel_call_id} — Cancel Vessel Call

**Role required:** `ADMIN` only

This is a **soft delete** — the record is retained for audit and port dues reporting. A `VESSEL_CALL_CANCELLED` Kafka event is published. `DEPARTED` calls cannot be cancelled.

---

### 3.10 Common Mistakes

| Mistake | Error Received | Fix |
|---|---|---|
| IMO number with letters or punctuation (`IMO9876543`, `9876-543`) | `422 validation_error` | Send only the 7 digits: `"9876543"` |
| `draft_arrival` exceeds `draft_max` | `422 validation_error` | Verify load condition; `draft_arrival` must be ≤ `draft_max` |
| `etd` before `eta` | `422 validation_error` | `etd` must be strictly after `eta` |
| `eta` less than 24 hours from submission | `422 validation_error` | Submit at least 24 h in advance; MAWANI requires 72 h for vessels over 10 000 GT |
| Shipping agent UUID from production used in staging | `404 not_found` | Stakeholders are registered separately in each environment |
| Calling status transition as an AGENT | `403 forbidden` | Only OPERATOR and ADMIN can change vessel status |
| Sending `beam` equal to or greater than `loa` | `422 validation_error` | Beam must be strictly less than LOA |

---

## 4. Cargo Manifest API

A **cargo manifest** is the formal declaration of all cargo on a vessel call, required by Saudi Customs law 24–48 hours before vessel arrival. After PCS accepts the manifest, it is forwarded to ZATCA Fasah for customs assessment.

### 4.1 Two-Track Status System

Cargo manifests have **two independent statuses**:

```
SUBMISSION STATUS (PCS internal processing)
-------------------------------------------
  DRAFT
    │  Agent calls submit endpoint
    ▼
  SUBMITTED
    │  PCS validates format, HS codes, dangerous goods fields
    ▼
  ACCEPTED  ────► Can now be forwarded to ZATCA
    or
  REJECTED  ────► Agent must correct errors and resubmit

CUSTOMS STATUS (ZATCA Fasah outcome)
-------------------------------------
  PENDING     ← After ACCEPTED manifest is sent to ZATCA
    │
    ├──► CLEARED    ← All duties assessed and paid; containers released
    ├──► HELD       ← ZATCA placed a hold (see ZATCA reference for reason)
    └──► INSPECTION ← Physical inspection ordered
```

These statuses evolve independently. A manifest can be `ACCEPTED` (PCS validated it) and `PENDING` (ZATCA is still reviewing it) at the same time.

---

### 4.2 EDIFACT CUSCAR to PCS JSON Mapping

Shipping lines that generate CUSCAR messages for other ports can map fields to PCS JSON as follows:

| EDIFACT CUSCAR Segment | Segment Code | PCS JSON Field | Notes |
|---|---|---|---|
| Message identification | `BGM+85` | — | PCS assigns `manifest_number` automatically |
| Submission date | `DTM+137` | `submitted_at` | ISO 8601 UTC in PCS |
| Vessel name | `TDT+20::::<vessel_name>` | `vessel_name` | Normalised to uppercase |
| Voyage number | `TDT+20+<voyage>` | `voyage_number` | Carrier voyage identifier |
| IMO number | `TDT+20++++IMO:<imo>` | `imo_number` | 7 digits only |
| Port of loading | `LOC+9+<LOCODE>` | `last_port` | UN LOCODE format |
| Port of discharge | `LOC+11+<LOCODE>` | set on vessel call | King Abdulaziz = `SADAN` |
| ETA | `DTM+132+<YYYYMMDDHHNN>:203` | `eta` on vessel call | Convert to UTC ISO 8601 |
| Consignee name | `NAD+CN++<name>` | `consignee_name` | Per cargo item |
| Consignee address | `NAD+CN++<name>+<addr>` | — | Not stored separately |
| Shipper name | `NAD+CZ++<name>` | `shipper_name` | Per cargo item |
| Container number | `EQD+CN+<number>+6` | `container_number` | ISO 6346; validated |
| Container size/type | `EQD+CN+<number>+6++<iso_type>` | `container_size` | Map ISO type to PCS enum (see below) |
| Bill of Lading | `RFF+BM:<bl_number>` | `bill_of_lading_number` | Per cargo item |
| HS code | `GDS+<hs_code>` | `hs_code` | 6–10 digits; no dots |
| Gross weight (kg) | `MEA+WT+AAI+KGM:<weight>` | `weight_kg` | Decimal, ≥ 0 |
| Volume (CBM) | `MEA+VOL+AAW+MTQ:<volume>` | `volume_cbm` | Decimal, ≥ 0 |
| Commodity description | `FTX+AAA++<description>` | `commodity_description` | Plain text, ≤ 500 chars |
| Dangerous goods class | `DGS+IMD+<class>+<un_number>` | `imdg_class`, `un_number` | Required if `cargo_type=DANGEROUS` |
| Seal number | `SEL+<seal>+CA` | `seal_number` | Per container |

**Container size ISO type → PCS enum:**

| ISO Type Code | PCS `container_size` |
|---|---|
| `22G1`, `22G0` | `20GP` |
| `42G1`, `42G0` | `40GP` |
| `45G1`, `45G0` | `40HC` |
| `L5G1`, `L5G0` | `45HC` |
| `22U6`, `22U1` | `20OT` |
| `42U6`, `42U1` | `40OT` |
| `22P1`, `22P3` | `20FR` |
| `42P1`, `42P3` | `40FR` |

---

### 4.3 POST /api/v1/cargo/manifests — Submit a Cargo Manifest

**Role required:** `AGENT`, `OPERATOR`, `ADMIN`

A manifest submission includes the manifest-level record and all cargo items in one request. PCS validates the entire structure before accepting.

```bash
curl -X POST https://api.pcs.portauthority.sa/api/v1/cargo/manifests \
  -H "X-API-Key: pcs_a3f8b2c1..." \
  -H "Content-Type: application/json" \
  -d '{
    "vessel_call_id": "22000000-0000-0000-0000-000000000099",
    "total_containers": 3,
    "total_weight_mt": "65.500",
    "submission_method": "API",
    "submitted_by": "11000000-0000-0000-0000-000000000001",
    "items": [
      {
        "container_number": "TCKU3953430",
        "container_size": "40HC",
        "cargo_type": "GENERAL",
        "commodity_description": "Consumer electronics — 4K smart televisions, model XR-65",
        "hs_code": "8528720000",
        "weight_kg": "22500.000",
        "volume_cbm": "67.200",
        "shipper_name": "Hisense International Electronics Co. Ltd.",
        "consignee_name": "Al-Jazirah Electronics Trading Co., Dammam",
        "bill_of_lading_number": "OOLU-DMM-2026-0881",
        "seal_number": "CN-7712344"
      },
      {
        "container_number": "MSCU0123456",
        "container_size": "20GP",
        "cargo_type": "DANGEROUS",
        "commodity_description": "Lithium-ion battery cells — Class 9 miscellaneous dangerous goods",
        "hs_code": "8507600000",
        "weight_kg": "18000.000",
        "volume_cbm": "28.000",
        "shipper_name": "CATL Energy Storage Co. Ltd., Ningde",
        "consignee_name": "Saudi Electric Vehicle Co., Riyadh",
        "bill_of_lading_number": "MSCU-DMM-2026-0445",
        "seal_number": "CN-4489901",
        "imdg_class": "9",
        "un_number": "3480"
      },
      {
        "container_number": "TGHU9876540",
        "container_size": "40HC",
        "cargo_type": "REEFER",
        "commodity_description": "Frozen halal beef — IQF cuts, Brazilian origin, halal certified",
        "hs_code": "0201300000",
        "weight_kg": "25000.000",
        "volume_cbm": "67.000",
        "shipper_name": "JBS Friboi International, São Paulo",
        "consignee_name": "Al-Marai Food Distribution Co., Jeddah",
        "bill_of_lading_number": "HLXU-DMM-2026-0033",
        "seal_number": "BR-5523891",
        "reefer_temp_celsius": "-18.00"
      }
    ]
  }'
```

**201 Created — success:**

```json
{
  "id": "33000000-0000-0000-0000-000000000099",
  "vessel_call_id": "22000000-0000-0000-0000-000000000099",
  "manifest_number": "PCS-MAN-2026-0099",
  "total_containers": 3,
  "total_weight_mt": "65.500",
  "submission_method": "API",
  "submission_status": "SUBMITTED",
  "customs_status": "PENDING",
  "submitted_by": "11000000-0000-0000-0000-000000000001",
  "submitted_at": "2026-06-12T09:30:00Z",
  "items": [
    {
      "id": "44000000-0000-0000-0000-000000000001",
      "container_number": "TCKU3953430",
      "container_size": "40HC",
      "cargo_type": "GENERAL",
      "commodity_description": "Consumer electronics — 4K smart televisions, model XR-65",
      "hs_code": "8528720000",
      "weight_kg": "22500.000",
      "volume_cbm": "67.200",
      "shipper_name": "Hisense International Electronics Co. Ltd.",
      "consignee_name": "Al-Jazirah Electronics Trading Co., Dammam",
      "bill_of_lading_number": "OOLU-DMM-2026-0881",
      "seal_number": "CN-7712344",
      "imdg_class": null,
      "un_number": null,
      "reefer_temp_celsius": null,
      "customs_status": "PENDING"
    }
  ]
}
```

---

### 4.4 HS Code Validation Rules

| Rule | Detail |
|---|---|
| **Length** | 6 to 10 digits. Saudi ZATCA uses 10-digit national HS codes; 6-digit WCO base codes are accepted but ZATCA may request the 10-digit code during review. |
| **Format** | Digits only — no dots, spaces, or letters. PCS stores `8528720000`; ZATCA formats it as `8528.72.00` internally. |
| **Prohibited** | HS codes in Saudi Customs prohibited list (Chapter 93 weapons, certain Chapter 30 pharmaceuticals) trigger immediate review. PCS does not block submission but ZATCA will place a HELD status. |
| **Reefer food** | Halal-certified products should use the correct HS heading. For frozen chicken: `0207.14.00`. For frozen beef: `0201.30.00`. Wrong HS codes are the most common cause of customs delays at Saudi ports. |

---

### 4.5 Dangerous Goods Requirements

When `cargo_type` is `DANGEROUS`, two additional fields become **mandatory**:

| Field | Format | Example | Notes |
|---|---|---|---|
| `imdg_class` | String, e.g. `"3"`, `"6.1"`, `"8"` | `"9"` | IMDG hazard class. Sub-class included if applicable (e.g., `"6.1"` not just `"6"`). |
| `un_number` | 4 digits, no `UN` prefix | `"3480"` | UN Number from the IMDG Code. `3480` = Lithium batteries. |

```json
{
  "cargo_type": "DANGEROUS",
  "imdg_class": "3",
  "un_number": "1203"
}
```

**Common IMDG classes seen at King Abdulaziz Port:**

| Class | Description | Common commodity |
|---|---|---|
| `1` | Explosives | Fireworks, detonators |
| `2.1` | Flammable gas | LPG cylinders |
| `3` | Flammable liquid | Solvents, paints, fuel |
| `4.1` | Flammable solid | Matches, metallic powders |
| `5.1` | Oxidising substance | Ammonium nitrate fertiliser |
| `6.1` | Toxic substances | Pesticides, industrial chemicals |
| `8` | Corrosives | Batteries (wet), acids |
| `9` | Miscellaneous | Lithium batteries, dry ice, magnetised material |

A **DG Certificate** (multimodal dangerous goods form per IMDG Code Chapter 5.4) must be presented to the terminal operator separately. PCS records the classification; the physical document is checked at gate-in.

---

### 4.6 Reefer Container Requirements

When `cargo_type` is `REEFER`, the field `reefer_temp_celsius` is **mandatory**:

| Field | Type | Range | Example |
|---|---|---|---|
| `reefer_temp_celsius` | decimal | `-30.00` to `+15.00` | `"-18.00"` |

**Common reefer temperature setpoints:**

| Commodity | Temperature | Notes |
|---|---|---|
| Frozen fish, beef, chicken | `-18.00`°C | Standard halal frozen requirement |
| Ice cream | `-25.00`°C | Deep-freeze |
| Fresh fruit (bananas) | `+13.50`°C | Chilled, not frozen |
| Fresh vegetables | `+4.00`°C | |
| Pharmaceuticals (vaccines) | `+2.00` to `+8.00`°C | Cold chain critical; verify with consignee |
| Chilled meat | `-1.50`°C | Near-freezing without freezing |

Reefer containers must be pre-cooled to setpoint before vessel loading. Terminal staff verify the setpoint at gate-in and during stow. If the cargo arrives with the wrong setpoint, terminal will not accept the container until the agent amends the manifest.

---

### 4.7 GET /api/v1/cargo/manifests/{manifest_id}

**Role required:** Any authenticated role

Returns the full manifest including all cargo items and their current customs status.

---

## 5. Container Tracking API

### 5.1 ISO 6346 Container Number Format

Every container number must conform to ISO 6346. The format is exactly 11 characters:

```
  T  C  K  U  3  9  5  3  4  3  0
  ─  ─  ─  ─  ─  ─  ─  ─  ─  ─  ─
  │  │  │  │  └──────────────┘  │
  └──┘  └──┘         │          │
  Owner  Cat       6 serial    Check
  code  letter     digits     digit
  (3)    (1)        (6)        (1)

Owner code:  3 uppercase letters (TCKU = Touax Container Leasing)
Category:    U = universal/freight container
             J = detachable freight equipment
             Z = trailer and chassis
Serial:      6 numeric digits (000000–999999)
Check digit: 1 digit (0–9); computed by the ISO 6346 check digit algorithm
```

**Validation:** PCS validates ISO 6346 format at two levels:
1. The input sanitizer middleware rejects requests with container numbers that don't match `[A-Z]{4}[0-9]{7}` before they reach any route handler.
2. The route handler validates the full check digit via the ISO 6346 algorithm.

A request with `container_number=TCKU395343X` (a letter where a digit is required) gets a `400` from the middleware before any business logic runs. A number with correct format but wrong check digit gets a `400` from the route handler.

---

### 5.2 Container Status Lifecycle

```
MANIFESTED          Container declared in cargo manifest; not yet discharged
    │
    │ Vessel berths; discharge operations begin
    ▼
DISCHARGED          Lifted off vessel; placed in terminal yard
    │
    │ Moved to assigned yard block
    ▼
YARD_IN             Positioned in storage block (e.g., "Block A, Row 5, Tier 2")
    │
    │ ZATCA customs clearance granted
    ▼
CUSTOMS_CLEARED     ZATCA issued clearance; duty assessed
    │
    │ Consignee or freight forwarder pays duties and obtains release order
    ▼
AVAILABLE_FOR_PICKUP  Container ready; release order issued to terminal
    │
    │ Truck arrives; gate-out performed
    ▼
GATE_OUT            Container loaded on truck; departed terminal
    │
    │ (Optional — confirmed by freight forwarder)
    ▼
DELIVERED           Cargo received by consignee at their premises
```

Status transitions flow strictly in this order. The API rejects any out-of-sequence transition with a `400 invalid_transition` error.

---

### 5.3 GET /api/v1/containers/{container_number} — Track a Container

**Role required:** Any authenticated role  
**Rate limit:** 60 / minute per IP  
**Caching:** Redis, 120-second TTL

```bash
curl https://api.pcs.portauthority.sa/api/v1/containers/TCKU3953430 \
  -H "X-API-Key: pcs_a3f8b2c1..."
```

**200 OK — container found:**

```json
{
  "container_number": "TCKU3953430",
  "current_status": "CUSTOMS_CLEARED",
  "customs_status": "CLEARED",
  "location": "Block C, Row 14, Tier 2",
  "consignee_name": "Al-Jazirah Electronics Trading Co., Dammam",
  "bill_of_lading_number": "OOLU-DMM-2026-0881",
  "manifest_number": "PCS-MAN-2026-0099",
  "vessel_name": "AL-KHAFJI",
  "voyage_number": "AKH-2026-W24A",
  "events": []
}
```

**400 Bad Request — invalid format:**

```json
{
  "error": "invalid_container_number",
  "message": "TCKU395343X is not a valid ISO 6346 container number. Format: 3 owner letters + U/J/Z + 6 serial digits + 1 check digit.",
  "request_id": "f1e2d3c4-..."
}
```

---

### 5.4 GET /api/v1/containers/{container_number}/history — Full Event History

**Role required:** Any authenticated role  
**Caching:** None — always reads from database

Returns the `ContainerStatusView` with the `events` array populated. Each event records who made the status change, when, and at which physical location in the terminal.

```json
{
  "container_number": "TCKU3953430",
  "current_status": "CUSTOMS_CLEARED",
  "events": [
    {
      "status": "MANIFESTED",
      "location": null,
      "notes": "Declared in manifest PCS-MAN-2026-0099",
      "event_time": "2026-06-12T09:30:00Z",
      "recorded_by": "system"
    },
    {
      "status": "DISCHARGED",
      "location": "Quay Crane 7, Berth N-12",
      "notes": "Discharged at 14:22; no damage noted",
      "event_time": "2026-06-15T14:22:00Z",
      "recorded_by": "port_operator_1"
    },
    {
      "status": "YARD_IN",
      "location": "Block C, Row 14, Tier 2",
      "notes": null,
      "event_time": "2026-06-15T16:08:00Z",
      "recorded_by": "port_operator_1"
    },
    {
      "status": "CUSTOMS_CLEARED",
      "location": "Block C, Row 14, Tier 2",
      "notes": "ZATCA clearance received via webhook. ZATCA reference: SADAN-PCS-MAN-2026-0099",
      "event_time": "2026-06-16T11:34:00Z",
      "recorded_by": "system"
    }
  ]
}
```

---

### 5.5 GET /api/v1/containers/available — Containers Ready for Pickup

**Role required:** Any authenticated role

Returns paginated list of containers currently in `AVAILABLE_FOR_PICKUP` status, i.e., cleared, duties paid, release order issued. Trucking companies poll this endpoint to identify which containers their vehicles should collect.

```bash
curl "https://api.pcs.portauthority.sa/api/v1/containers/available?page=1&size=50" \
  -H "X-API-Key: pcs_a3f8b2c1..."
```

---

### 5.6 GET /api/v1/containers/search — Search by B/L, Manifest, or Vessel

At least one query parameter must be provided:

| Parameter | Description |
|---|---|
| `bill_of_lading` | Partial or full B/L number (case-insensitive substring match) |
| `manifest_number` | Exact manifest number (e.g., `PCS-MAN-2026-0099`) |
| `vessel_name` | Partial vessel name (case-insensitive) |

```bash
curl "https://api.pcs.portauthority.sa/api/v1/containers/search?bill_of_lading=OOLU-DMM-2026" \
  -H "X-API-Key: pcs_a3f8b2c1..."
```

---

### 5.7 Webhook Setup for Real-Time Container Status Updates

Instead of polling `/api/v1/containers/{number}`, you can receive push notifications when a container's status changes. Configure a webhook endpoint on your system:

**1. Register your webhook URL** with the PCS integration team:

```
POST /api/v1/webhooks/register   (requires ADMIN role)
{
  "url": "https://your-tms.company.sa/pcs-webhook",
  "secret": "<your-chosen-hmac-secret>",
  "events": ["container.status_changed", "customs.cleared"]
}
```

**2. Verify the HMAC signature** on every incoming request:

```python
import hashlib
import hmac

def verify_pcs_webhook(body: bytes, signature_header: str, secret: str) -> bool:
    """Verify that a PCS webhook came from the real PCS server."""
    expected = hmac.new(
        secret.encode("utf-8"),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

# In your webhook handler:
@app.post("/pcs-webhook")
async def handle_pcs_event(request: Request):
    body = await request.body()
    signature = request.headers.get("X-PCS-Signature", "")
    if not verify_pcs_webhook(body, signature, YOUR_SECRET):
        return Response(status_code=401)

    event = await request.json()
    # event["event_type"] == "container.status_changed"
    # event["container_number"] == "TCKU3953430"
    # event["new_status"] == "AVAILABLE_FOR_PICKUP"
    await handle_status_change(event)
    return Response(status_code=204)
```

**3. Webhook payload format:**

```json
{
  "event_type": "container.status_changed",
  "event_time": "2026-06-16T11:34:00Z",
  "container_number": "TCKU3953430",
  "old_status": "YARD_IN",
  "new_status": "CUSTOMS_CLEARED",
  "manifest_number": "PCS-MAN-2026-0099",
  "vessel_name": "AL-KHAFJI",
  "consignee_name": "Al-Jazirah Electronics Trading Co., Dammam"
}
```

Return `204 No Content` within 5 seconds. PCS retries failed deliveries with exponential backoff (3 attempts, max 30s between attempts). After 3 failures, the webhook is suspended and the integration team is notified.

---

## 6. Integration Guide for Shipping Lines

This guide walks through the end-to-end process of automating manifest submission from a shipping line's Transport Management System (TMS) to PCS and ZATCA.

---

### Step 1 — Register as a Stakeholder (Manual)

Contact the Port Authority IT team at **it-support@portauthority.sa** with:

- Company trade name (Arabic and English)
- Commercial Registration number
- Saudi Customs broker license number (if freight forwarder)
- Technical contact name, email, and mobile number
- Desired access role: `AGENT` (submit own manifests) or `OPERATOR` (full port ops access)

The IT team will create your stakeholder account and send login credentials for the staging environment within 2 business days.

---

### Step 2 — Get an API Key from the PCS Admin Portal

1. Log in to the **staging** PCS web portal with your credentials.
2. Navigate to **Settings → API Access**.
3. Click **Generate New Key** — the key is shown exactly once.
4. Store it in AWS Secrets Manager (or equivalent), not in a config file.

```bash
# Store key in AWS Secrets Manager
aws secretsmanager create-secret \
  --name "pcs/staging/api-key" \
  --secret-string '{"PCS_API_KEY": "pcs_a3f8b2c1..."}'
```

Repeat this process in the **production** portal once testing is complete (Step 3).

---

### Step 3 — Test in the Staging Environment

The staging environment (`https://staging-api.pcs.portauthority.sa`) is identical to production in API contract but uses:
- Synthetic vessel data (no real shipping lines' data)
- Mock ZATCA (immediate random clearance responses — no real customs processing)
- Separate stakeholder and API key registrations

**Staging-specific behaviour:**
- ZATCA submissions receive a mock reference number `SADAN-<manifest_number>` immediately
- AIS feed serves synthetic vessel positions that follow a pre-programmed route
- No Slack notifications — pipeline events are logged only

Run these tests before requesting production access:

```bash
# 1. Verify your API key works
curl https://staging-api.pcs.portauthority.sa/api/v1/auth/me \
  -H "X-API-Key: pcs_your_staging_key"

# 2. Check you can see upcoming arrivals
curl https://staging-api.pcs.portauthority.sa/api/v1/vessels/upcoming \
  -H "X-API-Key: pcs_your_staging_key"

# 3. Submit a test vessel call (your stakeholder_id from the /me response)
curl -X POST https://staging-api.pcs.portauthority.sa/api/v1/vessels/ \
  -H "X-API-Key: pcs_your_staging_key" \
  -H "Content-Type: application/json" \
  -d '{ ... }'    # See step 4 below for the full payload

# 4. Submit a test manifest
# 5. Poll ZATCA clearance status
# 6. Verify container tracking reflects the correct status
```

---

### Step 4 — Submit Your First Vessel Call

A complete working example for the vessel **AL-KHAFJI** (IMO 9876543):

```python
import httpx
import os
from datetime import datetime, timezone, timedelta

API_KEY  = os.environ["PCS_API_KEY"]
BASE_URL = "https://api.pcs.portauthority.sa"  # use staging URL for testing

async def submit_vessel_call(shipping_agent_id: str) -> dict:
    eta = datetime.now(timezone.utc) + timedelta(days=5)
    etd = eta + timedelta(days=2)

    payload = {
        "imo_number": "9876543",
        "vessel_name": "AL-KHAFJI",
        "vessel_type": "CONTAINER",
        "flag_state": "SA",
        "loa": "294.10",
        "beam": "32.20",
        "draft_max": "13.60",
        "draft_arrival": "12.40",
        "gross_tonnage": 52000,
        "net_tonnage": 30000,
        "shipping_agent_id": shipping_agent_id,
        "eta": eta.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "etd": etd.strftime("%Y-%m-%dT%H:%M:%SZ"),
        "voyage_number": f"AKH-{datetime.now().year}-{datetime.now().strftime('%W')}A",
        "last_port": "CNSHA",   # Shanghai
        "next_port": "AEDXB",   # Dubai
        "purpose": "IMPORT",
    }

    async with httpx.AsyncClient(
        base_url=BASE_URL,
        headers={"X-API-Key": API_KEY},
        timeout=30.0,
    ) as client:
        r = await client.post("/api/v1/vessels/", json=payload)
        r.raise_for_status()
        vessel_call = r.json()
        print(f"Vessel call created: {vessel_call['id']}")
        print(f"Status: {vessel_call['status']}")   # EXPECTED
        return vessel_call
```

---

### Step 5 — Submit the Cargo Manifest

The manifest must be submitted after the vessel call is created. Link them via `vessel_call_id`.

```python
async def submit_cargo_manifest(vessel_call_id: str, stakeholder_id: str) -> dict:
    payload = {
        "vessel_call_id": vessel_call_id,
        "total_containers": 1,
        "total_weight_mt": "22.500",
        "submission_method": "API",
        "submitted_by": stakeholder_id,
        "items": [
            {
                "container_number": "TCKU3953430",
                "container_size": "40HC",
                "cargo_type": "GENERAL",
                "commodity_description": "Consumer electronics — 4K smart televisions, model XR-65",
                "hs_code": "8528720000",
                "weight_kg": "22500.000",
                "volume_cbm": "67.200",
                "shipper_name": "Hisense International Electronics Co. Ltd.",
                "consignee_name": "Al-Jazirah Electronics Trading Co., Dammam",
                "bill_of_lading_number": "OOLU-DMM-2026-0881",
                "seal_number": "CN-7712344",
            }
        ],
    }

    async with httpx.AsyncClient(
        base_url=BASE_URL,
        headers={"X-API-Key": API_KEY},
        timeout=30.0,
    ) as client:
        r = await client.post("/api/v1/cargo/manifests", json=payload)
        r.raise_for_status()
        manifest = r.json()
        print(f"Manifest: {manifest['manifest_number']}")
        print(f"Submission status: {manifest['submission_status']}")  # SUBMITTED
        return manifest
```

---

### Step 6 — Trigger ZATCA Submission and Monitor Clearance

Once PCS accepts the manifest (`submission_status == "ACCEPTED"`), trigger the ZATCA submission and poll for clearance.

```python
import asyncio
import httpx

async def submit_to_zatca_and_monitor(manifest_id: str) -> None:
    headers = {"X-API-Key": API_KEY}

    async with httpx.AsyncClient(base_url=BASE_URL, headers=headers, timeout=30.0) as client:

        # 1. Trigger ZATCA submission (idempotent — safe to call multiple times)
        r = await client.post(f"/api/v1/customs/submit/{manifest_id}")
        r.raise_for_status()
        result = r.json()
        print(f"ZATCA reference: {result['zatca_reference']}")
        print(f"Customs status: {result['customs_status']}")  # PENDING

        # 2. Poll for clearance (production typically 2–24 hours)
        #    Use a webhook in production instead — see Section 5.7
        for attempt in range(1, 49):    # poll up to 48 times
            await asyncio.sleep(1800)   # wait 30 minutes between polls

            r = await client.get(f"/api/v1/customs/status/{manifest_id}")
            r.raise_for_status()
            status_data = r.json()

            customs_status = status_data["customs_status"]
            print(f"Attempt {attempt}: customs_status = {customs_status}")

            if customs_status == "CLEARED":
                print("✅ Customs cleared! Containers are being released.")
                break
            elif customs_status == "HELD":
                print("⚠️  Customs hold placed. Contact your broker.")
                break
            elif customs_status == "INSPECTION":
                print("🔍 Physical inspection ordered. Coordinate with terminal.")
                break
            # PENDING — continue polling

        else:
            print("⏰ Polling timeout after 24 h. Check ZATCA portal directly.")


# Complete flow
async def main():
    # Read from your TMS or environment
    shipping_agent_id = os.environ["PCS_STAKEHOLDER_ID"]

    vessel_call = await submit_vessel_call(shipping_agent_id)
    manifest = await submit_cargo_manifest(vessel_call["id"], shipping_agent_id)

    # Wait for PCS acceptance (usually immediate, but up to a few seconds for heavy loads)
    if manifest["submission_status"] == "SUBMITTED":
        print("Waiting for PCS to accept the manifest...")
        await asyncio.sleep(5)
        # Re-fetch to check acceptance
        async with httpx.AsyncClient(base_url=BASE_URL, headers={"X-API-Key": API_KEY}) as client:
            r = await client.get(f"/api/v1/cargo/manifests/{manifest['id']}")
            manifest = r.json()

    if manifest["submission_status"] == "ACCEPTED":
        await submit_to_zatca_and_monitor(manifest["id"])
    else:
        print(f"Manifest not accepted: {manifest['submission_status']}")

asyncio.run(main())
```

---

### Integration Checklist

Before going live in production, verify each item:

**Authentication**
- [ ] API key stored in AWS Secrets Manager, not hardcoded or in `.env` files
- [ ] Key successfully authenticates against production `GET /api/v1/auth/me`
- [ ] TLS certificate validation is not disabled in your HTTP client

**Vessel Call Submission**
- [ ] ETA is submitted ≥ 72 hours before arrival (MAWANI requirement)
- [ ] `imo_number` is exactly 7 digits (no prefix, no punctuation)
- [ ] All timestamps are UTC (`Z` suffix or `+00:00`)
- [ ] `shipping_agent_id` is your production stakeholder UUID (not staging)
- [ ] `draft_arrival` ≤ `draft_max` validated in your TMS before submission

**Manifest Submission**
- [ ] Container numbers validated as ISO 6346 (4 letters + 7 digits)
- [ ] HS codes are 6–10 digits, no dots or letters
- [ ] All DANGEROUS containers have `imdg_class` and `un_number`
- [ ] All REEFER containers have `reefer_temp_celsius`
- [ ] `total_containers` matches the count of items in the `items` array
- [ ] `total_weight_mt` matches the sum of `weight_kg` / 1000 across all items (within 1% tolerance)

**Error Handling**
- [ ] `401 token_expired` triggers a token refresh before retrying
- [ ] `429 rate_limit_exceeded` respects the `Retry-After` header
- [ ] `409 conflict` (duplicate manifest) does not create a duplicate submission
- [ ] `502 zatca_unavailable` queues the manifest and retries — not lost

**Monitoring**
- [ ] Webhook endpoint registered for `customs.cleared` events
- [ ] Webhook HMAC signature verification implemented
- [ ] Alert configured if manifest stays `PENDING` for > 24 hours
- [ ] Alert configured if vessel call ETA not updated when deviation > 2 hours

---

### Support Contacts

| Issue | Contact |
|---|---|
| API access, stakeholder registration | it-support@portauthority.sa |
| Customs clearance delays, ZATCA holds | customs@portauthority.sa |
| Berth assignment, operational queries | operations@portauthority.sa |
| Technical integration support | api-support@portauthority.sa |
| Emergency (vessel operations) | VHF Channel 16 / +966-13-xxx-xxxx |

**Business hours:** Sunday–Thursday, 07:00–15:00 AST (Arabia Standard Time, UTC+3). Emergency operational support is available 24/7 via VHF.

---

*This document is generated from the live PCS source code. Field names, enum values, and error codes match the API exactly. For the OpenAPI specification (JSON Schema + try-it-out console), contact api-support@portauthority.sa to obtain staging access credentials.*
