> ## 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 Beacon

> Map Beacon tracking API fields, parameters, and errors to their Terminal49 equivalents. Includes webhook setup, carrier coverage, and a migration checklist.

Beacon is a supply chain visibility platform that also tracks air waybills and offers order management. If you use only the ocean tracking API, moving to Terminal49 gives you direct terminal integrations (holds, fees, LFD) and 30+ webhook events. This guide covers ocean container and BOL tracking only, not Beacon's air tracking or order management.

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">
    Sign up at [app.terminal49.com](https://app.terminal49.com). The free plan tracks up to 10 active containers.
  </Step>

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

  <Step title="Make your first request">
    ```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>

<Tip>
  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.
</Tip>

<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

Beacon gives you a container-centric API. You register a container number, poll for current state, and own the schedule, the cache, and the deduplication.

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

<CardGroup cols={2}>
  <Card title="Before: Beacon" icon="rotate">
    Cron every 2+ hours, call Beacon's container endpoint, diff against your cache, dedupe events, then write to your database. Every successful call spends contracted usage. Freshness is capped by your polling interval.
  </Card>

  <Card title="After: Terminal49" icon="webhook">
    `POST /tracking_requests` once. Terminal49 polls carriers, terminals, and rail, then POSTs to your endpoint as things change. No cache layer, no dedupe logic.
  </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

|                         | Beacon                                                    | Terminal49                            |
| ----------------------- | --------------------------------------------------------- | ------------------------------------- |
| Tracking model          | Register containers, then poll (max every 2h recommended) | Register once, then push or poll      |
| Authentication          | Username/password login → short-lived Bearer token        | `Authorization: Token` header         |
| Base URL                | `https://api.beacon.com/v1`                               | `https://api.terminal49.com/v2`       |
| Content type            | `application/json`                                        | `application/vnd.api+json`            |
| Response format         | JSON API schema                                           | JSON:API                              |
| Webhooks                | Available via support                                     | 30+ events, HMAC-signed               |
| Carrier identification  | 4-character SCAC (`carrier_code`)                         | `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         | Limited                                                   | North American Class I and short-line |
| Getting an API key      | Granted per user by your customer success manager         | Self-serve                            |

## Authentication

Beacon issues a short-lived Bearer token from a login call and expects you to refresh it before it expires. Terminal49 uses a single static API key, so this entire flow goes away.

<CodeGroup>
  ```bash Beacon theme={null}
  # Step 1: log in to get an access token (max 20 requests/minute)
  curl -X POST https://api.beacon.com/v1/login \
    -H "Content-Type: application/json" \
    -d '{"username": "YOUR_BEACON_USERNAME", "password": "YOUR_BEACON_PASSWORD"}'
  # Returns: { "access_token", "refresh_token", "token_type": "Bearer", "expires_in": 300 }

  # Step 2: call the API with the access token (expires after 300 seconds)
  curl -X GET "https://api.beacon.com/v1/containers/MRKU9465770" \
    -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
    -H "Content-Type: application/json"

  # Step 3: refresh before it expires
  curl -X POST https://api.beacon.com/v1/login/token \
    -H "Content-Type: application/json" \
    -d '{"refresh_token": "YOUR_REFRESH_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>
  Note the `Token` prefix. It is not `Bearer`.
</Note>

<Tip>
  Terminal49's API key does not expire. Once you switch, delete the login call, the token cache, the 300-second expiry timer, and the refresh logic entirely — there is nothing to replace them with.
</Tip>

Beacon also requires your customer success manager to grant API access per user before you can call the API at all. Terminal49 keys are self-serve from the dashboard.

## Request parameter mapping

Beacon's `POST /v1/containers` takes an array of container objects in one call. Terminal49's `POST /tracking_requests` takes one identifier per request, but that identifier can be a container number, a BOL, or a booking number.

| Beacon field                                               | Terminal49 equivalent                                                                                                   | Notes                                                                                                                                                                                                                            |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `container_number` (required)                              | `request_number` + `request_type: "container"`                                                                          | Terminal49 also accepts BOL (`request_type: "bill_of_lading"`) and booking numbers (`request_type: "booking_number"`) as the identifier, which Beacon's container endpoint does not                                              |
| `carrier_code` (optional, 4-char SCAC)                     | `scac`                                                                                                                  | Same SCAC format                                                                                                                                                                                                                 |
| Omit `carrier_code`                                        | 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`                                                                                                                 |
| `destination_warehouse_name` (optional)                    | No equivalent field                                                                                                     | Terminal49 does not model an inland warehouse name on the tracking request; the container's `relationships.destination` covers inland rail/port destinations                                                                     |
| `custom_fields` (optional, up to 10 `{name, value}` pairs) | `ref_numbers` (array of strings) on the tracking request, per the [OpenAPI spec](/docs/api-docs/api-reference/introduction)  | Terminal49 stores these as a flat list of reference strings, not named key/value pairs — if you rely on the field name to route data downstream, you'll need to encode that in the string or handle it in your own mapping layer |
| Refresh parameter                                          | `PATCH /v2/containers/{id}/refresh`                                                                                     | Forces an immediate pull from all sources. Paid feature — requires account enablement, limited to 10 requests per minute                                                                                                         |
| Route data                                                 | `GET /v2/containers/{id}/map_geojson` — requires the Routing Data entitlement                                           | See [Routing](/docs/api-docs/in-depth-guides/routing)                                                                                                                                                                                 |
| API key header                                             | `Authorization` header                                                                                                  | `Token` prefix, not `Bearer`                                                                                                                                                                                                     |

<Note>
  Terminal49 takes one 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>

<Warning>
  Beacon consumes your contracted usage whenever a request succeeds and returns tracking data — even for containers you've stopped actively watching. Terminal49's free plan tracks up to 10 active containers at no cost; beyond that, usage is based on active tracking requests, not per-call lookups.
</Warning>

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

Beacon's `GET /v1/containers/{containerNumber}` response is container-centric — it does not return a separate shipment object. Below, the Beacon column reflects the real field names from that response.

### Shipment level

| Beacon field                          | Terminal49                                   |
| ------------------------------------- | -------------------------------------------- |
| `carrier.code`                        | `shipment.attributes.shipping_line_scac`     |
| `carrier.name`                        | `shipment.attributes.shipping_line_name`     |
| `status` (enum)                       | Derived from container status and milestones |
| `port_of_loading`                     | `shipment.relationships.port_of_lading`      |
| `port_of_discharge`                   | `shipment.relationships.port_of_discharge`   |
| `vessel_arrival_dates.estimated_date` | `shipment.attributes.pod_eta_at`             |
| No polling/refresh cache field        | No equivalent. Refresh is managed for you.   |

### Container level

| Beacon field                                                                                                                                              | Terminal49                                                                                                                                                           |
| --------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `container_number`                                                                                                                                        | `container.attributes.number`                                                                                                                                        |
| `status` (enum: `GATED_OUT_EMPTY`, `GATED_IN_FULL`, `LOADED_AT_POL`, `IN_TRANSIT`, `ARRIVED_AT_POD`, `DISCHARGED_AT_POD`, `GATED_OUT_FULL`, `PROCESSING`) | `container.attributes.current_status`                                                                                                                                |
| `gated_out_empty_dates` … `gated_out_full_dates` (each with `estimated_date` and/or `actual_date`)                                                        | Container timestamps, [transport events](/docs/api-docs/api-reference/containers/get-a-containers-transport-events), and webhook events — see the milestone mapping below |
| `custom_fields`                                                                                                                                           | Set at tracking-request time as `ref_numbers`; not returned on the container object                                                                                  |
| `purchase_orders`                                                                                                                                         | No equivalent. Terminal49's tracking API does not do order management                                                                                                |

<Note>
  Beacon's documented container response does not include an equipment type or size field. If your integration reads container equipment (dry, reefer, size) from Beacon today, confirm with Beacon support where that comes from before you map it — Terminal49 returns it as `container.attributes.equipment_type`, `equipment_length` (10, 20, 40, 45), and `equipment_height` (standard, high cube).
</Note>

### Locations, facilities, and vessels

| Beacon field                                   | Terminal49                                                                                                                        |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `port_of_loading.name`                         | `port.attributes.name`                                                                                                            |
| `port_of_loading.un_location_code`             | `port.attributes.code`                                                                                                            |
| `port_of_discharge.name` / `.un_location_code` | Same fields, on the discharge port resource                                                                                       |
| Port coordinates                               | Not returned by Beacon. Terminal49: `port.attributes.latitude` / `.longitude`                                                     |
| Port timezone                                  | Not returned by Beacon. Terminal49: `port.attributes.time_zone`                                                                   |
| Port country                                   | Not returned by Beacon. Terminal49: `port.attributes.country_code`                                                                |
| Terminal name/code                             | Not returned by Beacon. Terminal49: `terminal.attributes.name`, `terminal.attributes.smdg_code` or `bic_facility_code`            |
| `vessel.name`                                  | `shipment.attributes.pod_vessel_name`                                                                                             |
| `vessel.imo_number`                            | `shipment.attributes.pod_vessel_imo`                                                                                              |
| Vessel MMSI, position, extra fields            | Not returned by Beacon. Available on Terminal49 via the [Vessels API](/docs/api-docs/api-reference/vessels/get-a-vessel-using-the-imo) |

## Milestone and event mapping

Beacon returns each milestone as its own date object with `estimated_date` and/or `actual_date`, rather than a single flat events array. Terminal49 exposes the same milestones as normalized transport events and pushes each one to your webhook.

| Beacon date object       | Terminal49 event                        |
| ------------------------ | --------------------------------------- |
| `gated_out_empty_dates`  | `container.transport.empty_out`         |
| `gated_in_full_dates`    | `container.transport.full_in`           |
| `loaded_dates`           | `container.transport.vessel_loaded`     |
| `vessel_departure_dates` | `container.transport.vessel_departed`   |
| `vessel_arrival_dates`   | `container.transport.vessel_arrived`    |
| `discharged_dates`       | `container.transport.vessel_discharged` |
| `gated_out_full_dates`   | `container.transport.full_out`          |
| No equivalent            | `container.transport.empty_in`          |

Terminal49 also emits milestones Beacon has no equivalent for:

* **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 Beacon 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="Scope is ocean only, not air or orders" icon="arrows-left-right">
    Beacon also tracks air waybills (AWBs) and offers order management (creating and updating orders, linking an order to a shipment). Terminal49 does not do either. If your integration uses Beacon for air freight or order management, plan to keep Beacon for those or replace them separately — this guide covers ocean container and BOL tracking only.
  </Accordion>

  <Accordion title="No more token refresh loop" icon="key">
    Beacon's access token expires every 300 seconds, so a real integration ends up with a refresh timer, a token cache, and retry logic around `401`s from an expired token. Terminal49's API key is static — delete all of that. There is no login call, no refresh endpoint, and no expiry to track.
  </Accordion>

  <Accordion title="Usage accounting works differently" icon="receipt">
    Beacon consumes your contracted usage every time a request succeeds and returns tracking data, even for containers you're no longer actively watching — so idle containers you forgot to stop polling still cost you. Terminal49's free plan tracks up to 10 active containers; usage is based on active tracking requests, not per-call lookups, so stopping a tracking request stops it counting.
  </Accordion>

  <Accordion title="Tracking requests are asynchronous" icon="hourglass-half">
    `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, and we retry automatically. See [Tracking Request Lifecycle](/docs/api-docs/in-depth-guides/tracking-request-lifecycle).
  </Accordion>

  <Accordion title="JSON:API structure" icon="code">
    Terminal49 returns JSON:API, not flat JSON. Relationships are ID references into an `included` array. Use a JSON:API client library, or use `include` to sideload exactly what you need. Parsing raw JSON works but you will write more code than you expect.
  </Accordion>

  <Accordion title="Timestamps are UTC with a separate timezone field" icon="clock">
    Beacon's date fields carry a local UTC offset for the event's location, but fall back to a plain `...Z` UTC timestamp if no zone information is available, or a date with no time at all if no time information is available — so parsing has to branch on which shape you got. Terminal49 always stores event timestamps in UTC and returns the matching IANA timezone alongside as a separate field. Convert for display rather than assuming local time. See [Event Timestamps](/docs/api-docs/in-depth-guides/event-timestamps).
  </Accordion>

  <Accordion title="Polling cadence vs. webhooks" icon="arrows-rotate">
    Beacon recommends polling `GET /v1/containers/{containerNumber}` no more than once every 2 hours. If your Beacon integration is polling on a cron, you can either keep the same cadence against `GET /v2/containers`, or drop polling entirely and let Terminal49 push webhook events as they happen — freshness is no longer capped by how often you ask.
  </Accordion>

  <Accordion title="Empty arrays are the normal state" icon="brackets-square">
    `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" icon="code-compare">
    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

Beacon returns HTTP status codes with an error body containing `timestamp`, `error`, and `sub_error` fields. Terminal49 also uses standard HTTP status codes. Replace any Beacon-specific error parsing 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 Beacon status codes you are handling today:

| Beacon status                                             | Terminal49                                                                                     |
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 400 Bad request                                           | HTTP 400 or 422                                                                                |
| 401 Unauthorised (missing or expired token)               | HTTP 401 — but there is no token to expire, since the key is static                            |
| 403 Forbidden                                             | HTTP 403                                                                                       |
| 429 Rate limit exceeded (120/min non-login, 20/min login) | HTTP 429. See [Rate Limiting](/docs/api-docs/in-depth-guides/rate-limiting) for Terminal49's limits |
| No tracking data found                                    | `tracking_request.failed`, or `tracking_request.awaiting_manifest` if not yet manifested       |
| 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 Beacon

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. Beacon advertises coverage across 160+ ocean and air carriers combined. Ours are direct integrations covering the lines that move volume into North America, and each is normalized into one schema. 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 air tracking.** Beacon also tracks air waybills (AWBs). Terminal49 tracks ocean only. If your integration covers air freight, keep it on Beacon or move it elsewhere — this migration handles only the ocean portion.

**No order management.** Beacon lets you create and update orders and link an order to a shipment. Terminal49 does not model orders — it tracks shipments and containers. If your Beacon integration uses order management, keep Beacon for that or replace it separately.

**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 the key to `Authorization: Token`. Note the `Token` prefix. Set `Content-Type: application/vnd.api+json`.
  </Step>

  <Step title="Check your carrier mix">
    Compare your Beacon carrier values against the [carrier list](/docs/coverage/ocean-carriers). Flag anything missing before you cut over.
  </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 Beacon-specific error parsing 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">
    <Steps>
      <Step title="Expose an HTTPS endpoint">
        Accept our POST payloads at a public URL.
      </Step>

      <Step title="Register a webhook">
        Subscribe only to events you act on.
      </Step>

      <Step title="Verify HMAC signatures">
        Reject any payload whose signature does not match.
      </Step>

      <Step title="Whitelist our IPs">
        Only needed if your firewall restricts inbound traffic.
      </Step>

      <Step title="Trigger a test delivery">
        Confirm end-to-end before going live.
      </Step>

      <Step title="Retire your polling job">
        Remove your dedupe layer along with it.
      </Step>
    </Steps>

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

  <Tab title="Polling path">
    <Steps>
      <Step title="Store the tracking request ID">
        The creation response is pending and carries no shipment yet. Keep the tracking request ID, then fetch the tracking request again (or handle `tracking_request.succeeded`) to get the shipment ID once the carrier responds.
      </Step>

      <Step title="Repoint your scheduler">
        Point it at `GET /v2/shipments` or `GET /v2/containers`.
      </Step>

      <Step title="Keep your existing cadence">
        No change to how often you poll.
      </Step>
    </Steps>

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

    <Tip>
      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`.
    </Tip>
  </Tab>
</Tabs>

## Migrate with an AI coding agent

If you use Cursor, Claude Code, 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 Beacon code, shadows every Beacon 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 (Beacon 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 `BeaconClient`, 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 Beacon 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 Beacon code, its login/token-refresh flow, 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 Beacon tracking API to the Terminal49 API,
side by side. Terminal49's authoritative migration guide is at
https://terminal49.com/docs/migrate/beacon. Use it as
the source of truth for field mappings, event names, error codes, and behavior differences.

# Repo context (fill this in before running)

- Beacon client lives at: [path/to/beacon/client.ts]
- Beacon is called from: [list the call sites or "find them"]
- Beacon auth env vars in use today: [e.g. BEACON_USERNAME, BEACON_PASSWORD — Beacon
  logs in via POST /v1/login and refreshes a 300-second access token via
  POST /v1/login/token; note anywhere that refresh timer or token cache lives]
- 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 via BullMQ, matching Beacon's recommended
  minimum interval]
- 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 Beacon 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.

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

## PR 1 — Discovery and interface

- Grep the repo for every Beacon call site. List them in the PR description.
- Extract the Beacon client's public surface into an interface
  (`TrackingProvider` with methods like `track(number, type, carrier)`,
  `getShipment(id)`, `refresh(id)`).
- Make the existing Beacon 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`.
- Normalize responses to the same shape Beacon 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 Beacon 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 `beacon` (default) and `terminal49`.
- Route all reads through the flag. Beacon 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 `BeaconClient`, its tests, its dedupe cache, and any equipment-code parsing
  helpers Terminal49 makes redundant.
- Delete the Beacon login flow entirely: the `POST /v1/login` call, the
  `POST /v1/login/token` refresh call, the token cache, and any refresh timer or
  scheduled job tied to the 300-second expiry. Terminal49's key does not expire, so
  none of this has a replacement — it just goes away.
- Remove `BEACON_USERNAME`, `BEACON_PASSWORD`, and any Beacon token env vars from the
  secret store and deployment config.
- Remove the shadow provider and the feature flag.
- Update README and any runbooks. Note the new webhook endpoint if applicable.

# Definition of done

- All Beacon 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.
- Beacon 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 Beacon 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.

Start with PR 1. Post the list of Beacon call sites and the proposed
`TrackingProvider` interface, and wait for my review before writing PR 2.
```

<Note>
  The prompt is deliberately opinionated on side-by-side migration and small PRs. If your team prefers a big-bang cutover or a different branching model, edit the **Plan** section before sending it to your agent.
</Note>

## 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>
