> ## Documentation Index
> Fetch the complete documentation index at: https://terminal49.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrating from FourKites

> Map FourKites ocean tracking fields, webhooks, and errors to Terminal49 equivalents. Includes carrier coverage and a migration checklist.

<Note>
  This guide covers the ocean tracking portion only. FourKites road, rail, and LTL tracking are out of scope here; keep them in FourKites unless you are replacing them with another provider.
</Note>

If you have FourKites ocean tracking in production, this page maps it onto Terminal49 field by field, so you can cut over without reverse-engineering our schema.

Terminal49 is a direct-integration ocean and North American terminal API, self-serve, with holds and fees out of the box. FourKites is multi-modal, enterprise-priced, and centered on their broader visibility platform. This guide addresses only the ocean container portion.

There is no compatibility shim. You will change your request code and your response parsing. For most integrations that is an afternoon.

## Start in sixty seconds

Signing up and getting a key is self-serve.

<Steps>
  <Step title="Create an account">
    [Create an account](https://app.terminal49.com) — the free plan tracks up to 10 active containers.
  </Step>

  <Step title="Generate an API key">
    Generate a key at [app.terminal49.com/developers/api-keys](https://app.terminal49.com/developers/api-keys).
  </Step>

  <Step title="Run your first curl">
    ```bash theme={null}
    curl -X POST https://api.terminal49.com/v2/tracking_requests \
      -H "Content-Type: application/vnd.api+json" \
      -H "Authorization: Token YOUR_API_KEY" \
      -d '{
        "data": {
          "type": "tracking_request",
          "attributes": {
            "request_number": "YOUR_BOL_NUMBER",
            "request_type": "bill_of_lading",
            "scac": "MAEU"
          }
        }
      }'
    ```
  </Step>
</Steps>

<Warning>
  The full API key is shown once, right after you create it. Copy it before you navigate away — after that it is masked and cannot be revealed. If you miss it, create a new key and delete the old one.
</Warning>

<Note>
  The free plan tracks up to 10 active containers. Creating tracking requests through the API works right away; reading tracking data back through the API requires a free 7-day API trial (same 10-container limit) — contact us via in-app chat or [support@terminal49.com](mailto:support@terminal49.com) and we enable it.
</Note>

If you want to test without a live shipment, use the [test tracking numbers](/docs/api-docs/useful-info/test-numbers), which simulate success and failure outcomes.

<Note>
  **Migration offer:** sign up now and the [Vessels API](/docs/api-docs/api-reference/vessels/get-a-vessel-using-the-id) — vessel schedules, AIS positions, and projected routes — is free for your first month.
</Note>

## The architectural shift

FourKites tracks shipments across modes in a single enterprise platform. Ocean is one product among many, organized around loads and shipments with nested segments.

Terminal49 splits ocean tracking in two. You register a tracking request once. We keep it updated and push changes to your webhook.

<CardGroup cols={2}>
  <Card title="Before — FourKites" icon="rotate">
    Query the FourKites platform for shipment or load state, often across ocean, road, and rail segments. Extract ocean data from the multi-modal shipment model. Polling, enterprise contract, and broad visibility scope.
  </Card>

  <Card title="After — Terminal49" icon="webhook">
    `POST /tracking_requests` once → Terminal49 polls carriers, terminals, and rail → we POST to your endpoint as things change → write to your database. No cache layer, no dedupe logic. Ocean only, direct terminal data.
  </Card>
</CardGroup>

You can keep polling if you prefer — point your existing scheduler at `GET /v2/shipments` or `GET /v2/containers`. But webhooks are the reason the API is shaped this way, and terminal data (holds, fees, last free day) changes on a cadence that polling tends to miss.

## Quick comparison

|                         | FourKites                                | Terminal49                              |
| ----------------------- | ---------------------------------------- | --------------------------------------- |
| Tracking model          | Poll via enterprise platform             | Register once, then push or poll        |
| Authentication          | Platform credentials (varies by product) | `Authorization: Token` header           |
| Base URL                | Your FourKites API endpoint              | `https://api.terminal49.com/v2`         |
| Content type            | `application/json`                       | `application/vnd.api+json`              |
| Response format         | Custom JSON                              | JSON:API                                |
| Webhooks                | Available                                | 30+ events, HMAC-signed                 |
| Carrier identification  | SCAC or internal mapping                 | `scac`, or `auto_detect_vocc_scac`      |
| Terminal holds and fees | No direct equivalent                     | Included on the container object        |
| Last free day           | No direct equivalent                     | Included, with per-source breakdown     |
| Rail milestones         | Available                                | North American Class I and short-line   |
| Multi-modal scope       | Ocean, road, rail, freight               | Ocean and North American terminals only |
| Getting an API key      | Enterprise contract                      | Self-serve                              |

## Authentication

Move from your FourKites credentials — whichever scheme your FourKites product uses today — to a single `Authorization: Token` header.

<CodeGroup>
  ```bash FourKites theme={null}
  # However your integration authenticates today, for example:
  curl "https://<your-fourkites-api-endpoint>/shipments" \
    -H "Authorization: Bearer YOUR_FOURKITES_TOKEN"
  ```

  ```bash Terminal49 theme={null}
  curl -X POST https://api.terminal49.com/v2/tracking_requests \
    -H "Content-Type: application/vnd.api+json" \
    -H "Authorization: Token YOUR_T49_KEY" \
    -d '{"data":{"type":"tracking_request","attributes":{
         "request_number":"MRKU9465770",
         "request_type":"container",
         "scac":"MAEU"}}}'
  ```
</CodeGroup>

Note the `Token` prefix. It is not `Bearer`.

## Request parameter mapping

| FourKites parameter        | Terminal49 equivalent                                                                                                   | Notes                                                                                                                    |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Shipment / load identifier | `request_number`                                                                                                        |                                                                                                                          |
| Container number           | `request_type: "container"` + `request_number`                                                                          |                                                                                                                          |
| Bill of lading number      | `request_type: "bill_of_lading"`                                                                                        | Master or house BOL                                                                                                      |
| Booking number             | `request_type: "booking_number"`                                                                                        |                                                                                                                          |
| SCAC / carrier code        | `scac`                                                                                                                  | Same SCAC values for most carriers                                                                                       |
| Carrier auto-detection     | Set `auto_detect_vocc_scac: true`, or call [Infer Tracking Number](/docs/api-docs/in-depth-guides/auto-detect-carrier) first | Auto-detection runs asynchronously; a failed inference fails the tracking request with `scac_auto_detect_failed`         |
| Force refresh              | `PATCH /v2/containers/{id}/refresh`                                                                                     | Forces an immediate pull from all sources. Paid feature — requires account enablement, limited to 10 requests per minute |
| Route / journey legs       | `GET /v2/containers/{id}/map_geojson`                                                                                   | Requires the Routing Data entitlement. See [Routing](/docs/api-docs/in-depth-guides/routing)                                  |

<Note>
  FourKites organizes around shipments and loads that may contain multiple segments across modes. Terminal49 takes one ocean identifier per tracking request. Track by BOL and we return every container on that bill of lading as related container resources. Container-number tracking requests are currently in beta.
</Note>

## Response field mapping

Terminal49 is JSON:API compliant, so relationships between shipments, containers, ports, and terminals are explicit rather than something you reassemble from ID references.

Use the [`include` parameter](/docs/api-docs/in-depth-guides/including-resources) to sideload related resources in one call instead of chasing IDs.

### Shipment level

| FourKites                        | Terminal49                                                                                               |
| -------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Bill of lading number            | `shipment.attributes.bill_of_lading_number`                                                              |
| Carrier SCAC                     | `shipment.attributes.shipping_line_scac`                                                                 |
| Carrier name                     | `shipment.attributes.shipping_line_name`                                                                 |
| Shipment status                  | Derived from container status and milestones                                                             |
| Inland origin / place of receipt | No direct equivalent — Terminal49 shipments start at the port of lading                                  |
| Port of loading                  | `shipment.relationships.port_of_lading` (name and LOCODE also on `shipment.attributes.port_of_lading_*`) |
| Port of discharge                | `shipment.relationships.port_of_discharge`                                                               |
| Final destination (inland)       | `shipment.relationships.destination`                                                                     |
| ETA at discharge port            | `shipment.attributes.pod_eta_at`                                                                         |

### Container level

| FourKites            | Terminal49                                                                                                                         |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Container number     | `container.attributes.number`                                                                                                      |
| Equipment / ISO code | `container.attributes.equipment_type` + `equipment_length` + `equipment_height`                                                    |
| Container status     | `container.attributes.current_status`                                                                                              |
| Event list           | Container timestamps, [transport events](/docs/api-docs/api-reference/containers/get-a-containers-transport-events), and webhook events |

<Note>
  FourKites may return an ISO code string like `45G1`. Terminal49 splits this into three normalized fields: type (dry, reefer, open top, flat rack, bulk, tank), length (10, 20, 40, 45), and height (standard, high cube). If you were parsing ISO codes yourself, you can delete that code.
</Note>

### Locations, facilities, and vessels

| FourKites                  | Terminal49                                                                                  |
| -------------------------- | ------------------------------------------------------------------------------------------- |
| Port name                  | `port.attributes.name`                                                                      |
| Port code / UN/LOCODE      | `port.attributes.code`                                                                      |
| Port coordinates           | `port.attributes.latitude` / `.longitude`                                                   |
| Port timezone              | `port.attributes.time_zone`                                                                 |
| Port country               | `port.attributes.country_code`                                                              |
| Terminal / facility name   | `terminal.attributes.name`                                                                  |
| Terminal SMDG code         | `terminal.attributes.smdg_code`                                                             |
| Terminal BIC facility code | `terminal.attributes.bic_facility_code`                                                     |
| Vessel name                | `shipment.attributes.pod_vessel_name`                                                       |
| Vessel IMO                 | `shipment.attributes.pod_vessel_imo`                                                        |
| Vessel MMSI                | Available via the [Vessels API](/docs/api-docs/api-reference/vessels/get-a-vessel-using-the-imo) |
| Vessel call sign / flag    | Not returned                                                                                |

## Milestone and event mapping

FourKites returns milestones within your shipment or load resource. Terminal49 exposes the same milestones as normalized transport events and pushes each one to your webhook.

Where mappings exist:

| FourKites milestone    | Terminal49 event                        |
| ---------------------- | --------------------------------------- |
| Loaded on vessel       | `container.transport.vessel_loaded`     |
| Vessel departed        | `container.transport.vessel_departed`   |
| Vessel arrived         | `container.transport.vessel_arrived`    |
| Discharged from vessel | `container.transport.vessel_discharged` |
| Full out / gated out   | `container.transport.full_out`          |
| Full in / gated in     | `container.transport.full_in`           |
| —                      | `container.transport.empty_out`         |
| —                      | `container.transport.empty_in`          |

Terminal49 also emits milestones with no FourKites ocean equivalent:

* **Vessel berthed** — `container.transport.vessel_berthed`
* **Available for pickup** — `container.transport.available` and `.not_available`
* **Transshipment** — arrived, discharged, loaded, departed
* **Feeder vessel and barge** — arrived, discharged, loaded, departed
* **Rail** — loaded, departed, arrived, unloaded, plus `arrived_at_inland_destination`

See the full [event catalog](/docs/api-docs/webhooks/event-catalog).

### Registering a webhook

```bash theme={null}
curl -X POST https://api.terminal49.com/v2/webhooks \
  -H "Content-Type: application/vnd.api+json" \
  -H "Authorization: Token YOUR_API_KEY" \
  -d '{
    "data": {
      "type": "webhook",
      "attributes": {
        "url": "https://your-endpoint.example.com/t49",
        "active": true,
        "events": [
          "container.transport.vessel_discharged",
          "container.transport.available",
          "container.pickup_lfd.changed"
        ]
      }
    }
  }'
```

Payloads are HMAC-signed. See [webhook setup](/docs/api-docs/in-depth-guides/webhooks) for signature verification, and [List webhook IPs](/docs/api-docs/api-reference/webhooks/list-webhook-ips) if your firewall restricts inbound traffic.

## What you gain

This is the part worth reading even if the rest is mechanical. Terminal49 integrates with terminals directly, not only carriers, so the container object carries operational data that has no FourKites equivalent.

### Holds

`holds_at_pod_terminal` is an array of active holds blocking pickup:

```json theme={null}
{
  "holds_at_pod_terminal": [
    { "name": "customs", "status": "hold", "description": "CBP HOLD" },
    { "name": "freight", "status": "hold", "description": null }
  ]
}
```

Hold names are `freight`, `customs`, `USDA`, `VACIS`, `TMF`, and `other`. Status is `hold` or `pending`. When a hold clears, the object is removed from the array — there is no released state.

<Warning>
  Hold names are case-sensitive. `USDA`, `VACIS`, and `TMF` are uppercase; `freight`, `customs`, and `other` are lowercase. Match exactly.
</Warning>

### Fees

`fees_at_pod_terminal` carries type, amount, and currency:

```json theme={null}
{
  "fees_at_pod_terminal": [
    { "type": "demurrage", "amount": 850.00, "currency_code": "USD" },
    { "type": "exam", "amount": 450.00, "currency_code": "USD" }
  ]
}
```

Fee types are `demurrage`, `extended_dwell_time`, `exam`, `total`, and `other`.

<Warning>
  Some terminals report a `total` line item alongside individual fees. Filter it out before summing or you will double-count.
</Warning>

### Last free day

`pickup_lfd` is a coalesced value that follows a fixed source priority: shipping line, then terminal, then rail. It does not pick the earliest date. The individual sources are available separately on `import_deadlines`:

* `pickup_lfd_line` — the shipping line's LFD (per diem deadline)
* `pickup_lfd_terminal` — the terminal's LFD (demurrage deadline)
* `pickup_lfd_rail` — the rail carrier's LFD at the inland destination

Each has its own webhook event, so you can alert on whichever source your operation cares about.

### Release readiness

Two fields answer "can I pick this up?" — `available_for_pickup` and the holds array:

```javascript theme={null}
function isReadyForPickup(container) {
  const { available_for_pickup, holds_at_pod_terminal } = container.attributes;
  const hasActiveHolds = holds_at_pod_terminal.some(h => h.status === 'hold');
  return available_for_pickup === true && !hasActiveHolds;
}
```

Full detail in [Holds, Fees, and Release Readiness](/docs/api-docs/in-depth-guides/holds-and-fees).

<Info>
  Holds, fees, LFD, and availability come back on the container object wherever the terminal is a supported source. They are not a paid add-on and they do not require a sales conversation. See [Entitlements](/docs/api-docs/useful-info/entitlements) for the features that do require account enablement — Routing Data (container map and vessel positions), rail LFD, container refresh, and the embeddable map and widget are the gated ones.
</Info>

## Gotchas that will bite you

<AccordionGroup>
  <Accordion title="Enterprise contract vs self-serve pricing">
    FourKites is an enterprise platform. Terminal49 is self-serve with a free plan that tracks up to 10 active containers. API reads require a free 7-day API trial we enable on request. If you need volume pricing, it exists, but you do not need a contract to start.
  </Accordion>

  <Accordion title="Multi-modal shipment model: extract ocean segments">
    FourKites shipments may contain road, rail, and ocean segments in one object. Terminal49 is ocean only. Map only the ocean leg. Keep road and rail in FourKites or replace them separately.
  </Accordion>

  <Accordion title="FourKites event vocabulary differs from normalized transport events">
    FourKites event names and codes are specific to their platform. You will need a mapping table to translate them onto Terminal49's normalized events such as `container.transport.vessel_discharged`. Where exact mappings do not exist, handle the Terminal49 events directly.
  </Accordion>

  <Accordion title="Tracking requests are asynchronous">
    `POST /tracking_requests` returns immediately with a pending status. The shipment appears once the carrier responds. Subscribe to `tracking_request.succeeded` and `tracking_request.failed` rather than expecting shipment data in the creation response. A request may also land in `awaiting_manifest` if the carrier has not manifested the shipment yet — we retry automatically. See [Tracking Request Lifecycle](/docs/api-docs/in-depth-guides/tracking-request-lifecycle).
  </Accordion>

  <Accordion title="Timestamps are UTC with a separate timezone field">
    FourKites may return local time or timestamps with offset. Terminal49 stores event timestamps in UTC and returns the matching IANA timezone alongside. Convert for display rather than assuming local time. See [Event Timestamps](/docs/api-docs/in-depth-guides/event-timestamps).
  </Accordion>

  <Accordion title="Empty arrays are the normal state">
    `holds_at_pod_terminal: []` and `fees_at_pod_terminal: []` mean no active holds or fees. This is the common case. Do not treat it as missing data.
  </Accordion>

  <Accordion title="container.updated carries a changeset">
    Terminal changes (fees, holds, LFD, appointment, availability) arrive on `container.updated` with a `changeset` showing old value first, new value second. Use it instead of diffing state yourself.
  </Accordion>
</AccordionGroup>

## Error handling

FourKites returns errors within the response body of their platform API. Terminal49 uses standard HTTP status codes. Replace body checks and message-string matching with status-code checks.

| Status | Meaning                                                                    |
| ------ | -------------------------------------------------------------------------- |
| 400    | Malformed request or failed validation                                     |
| 401    | Missing or invalid API key                                                 |
| 403    | Key lacks permission, or the feature is not enabled on your plan           |
| 404    | Resource does not exist                                                    |
| 422    | Valid syntax, rejected content — for example a malformed container number  |
| 429    | Rate limited. See [Rate Limiting](/docs/api-docs/in-depth-guides/rate-limiting) |
| 5xx    | Terminal49 or an upstream carrier or terminal is unavailable               |

Rough equivalence for the FourKites errors you are handling today:

| FourKites error              | Terminal49                         |
| ---------------------------- | ---------------------------------- |
| Authentication failure       | HTTP 401                           |
| Permission / access denied   | HTTP 403                           |
| Rate limit exceeded          | HTTP 429                           |
| Validation failure           | HTTP 400 or 422                    |
| Resource not found           | HTTP 404                           |
| Upstream carrier unavailable | Not surfaced — we retry internally |

The [TypeScript SDK](/docs/sdk/introduction) maps these to typed errors (`AuthenticationError`, `ValidationError`, `RateLimitError`, `UpstreamError`, `FeatureNotEnabledError`, `AuthorizationError`, `NotFoundError`) and retries rate-limit and server errors automatically with exponential backoff.

## Where we are narrower than FourKites

Worth knowing before you commit.

**Carrier count.** Terminal49 integrates directly with 36 ocean carriers, plus 2 more enabled on request — as of 14 August 2026. Check your carrier mix against the [ocean carrier list](/docs/coverage/ocean-carriers) before cutover, and read the known issues section there — we publish the per-carrier field gaps.

**Terminal data is North America.** Holds, fees, LFD, and availability come from direct terminal integrations concentrated in the US and Canada, with European ports expanding. Ocean milestones work globally; terminal-level operational data does not yet.

**No road, rail, or LTL tracking.** FourKites is multi-modal. Terminal49 is ocean and North American terminals only. This migration handles only the ocean portion.

**No freight rates or sailing schedules.** We do not offer a rate calculator, rate index, or schedule search.

**Some fields are source-dependent.** Seal number, container weight, and departure or arrival events vary by carrier. The [field availability reference](/docs/coverage/fields) says which fields are always present and which depend on the carrier, terminal, or journey.

## Migration checklist

Everyone does the base path. Then pick a branch.

### Base path

<Steps>
  <Step title="Get a key">
    Self-serve at [app.terminal49.com/developers/api-keys](https://app.terminal49.com/developers/api-keys). Copy it immediately — it is shown once.
  </Step>

  <Step title="Switch authentication">
    Move from OAuth2 bearer or API key to `Authorization: Token`. Note the `Token` prefix.
  </Step>

  <Step title="Check your carrier mix">
    Compare your FourKites carrier values against the [carrier list](/docs/coverage/ocean-carriers). Flag anything missing before you cut over.
  </Step>

  <Step title="Extract ocean segments">
    Identify the ocean leg of each FourKites shipment. Discard road, rail, and LTL for this migration.
  </Step>

  <Step title="Create tracking requests">
    One `POST /tracking_requests` per BOL, booking, or container, replacing the per-request lookup.
  </Step>

  <Step title="Handle the async lifecycle">
    Tracking requests start pending. Handle `succeeded`, `failed`, and `awaiting_manifest` rather than expecting data on creation.
  </Step>

  <Step title="Update response parsing">
    JSON:API structure, split equipment fields, UTC timestamps with a separate timezone.
  </Step>

  <Step title="Update error handling">
    Replace body checks with HTTP status codes.
  </Step>

  <Step title="Backfill active shipments">
    Submit tracking requests for everything currently in transit. Send us the list if it is large and we will load it.
  </Step>

  <Step title="Add the terminal fields">
    Holds, fees, and LFD are the reason to do this properly rather than porting like for like.
  </Step>
</Steps>

### Then pick one

<Tabs>
  <Tab title="Webhook path">
    1. Expose an HTTPS endpoint that accepts our POST payloads.
    2. Register a webhook and subscribe only to events you act on.
    3. Verify HMAC signatures.
    4. Whitelist our IPs if your firewall restricts inbound traffic.
    5. Trigger a test delivery before going live.
    6. Retire your polling job and your dedupe layer.

    See [webhook best practices](/docs/api-docs/webhooks/best-practices) for retries and idempotency.
  </Tab>

  <Tab title="Polling path">
    1. Store the tracking request ID from the creation response. The shipment ID arrives later — the creation response is pending with no shipment attached; fetch the tracking request again (or handle `tracking_request.succeeded`) to get it once the carrier responds.
    2. Repoint your existing scheduler at `GET /v2/shipments` or `GET /v2/containers`.
    3. Keep your existing cadence.

    Skipped: endpoint setup, signature verification, IP whitelisting, delivery testing.

    Terminal data changes on a cadence polling tends to miss. If you only adopt webhooks for one thing, make it `container.updated` and `container.pickup_lfd.changed`.
  </Tab>
</Tabs>

## Migrate with an AI coding agent

If you use Claude Code, Cursor, Codex, Windsurf, Copilot, or another AI coding assistant, hand it the prompt below. It is written to run a **side-by-side migration**: the agent stands up a Terminal49 client next to your existing FourKites code, shadows every FourKites call with a Terminal49 call, diffs the responses, and only cuts over once parity is proven.

<Tip>
  Point your agent at this page as context (paste the URL or add it as a doc source). The prompt references the mappings above, so the more of this page the agent can see, the better it does.
</Tip>

<AccordionGroup>
  <Accordion title="How to use this prompt" icon="wand-magic-sparkles">
    1. Open your repo in your AI coding tool.
    2. Add this page as a documentation source, or paste its URL into the chat.
    3. Copy the prompt below into a new chat and send it.
    4. Answer the agent's discovery questions (FourKites client location, env var names, carrier mix).
    5. Review each PR the agent opens. It should ship in small, reviewable steps: client, shadow, parity harness, cutover, cleanup.
  </Accordion>

  <Accordion title="What the agent will produce" icon="list-check">
    * A `Terminal49Client` alongside your existing FourKites client, sharing the same interface where possible.
    * A shadow-mode wrapper that calls both providers and logs response diffs without changing behavior.
    * A parity report per shipment: matched fields, diverged fields, and Terminal49-only fields (holds, fees, LFD).
    * A feature-flagged cutover: route reads to Terminal49, keep FourKites as fallback until you flip the flag off.
    * A webhook receiver with HMAC verification, or a polling scheduler, depending on which path you pick.
    * A cleanup PR that removes FourKites code, env vars, dependencies, and dedupe logic.
  </Accordion>
</AccordionGroup>

### The prompt

Copy this into your agent. Replace the bracketed placeholders in the **Repo context** block before sending.

```markdown Terminal49 migration agent prompt expandable icon=robot wrap theme={null}
You are migrating this codebase from the FourKites API to the Terminal49 API,
side by side. Terminal49's authoritative migration guide is at
https://terminal49.com/docs/migrate/fourkites. Use it as
the source of truth for field mappings, event names, error codes, and behavior differences.

# Repo context (fill this in before running)

- FourKites client lives at: [path/to/client]
- FourKites is called from: [list the call sites or "find them"]
- Language and framework: [e.g. TypeScript + Node, Python + FastAPI, Ruby on Rails]
- Storage for tracking state: [e.g. Postgres table `shipments`]
- Current polling schedule: [e.g. cron every 2h]
- Carrier mix (SCACs we track most): [e.g. MAEU, MSCU, CMDU, HLCU, ONEY]
- Deployment target: [e.g. AWS ECS, Vercel, Fly.io]
- Secret store: [e.g. AWS Secrets Manager, `.env`, Doppler]

# Rules

1. Do not delete FourKites code until the cleanup step. Migration is side by side.
2. Ship in small PRs. Each PR must build, pass tests, and be independently revertable.
3. Never invent Terminal49 fields, endpoints, or event names. If the guide does not
   confirm a mapping, ask me. Do not guess.
4. Terminal49 uses `Authorization: Token <key>` (not `Bearer`) and content type
   `application/vnd.api+json`. Get this right on the first request.
5. Terminal49 is asynchronous. `POST /tracking_requests` returns pending. Data arrives
   via `tracking_request.succeeded`, `tracking_request.failed`, or
   `tracking_request.awaiting_manifest` webhooks, or by polling `GET /v2/shipments`. Do not expect shipment data on creation.
6. Timestamps are UTC with a separate IANA `time_zone` field. Do not assume local time.
7. `holds_at_pod_terminal: []` and `fees_at_pod_terminal: []` are the normal state,
   not missing data. A fee `amount` of 0 is valid.
8. On `container.updated`, prefer the `changeset` over diffing state yourself.
9. Terminal49 covers the ocean leg only. Keep FourKites road, rail, and LTL tracking
   in place (or leave stubs pointing elsewhere). Extract the ocean segment from each
   multi-modal FourKites shipment before mapping it.

# Plan (execute in order, one PR per step)

## PR 1 — Discovery and interface

- Grep the repo for every FourKites call site. List them in the PR description.
- Extract the FourKites client's public surface into an interface
  (`TrackingProvider` with methods like `track(number, type, carrier)`,
  `getShipment(id)`, `refresh(id)`).
- Make the existing FourKites client implement it. No behavior change.

## PR 2 — Terminal49 client

- Add a `Terminal49Client` implementing the same `TrackingProvider` interface.
- Auth via `T49_API_KEY` env var, header `Authorization: Token ${key}`.
- Base URL `https://api.terminal49.com/v2`, content type `application/vnd.api+json`.
- Implement:
  - `createTrackingRequest({ request_number, request_type, scac })` returning the
    tracking request ID.
  - `getShipment(id, { include: 'containers,port_of_lading,port_of_discharge' })`.
  - `getContainer(id)`.
  - `refreshContainer(id)` mapping to `PATCH /v2/containers/{id}/refresh` (a paid
    feature — skip it unless our account has it enabled).
- Normalize responses to the same shape FourKites callers expect today, using the field
  mapping from the guide. Split equipment codes into `equipment_type`, `equipment_length`,
  `equipment_height`. Convert timestamps to UTC + `time_zone`.
- Map errors to typed classes: `AuthenticationError` (401), `AuthorizationError` (403),
  `ValidationError` (400/422), `RateLimitError` (429), `UpstreamError` (5xx),
  `FeatureNotEnabledError` (403 + feature flag response).
- Add unit tests using recorded fixtures. Do not hit the live API in tests.

## PR 3 — Shadow mode

- Add a `ShadowProvider` that wraps both clients. On every read:
  - Call FourKites as the primary. Return its response.
  - Fire-and-forget a Terminal49 call for the same identifier.
  - Log a structured diff: matched fields, diverged fields, T49-only fields.
- Gate with env var `TRACKING_SHADOW_MODE=true`. Off by default.
- Add a parity report script that aggregates shadow logs by SCAC and field.
- Do NOT change what callers see. This step is observation only.

## PR 4 — Backfill script

- Write a one-shot script that reads all active shipments from our database and calls
  `POST /tracking_requests` for each (one per BOL, booking, or container).
- Store the returned `tracking_request.id` and eventual `shipment.id` on our records.
- Rate-limit to respect Terminal49's limits (handle 429 with exponential backoff).
- Idempotent: safe to re-run. Skip rows already backfilled.

## PR 5 — Webhook receiver (only if we picked the webhook path)

- Add `POST /webhooks/terminal49` endpoint.
- Verify HMAC signature using the shared secret from `T49_WEBHOOK_SECRET`. Reject on
  mismatch with 401.
- Handle these events at minimum:
  - `tracking_request.succeeded`, `tracking_request.failed`, `tracking_request.awaiting_manifest`
  - `container.transport.vessel_discharged`, `container.transport.available`,
    `container.transport.full_out`
  - `container.updated` (use the `changeset`, do not diff)
  - `container.pickup_lfd.changed`
- Persist events idempotently keyed by event ID.
- Register the webhook via `POST /v2/webhooks` from a bootstrap script, subscribing
  only to events we handle.
- If our firewall restricts inbound traffic, whitelist Terminal49 IPs from
  `GET /v2/webhooks/ips`.

## PR 6 — Cutover behind a flag

- Add feature flag `TRACKING_PROVIDER` with values `fourkites` (default) and `terminal49`.
- Route all reads through the flag. FourKites stays available as a fallback for one
  release cycle.
- Flip staging to `terminal49`, verify parity report is clean, then flip production.

## PR 7 — Cleanup

- Delete the FourKites client, its tests, its env vars, its dedupe cache, and any
  equipment-code parsing helpers Terminal49 makes redundant.
- Remove the shadow provider and the feature flag.
- Update README and any runbooks. Note the new webhook endpoint if applicable.

# Definition of done

- All FourKites call sites now go through Terminal49.
- Webhook (or polling) is live in production.
- Parity report shows no unexplained divergences for our top 10 SCACs.
- Holds, fees, and LFD are exposed to whichever downstream system needs them
  (dashboard, alerts, customer emails). Do not migrate without wiring these up. They
  are the reason to do this properly.
- FourKites dependency, env vars, and dead code are gone from the repo.

# Ask me before you

- Choose between the webhook path and the polling path. Default to webhooks unless our
  infra makes an inbound HTTPS endpoint hard.
- Change the shape of any function FourKites callers use today. Prefer a normalization
  layer inside the Terminal49 client.
- Add a new dependency. Prefer stdlib and what is already in the repo.
- Touch anything outside the tracking integration.
```

## Getting help

Send us your list of active container and bill of lading numbers and we will load them rather than making you script the backfill.

If something in this mapping is wrong or incomplete, tell us. We would rather fix the page than have you work around it.

<CardGroup cols={2}>
  <Card title="API reference" icon="code" href="/docs/api-docs/api-reference/introduction">
    Every endpoint, with request and response schemas
  </Card>

  <Card title="TypeScript SDK" icon="rectangle-terminal" href="/docs/sdk/introduction">
    Typed client with retries and pagination built in
  </Card>

  <Card title="Coverage" icon="ship" href="/docs/coverage/home">
    Carriers, terminals, rail, and field availability
  </Card>

  <Card title="Test numbers" icon="flask" href="/docs/api-docs/useful-info/test-numbers">
    Simulate success and failure outcomes
  </Card>
</CardGroup>
