# AGENTS
Source: https://terminal49.com/docs/AGENTS
# Documentation agent instructions
These instructions guide automated changes for the Terminal49 docs in this repository.
## Scope
* Primary docs live in `docs/` (MDX pages, `docs/docs.json`, and `docs/openapi.json`).
* Do not edit generated files unless explicitly asked (e.g., `Terminal49-API.postman_collection.json`).
## Audience focus
* Primary persona: integration engineers at BCOs/shippers/exporters who may not know logistics terms.
* Secondary personas: logistics operators and decision-makers who know the domain but are less technical.
* Each page should be laser-focused on one persona and one goal.
## Content goals by section
* Getting Started: tutorial-style onboarding and first success within 30 minutes.
* In Depth Guides: how-to and explanation content for workflows and best practices.
* Useful Info: explanation/FAQ content that supports decisions and integrations.
* API Reference: reference-only endpoint lookups, no narrative.
For substantive documentation writing, use the repo-local skill at `../skills/terminal49-docs-writing/SKILL.md`. It includes content-type-specific guides for tutorials, how-to guides, reference pages, concepts, webhook/event docs, SDK/MCP docs, DataSync/coverage docs, changelog/update posts, and navigation.
## Voice and terminology
* Sound like a domain expert but stay friendly and easy to understand.
* Use active voice and second person ("you").
* Use consistent product terms: "Terminal49", "tracking request", "shipment", "container", "webhook".
* Define acronyms on first use (e.g., Bill of Lading (BOL)); link to a glossary if available.
* Approved positioning phrases (use where relevant, do not invent new claims):
* Automated Container Tracking API
* Tracking shipments and containers from empty-out at the origin to empty-return at the destination
* Single API to track bill of ladings, bookings, and container numbers with global coverage
* Complete import milestones in North America including rail data
## API and code examples
* Base URL is `https://api.terminal49.com/v2` unless a page says otherwise.
* Examples should be realistic but safe (no real keys, emails, or customer data).
* Use JSON with 2-space indentation; label code fences (e.g., `json, `bash, \`\`\`json http).
* Prefer copy-pasteable snippets with complete headers.
* For auth examples, use `Authorization: Token YOUR_API_KEY`.
## When updating API reference
* If you change API behavior or schemas, update `docs/openapi.json` first.
* Regenerate the Postman collection with:
`openapi2postmanv2 -s docs/openapi.json -o Terminal49-API.postman_collection.json -p -O folderStrategy=Tags`
## MDX conventions
* Every page must include frontmatter with a `title`.
* Use Mintlify components like `` or `` sparingly for emphasis.
* Keep headings concise and action-oriented.
# Create container custom field
Source: https://terminal49.com/docs/api-docs/api-reference/containers/create-container-custom-field
post /containers/{container_id}/custom_fields
Create or update a custom field value on a container in the Terminal49 API. Attach internal metadata like reference codes, priority, or cost centers.
Creates or updates a custom field on a container. If a custom field with the specified `api_slug` already exists, it will be updated.
## Path parameters
| Parameter | Required | Description |
| -------------- | -------- | ----------------------- |
| `container_id` | Yes | The ID of the container |
## Request body
| Parameter | Required | Description |
| -------------------------- | -------- | ------------------------------------------------------------- |
| `data.type` | Yes | Must be `custom_field` |
| `data.attributes.api_slug` | Yes | The slug of the custom field definition |
| `data.attributes.value` | Yes | The value to set (type depends on the definition's data type) |
The container is implied by the path, so do not send `data.relationships.entity` on this endpoint.
## Authorization
Requires `update` permission on the container.
## Response
Returns `201 Created` with the custom field resource on success.
## Behavior
* Uses `find_or_initialize_by` internally, so it creates if missing or updates if it exists
* Values are validated against the definition's data type
* For enum fields, values are validated against the definition's options
## Example request
```json theme={null}
{
"data": {
"type": "custom_field",
"attributes": {
"api_slug": "customer_reference_number",
"value": "ABC124"
}
}
}
```
## Example response
```json theme={null}
{
"data": {
"id": "YOUR_CUSTOM_FIELD_ID",
"type": "custom_field",
"attributes": {
"api_slug": "customer_reference_number",
"value": "ABC124",
"display_value": "ABC124"
},
"relationships": {
"entity": {
"data": {
"id": "YOUR_CONTAINER_ID",
"type": "container"
}
},
"definition": {
"data": {
"id": "YOUR_DEFINITION_ID",
"type": "custom_field_definition"
}
}
}
}
}
```
# Delete container custom field
Source: https://terminal49.com/docs/api-docs/api-reference/containers/delete-container-custom-field
delete /containers/{container_id}/custom_fields/{api_slug}
Remove a custom field value from a container in the Terminal49 API by referencing the custom field definition's api_slug for that container record.
Deletes a specific custom field from a container by its `api_slug`.
## Path parameters
| Parameter | Required | Description |
| -------------- | -------- | -------------------------------------------- |
| `container_id` | Yes | The ID of the container |
| `api_slug` | Yes | The api\_slug of the custom field definition |
## Authorization
Requires `update` permission on the container.
## Response
Returns `204 No Content` on success.
# Edit a container
Source: https://terminal49.com/docs/api-docs/api-reference/containers/edit-a-container
patch /containers
Update editable container attributes in the Terminal49 API, including weight, seal number, and reference fields, on active or completed shipments.
# Get a container
Source: https://terminal49.com/docs/api-docs/api-reference/containers/get-a-container
get /containers/{id}
Retrieve a single container's full record from the Terminal49 API, including status, holds, fees, and last free day, using its container resource ID.
# Get a container's raw events
Source: https://terminal49.com/docs/api-docs/api-reference/containers/get-a-containers-raw-events
get /containers/{id}/raw_events
Retrieve raw, carrier-sourced container events from the Terminal49 API. Note: deprecated — use transport events for normalized milestone data instead.
This endpoint is deprecated. For past milestone data, use [Get a container's transport events](/docs/api-docs/api-reference/containers/get-a-containers-transport-events).
Returns past and estimated future milestones for a container as reported by the carrier. The `event` and `timestamp` attributes are normalized where possible; other attributes are passed through as-is. Not every value in `event` has a normalized name — container movement events are usually normalized, but exceptions occur.
The response includes an `original_event` field with the raw carrier event name, which is not present on `transport_events`.
## `raw_events` vs `transport_events`
| | `raw_events` | `transport_events` |
| ------------------------- | ------------------------------------------------------------------ | ----------------------------------------------------------- |
| Status | Deprecated | Recommended |
| Source | Carrier feed, mostly as-is | Vetted and normalized across carriers |
| Estimated events | Any event type can be estimated, flagged by `attributes.estimated` | Only three normalized `estimated.*` event types (see below) |
| Includes `original_event` | Yes | No |
| Data quality | More events, less consistent normalization | Fewer events, higher confidence |
Use `transport_events` for past milestones. Use `raw_events` only when you need the carrier's original event name or estimated events that have no `transport_events` equivalent.
## Estimated events
In `raw_events`, any event type can carry an estimated timestamp. The `attributes.estimated` boolean is `true` when the timestamp is an estimate, so estimated feeder, rail, and transshipment events appear here.
In `transport_events`, estimates are separate event types, and only three exist:
* `container.transport.estimated.vessel_departed`
* `container.transport.estimated.vessel_arrived`
* `container.transport.estimated.arrived_at_inland_destination`
There are no estimated equivalents for feeder, rail, or transshipment events in `transport_events`. If you need estimated timestamps for those milestones, `raw_events` is currently the only endpoint that provides them.
# Get a container's transport events
Source: https://terminal49.com/docs/api-docs/api-reference/containers/get-a-containers-transport-events
get /containers/{id}/transport_events
List normalized transport events for a single container — vessel discharge, gate-out, rail ramp arrival, and more — from the Terminal49 tracking API.
This endpoint returns the container's normalized transport event history — vessel, rail, transshipment, terminal/gate, delivery, and document events — across the entire journey. It does not accept filters for event type, data source, or timestamp. To narrow results to a subset (for example, terminal-only events such as `full_in`, `full_out`, `empty_out`, `empty_in`, `vessel_discharged`, `vessel_arrived`, or `vessel_berthed`), fetch the list and filter client-side on `attributes.event` or `attributes.data_source`.
## Pagination
The response follows the standard Terminal49 pagination shape: a `links` object (`self`, `current`, `next`, `prev`, `last`) and a `meta` object with `size` (page size) and `total` (total events for the container). Most containers have far fewer transport events than one page, so `links.next` is usually absent and a single request returns the full history.
When a container does have more events than fit on one page, follow `links.next` until it is absent. Do not construct pagination URLs by hand — use the URLs returned in `links`.
## Event types
The `attributes.event` field is one of a fixed set of normalized event names covering vessel, rail, transshipment, feeder, terminal/gate, inland-destination, availability, and delivery milestones. The complete enum is defined on the `transport_event` schema in the OpenAPI spec and mirrored in the [Webhook Event Catalog](/docs/api-docs/webhooks/event-catalog), which describes what each event means.
The `attributes.data_source` field indicates where the event originated: `shipping_line`, `terminal`, or `ais`.
### Estimated event types
Three estimated event types can appear in the response:
* `container.transport.estimated.vessel_departed`
* `container.transport.estimated.vessel_arrived`
* `container.transport.estimated.arrived_at_inland_destination`
These are the only estimated events in `transport_events`. Feeder, rail, and transshipment events exist only as actual milestones here. For estimated timestamps on those event types, use the deprecated [raw events endpoint](/docs/api-docs/api-reference/containers/get-a-containers-raw-events), which flags estimates with an `attributes.estimated` boolean on any event type.
## Null locations and timezones
Some events may have a `null` `location_locode` and `timezone` — most commonly on estimated events. See [Event Timestamps](/docs/api-docs/in-depth-guides/event-timestamps) for details on how to interpret those timestamps.
## `transport_events` vs `raw_events`
`transport_events` is the recommended endpoint for milestone data. Events are normalized across carriers and go through additional vetting to reduce false positives. `raw_events` is deprecated and returns the carrier feed as-is; it contains more events but with less consistent normalization. See [Get a container's raw events](/docs/api-docs/api-reference/containers/get-a-containers-raw-events) for details.
# Get container map GeoJSON
Source: https://terminal49.com/docs/api-docs/api-reference/containers/get-container-map-geojson
get /containers/{id}/map_geojson
Retrieve a GeoJSON FeatureCollection for a container with port locations, current vessel position, past path, and estimated future route in one call.
This endpoint returns a GeoJSON FeatureCollection containing all map-related data for a container in a single response. The response includes port locations, current vessel position (if at sea), past vessel paths, and estimated future routes.
For detailed documentation on the response structure, feature types, and their properties, see the [Container Map GeoJSON Data guide](/docs/api-docs/in-depth-guides/routing).
# List container custom fields
Source: https://terminal49.com/docs/api-docs/api-reference/containers/list-container-custom-fields
get /containers/{container_id}/custom_fields
List every custom field value attached to a container in the Terminal49 API, including the api_slug, current value, and resolved option for enum fields.
Lists all custom fields attached to a specific container.
## Path parameters
| Parameter | Required | Description |
| -------------- | -------- | ----------------------- |
| `container_id` | Yes | The ID of the container |
## Authorization
Requires `show` permission on the container.
## Response
Returns a JSONAPI array of custom field resources including:
* `value` - The raw stored value
* `display_value` - Formatted value for display
* Relationships to the definition and user who last updated the field
# List containers
Source: https://terminal49.com/docs/api-docs/api-reference/containers/list-containers
get /containers
List all containers in your Terminal49 account with filters for status, shipment, last free day, and pagination cursors for large result sets.
# Refresh a container
Source: https://terminal49.com/docs/api-docs/api-reference/containers/refresh-container
patch /containers/{id}/refresh
Force an immediate data refresh for a container in the Terminal49 API, pulling new status, milestones, holds, and last free day data from all sources.
# Update container custom field
Source: https://terminal49.com/docs/api-docs/api-reference/containers/update-container-custom-field
patch /containers/{container_id}/custom_fields/{api_slug}
Update an existing custom field value on a container in the Terminal49 API, identified by the custom field definition's api_slug for that container.
Updates a specific custom field on a container by its `api_slug`.
## Path parameters
| Parameter | Required | Description |
| -------------- | -------- | -------------------------------------------- |
| `container_id` | Yes | The ID of the container |
| `api_slug` | Yes | The api\_slug of the custom field definition |
## Request body
| Parameter | Required | Description |
| --------- | -------- | -------------------- |
| `value` | Yes | The new value to set |
## Authorization
Requires `update` permission on the container.
## Response
Returns `200 OK` with the updated custom field resource on success.
# Create a custom field
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/create-a-custom-field
post /custom_fields
Create a custom field value record in the Terminal49 API, attaching a definition and value to a target shipment, container, or other supported resource.
Use this endpoint to create a custom field value on a shipment or container when you need to send the full JSON:API relationship payload yourself. The field must reference an existing custom field definition.
## Request body
| Parameter | Required | Description |
| ------------------------------------- | -------- | ------------------------------------------------------- |
| `data.type` | Yes | Must be `custom_field` |
| `data.attributes.api_slug` | Yes | The slug of the custom field definition |
| `data.attributes.value` | Yes | The field value (must match the definition's data type) |
| `data.relationships.entity.data.type` | Yes | `shipment` or `container` |
| `data.relationships.entity.data.id` | Yes | The shipment or container ID |
## Value formats by data type
| Data type | Expected value format |
| ------------ | --------------------------------------------------------------------------------------------------- |
| `short_text` | Any string |
| `number` | Numeric value |
| `date` | Date string (parsed using definition's `default_format` or flexible parsing) |
| `datetime` | DateTime string |
| `boolean` | `true` or `false` |
| `enum` | String matching one of the definition's option values |
| `enum_multi` | Array of strings matching the definition's option values |
| `reference` | Object identifying the referenced record, for example `{ "type": "shipment", "id": "SHIPMENT_ID" }` |
## Validation
* Values are validated against the definition's data type
* Enum values must match one of the definition's configured options
* Reference values must match the definition's configured `reference_type`
* The `api_slug` must reference a definition belonging to your account or a Terminal49 template
## Example request
```json theme={null}
{
"data": {
"type": "custom_field",
"attributes": {
"api_slug": "customer_reference_number",
"value": "ABC124"
},
"relationships": {
"entity": {
"data": {
"type": "shipment",
"id": "YOUR_SHIPMENT_ID"
}
}
}
}
}
```
## Example response
```json theme={null}
{
"data": {
"id": "YOUR_CUSTOM_FIELD_ID",
"type": "custom_field",
"attributes": {
"api_slug": "customer_reference_number",
"value": "ABC124",
"display_value": "ABC124"
},
"relationships": {
"entity": {
"data": {
"id": "YOUR_SHIPMENT_ID",
"type": "shipment"
}
},
"definition": {
"data": {
"id": "YOUR_DEFINITION_ID",
"type": "custom_field_definition"
}
}
}
}
}
```
# Create a custom field definition
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/create-a-custom-field-definition
post /custom_field_definitions
Create a new custom field definition in the Terminal49 API to attach structured metadata like reference codes or enums to shipments and containers.
Create a custom field definition to describe metadata you want to store on shipments or containers.
## Request body
| Parameter | Required | Description |
| ---------------- | -------- | ------------------------------------------------------------------------------- |
| `entity_type` | Yes | The entity type this field applies to (`Shipment` or `Container`) |
| `api_slug` | Yes | Unique identifier for the field |
| `display_name` | Yes | Human-readable name for the field |
| `data_type` | Yes | Data type for values (for example: `short_text`, `number`, `date`, `reference`) |
| `description` | No | Optional description of the field's purpose |
| `validation` | No | Validation rules (for example: `required`, `pattern`, `max_length`) |
| `default_format` | No | Default format string for numbers or dates |
| `default_value` | No | Default value for new custom fields |
| `reference_type` | No | Required when `data_type` is `reference` |
# Create a custom field option
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/create-a-custom-field-option
post /custom_field_definitions/{definition_id}/options
Add an enum option to a custom field definition in the Terminal49 API so users can select it when applying the custom field to shipments or containers.
Create a new option for an `enum` or `enum_multi` custom field definition.
## Path parameters
| Parameter | Description |
| --------------- | ---------------------------------------------------- |
| `definition_id` | The unique identifier of the custom field definition |
## Request body
| Parameter | Required | Description |
| ---------- | -------- | ------------------------------------ |
| `label` | Yes | Display label shown to users |
| `value` | Yes | Stored value (unique per definition) |
| `position` | No | Sort order for the option |
## Notes
Options can only be added to definitions with `data_type` of `enum` or `enum_multi`.
# Delete a custom field
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/delete-a-custom-field
delete /custom_fields/{id}
Delete a custom field value record from the Terminal49 API. The custom field definition is preserved for use on other shipments and containers.
Use this endpoint to delete a custom field value from a shipment or container.
## Path parameters
| Parameter | Description |
| --------- | --------------------------------------------------------- |
| `id` | The unique identifier of the custom field value to delete |
## Behavior
* The custom field value is removed from the associated entity
* Deleting a custom field value does not affect the underlying custom field definition
# Delete a custom field definition
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/delete-a-custom-field-definition
delete /custom_field_definitions/{id}
Delete a custom field definition from your Terminal49 account, removing the field type and its applied values from shipments and containers permanently.
Delete a custom field definition by its ID.
## Path parameters
| Parameter | Description |
| --------- | ---------------------------------------------------- |
| `id` | The unique identifier of the custom field definition |
## Behavior
Deleting a custom field definition also removes all associated custom field values.
# Delete a custom field option
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/delete-a-custom-field-option
delete /custom_field_definitions/{definition_id}/options/{option_id}
Remove an enum option from a custom field definition in the Terminal49 API. Existing records using the deleted option are cleared on next save.
Delete a custom field option by its ID.
## Path parameters
| Parameter | Description |
| --------------- | ---------------------------------------------------- |
| `definition_id` | The unique identifier of the custom field definition |
| `option_id` | The unique identifier of the option |
## Notes
Deleting an option does not automatically update existing custom field values that reference it.
# Get a custom field
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/get-a-custom-field
get /custom_fields/{id}
Retrieve a single custom field value record from the Terminal49 API, including the resolved value, related definition slug, and parent resource reference.
Use this endpoint to retrieve a single custom field value by its ID.
## Path parameters
| Parameter | Description |
| --------- | ----------------------------------------------- |
| `id` | The unique identifier of the custom field value |
## Response
The response includes:
* `value` - The raw stored value (type depends on the field's data type)
* `display_value` - Human-readable formatted value
* Relationships to the associated entity (shipment or container), definition, and the user who last updated it
## Data types
Custom fields support these data types, each with specific value handling:
| Data type | Storage | Display format |
| ------------ | --------------------------------- | -------------------------------------- |
| `short_text` | String | As-is |
| `number` | Decimal (precision: 18, scale: 6) | Formatted per `default_format` |
| `date` | Date | `YYYY-MM-DD` or custom format |
| `datetime` | DateTime | `YYYY-MM-DD HH:MM:SS` or custom format |
| `boolean` | Boolean | `Yes` or `No` |
| `enum` | String | Option label |
| `enum_multi` | Comma-separated string | Comma-separated labels |
# Get a custom field definition
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/get-a-custom-field-definition
get /custom_field_definitions/{id}
Retrieve a single custom field definition from the Terminal49 API, including its data type, slug, target resource type, and configured enum options.
Use this endpoint to retrieve a single custom field definition by its ID.
## Path parameters
| Parameter | Description |
| --------- | ---------------------------------------------------- |
| `id` | The unique identifier of the custom field definition |
# Get a custom field option
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/get-a-custom-field-option
get /custom_field_definitions/{definition_id}/options/{option_id}
Retrieve a single enum option for a custom field definition from the Terminal49 API, including the option's display label and persisted value identifier.
Retrieve a single custom field option by its ID.
## Path parameters
| Parameter | Description |
| --------------- | ---------------------------------------------------- |
| `definition_id` | The unique identifier of the custom field definition |
| `option_id` | The unique identifier of the option |
# List custom field definitions
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/list-custom-field-definitions
get /custom_field_definitions
List every custom field definition configured for your Terminal49 account, including data type, slug, and the target resource each definition applies to.
List all custom field definitions available to your account.
## Query filters
| Filter | Description |
| ---------------------- | ------------------------------------------------- |
| `filter[entity_type]` | Filter by entity type (`Shipment` or `Container`) |
| `filter[data_type]` | Filter by data type |
| `filter[display_name]` | Filter by display name (prefix match) |
# List custom field options
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/list-custom-field-options
get /custom_field_definitions/{definition_id}/options
List every enum option configured for a custom field definition in the Terminal49 API. Use this to populate selectors in your tracking UI or app.
List all options for a custom field definition.
## Path parameters
| Parameter | Description |
| --------------- | ---------------------------------------------------- |
| `definition_id` | The unique identifier of the custom field definition |
# List custom fields
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/list-custom-fields
get /custom_fields
List custom field value records across your account in the Terminal49 API, with filters for definition slug, target resource type, and resource ID.
Use this endpoint to retrieve custom field values attached to your shipments and containers. Custom fields let you store additional metadata on entities to support your business workflows.
## Query filters
Filter results using these query parameters:
| Filter | Description |
| ----------------------- | ------------------------------------------------- |
| `filter[entity_type]` | Filter by entity type (`Shipment` or `Container`) |
| `filter[entity_id]` | Filter by the ID of the shipment or container |
| `filter[definition_id]` | Filter by custom field definition ID |
## Response
The response includes:
* `value` - The raw stored value
* `display_value` - Formatted value for display (e.g., formatted numbers, date strings, enum labels)
* Relationships to the entity, definition, and user who last updated the field
# Update a custom field
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/update-a-custom-field
patch /custom_fields/{id}
Update the stored value on a custom field record in the Terminal49 API. The associated definition slug and parent resource reference remain unchanged.
Use this endpoint to update an existing custom field value.
## Path parameters
| Parameter | Description |
| --------- | --------------------------------------------------------- |
| `id` | The unique identifier of the custom field value to update |
## Request body
| Parameter | Required | Description |
| --------- | -------- | ----------------------------------------------------------- |
| `value` | Yes | The new field value (must match the definition's data type) |
## Behavior
* The new value is validated against the field definition's data type
* For enum fields, the value must match one of the definition's configured options
* The `updated_by` user is recorded for audit purposes
* Update pathway tracking records the source of the change
# Update a custom field definition
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/update-a-custom-field-definition
patch /custom_field_definitions/{id}
Update a custom field definition in the Terminal49 API, including its display label, data type constraints, and enum option set for shipments or containers.
Update an existing custom field definition.
## Path parameters
| Parameter | Description |
| --------- | ---------------------------------------------------- |
| `id` | The unique identifier of the custom field definition |
## Request body
Provide the fields you want to update, such as `display_name`, `description`, `validation`, or `default_format`.
## Notes
You cannot change `api_slug`, `entity_type`, or `data_type` after creation.
# Update a custom field option
Source: https://terminal49.com/docs/api-docs/api-reference/custom-fields/update-a-custom-field-option
patch /custom_field_definitions/{definition_id}/options/{option_id}
Update an enum option on a custom field definition in the Terminal49 API. Change the option's display label without invalidating records that reference it.
Update an existing custom field option.
## Path parameters
| Parameter | Description |
| --------------- | ---------------------------------------------------- |
| `definition_id` | The unique identifier of the custom field definition |
| `option_id` | The unique identifier of the option |
## Request body
Provide the fields you want to update, such as `label` or `position`.
# Document representations resource
Source: https://terminal49.com/docs/api-docs/api-reference/document-representations/document-representations-resource
Understand the Terminal49 document_representation resource — how extracted document payloads appear via include parameters and document webhook events.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
`document_representation` is a **resource type**, not a standalone endpoint.
You receive it through:
* document includes: `include=last_document_representation`
* email submission nested includes: `include=documents.last_document_representation`
* document webhook payloads (`document_representation.created`, `document_representation.failed`) in `included`
## Resource shape
* `type`: `document_representation`
* `attributes.schema_version`: public schema version string
* `attributes.payload`: extracted key/value payload object
* `attributes.created_at`
* `attributes.updated_at`
## Where to fetch related schemas
Use [`GET /document_schemas/{id}`](/docs/api-docs/api-reference/document-schemas/get-a-document-schema) to retrieve schema metadata and payload contracts for document extraction outputs.
# Get a document schema
Source: https://terminal49.com/docs/api-docs/api-reference/document-schemas/get-a-document-schema
get /document_schemas/{id}
Retrieve a Terminal49 document schema for an extraction output, including the schema version and the structured payload contract for parsed fields.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# Delete a document
Source: https://terminal49.com/docs/api-docs/api-reference/documents/delete-a-document
delete /documents/{id}
Soft-delete (discard) a document record in the Terminal49 API. The underlying file is retained for compliance, but the record is hidden from list endpoints.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# Edit a document
Source: https://terminal49.com/docs/api-docs/api-reference/documents/edit-a-document
patch /documents/{id}
Update manual extraction fields and classification metadata on a document in the Terminal49 API, including document type and operator-edited values.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# Get a document
Source: https://terminal49.com/docs/api-docs/api-reference/documents/get-a-document
get /documents/{id}
Retrieve a single document record from the Terminal49 API, including classification, extraction status, and links to the file blob and last representation.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# Get a document download URL
Source: https://terminal49.com/docs/api-docs/api-reference/documents/get-a-document-download-url
get /documents/{id}/download_url
Get a presigned download URL for a document file in the Terminal49 API to view, share, or stream the original file securely without proxying through your app.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# List document types
Source: https://terminal49.com/docs/api-docs/api-reference/documents/list-document-types
get /documents/types
List the allowed document types and labels configured for your Terminal49 account so your app can populate selectors and validate uploads correctly.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# List documents
Source: https://terminal49.com/docs/api-docs/api-reference/documents/list-documents
get /documents
List documents from the Terminal49 API with filters for shipment, container, document type, and extraction status, plus sorting and include parameters.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# Re-classify a document
Source: https://terminal49.com/docs/api-docs/api-reference/documents/re-classify-a-document
post /documents/{id}/reclassify
Trigger asynchronous re-classification of a document in the Terminal49 API to recompute the document type using the latest classification model output.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# Re-extract a document
Source: https://terminal49.com/docs/api-docs/api-reference/documents/re-extract-a-document
post /documents/{id}/reextract
Trigger asynchronous re-extraction of a document in the Terminal49 API to refresh structured field values using the latest extraction model and schema.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# Re-link a document
Source: https://terminal49.com/docs/api-docs/api-reference/documents/re-link-a-document
post /documents/{id}/relink
Re-run reference linking on a document in the Terminal49 API so updated bill of lading, booking, or container numbers attach to the right shipment record.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# Rotate a document
Source: https://terminal49.com/docs/api-docs/api-reference/documents/rotate-a-document
post /documents/{id}/rotate
Queue a rotation update for an image-based document in the Terminal49 API. PDFs and other non-image document types are not supported by this endpoint.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
After rotation is accepted, request [`GET /documents/{id}/download_url`](/docs/api-docs/api-reference/documents/get-a-document-download-url) again to retrieve the updated image.
# Upload a document
Source: https://terminal49.com/docs/api-docs/api-reference/documents/upload-a-document
post /documents
Create a document record in the Terminal49 API by attaching an ActiveStorage signed blob ID. Triggers downstream classification and field extraction.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but the schema and behavior may evolve based on feedback.
# Get an email submission
Source: https://terminal49.com/docs/api-docs/api-reference/email-submissions/get-an-email-submission
get /email_submissions/{id}
Retrieve a single email submission from the Terminal49 API, including parsed sender metadata, attached documents, and reference linking results.
# List email submissions
Source: https://terminal49.com/docs/api-docs/api-reference/email-submissions/list-email-submissions
get /email_submissions
List inbound email submissions to your Terminal49 account with filters and includes, useful for auditing parsed documents and reference linking outcomes.
# Introduction
Source: https://terminal49.com/docs/api-docs/api-reference/introduction
Complete REST API reference for tracking ocean shipments and containers. Covers BOL, booking, and container number endpoints with real-time webhooks.
The Terminal49 API gives you a single integration to track Bills of Lading (BOLs), bookings, and container numbers across global ocean carriers. You get complete import milestones — from empty-out at origin to empty-return at destination, including rail data in North America.
## Prerequisites
Before making API calls, you need:
* A Terminal49 account ([start a free trial](https://app.terminal49.com/register))
* An API key from the [Developer Portal](https://app.terminal49.com/developers/api-keys)
## Base URL
```
https://api.terminal49.com/v2
```
## Authentication
Include your API key in the `Authorization` header, prefixed with `Token` (not `Bearer`):
```bash theme={null}
curl https://api.terminal49.com/v2/shipments \
-H "Authorization: Token YOUR_API_KEY"
```
Replace `YOUR_API_KEY` with the raw key value from the [developer portal](https://app.terminal49.com/developers/api-keys) — no quotes, no `Bearer` prefix, and no extra whitespace. A `401 Unauthorized` response with `"Terminal49 API key could not be verified"` means the key is missing, malformed, revoked, or truncated when it was copied.
The full API key value is only shown once, right after you create it. Copy the complete token from that screen and store it in a secret manager before navigating away — after that, the value is masked and cannot be revealed again. If you lost the full value, create a new key, copy it immediately, then delete the older key.
### Restricted API access on Free plans
Free-plan accounts can authenticate and create tracking requests, but read endpoints are gated. A valid key on a restricted account returns `401 Unauthorized` with `"You do not have permissions for using the API, except for creating tracking requests. All other permissions require a paid plan. See https://app.terminal49.com/settings/billing"` when you call any endpoint other than `POST /v2/tracking_requests`.
To read tracking data (`GET /v2/shipments`, `GET /v2/containers`, `GET /v2/tracking_requests/{id}`, and other endpoints), your account needs full API access enabled. Full API access is not automatic on the Free plan. Contact [support@terminal49.com](mailto:support@terminal49.com) to enable a 7-day API trial, or see [Pricing](/docs/api-docs/useful-info/pricing) for plans that include ongoing API read access.
## Request and response format
* The API follows the [JSON:API](https://jsonapi.org/) specification
* All responses return `application/vnd.api+json` content type
* Requests that include a body should set `Content-Type: application/vnd.api+json`
* [JSON:API client libraries](https://jsonapi.org/implementations/#client-libraries) are available in most languages
### Example response
```json theme={null}
{
"data": {
"id": "dabf9e1c-4ddc-4e4b-a701-2de1cdb38010",
"type": "shipment",
"attributes": {
"status": "in_transit",
"pod_eta": "2025-04-15T00:00:00Z",
"shipping_line_name": "Maersk"
}
}
}
```
## Rate limits
| Detail | Value |
| ------------------- | ------------------------ |
| Default limit | 100 requests per minute |
| Scope | Per API key/account |
| Window | Rolling 60 seconds |
| Over-limit response | `429 Too Many Requests` |
| Retry header | `Retry-After` in seconds |
Some endpoints have their own rate-limit bucket. For example, Infer Tracking Number allows 200 requests per minute, Create Tracking Request allows 100 requests per minute, and Refresh Container allows 10 requests per minute. See the [rate limiting guide](/docs/api-docs/in-depth-guides/rate-limiting) for endpoint-specific limits, retry semantics, and best practices.
Use [webhooks](/docs/api-docs/in-depth-guides/webhooks) instead of polling to receive real-time updates. This keeps you well within rate limits and gives you faster data.
## Core resources
Create and manage tracking requests by BOL, booking, or container number.
Retrieve shipment details, ETAs, and milestones.
Container-level statuses, transport events, and map data.
Subscribe to real-time push notifications when shipment data changes.
## Supporting resources
Attach your own metadata to shipments and containers.
Look up supported carriers and SCACs.
Vessel details and future position data.
Port lookups by UN/LOCODE.
Terminal details at destination ports.
Manage customer and partner references.
# Get a metro area
Source: https://terminal49.com/docs/api-docs/api-reference/metro-areas/get-a-metro-area-using-the-unlocode-or-the-id
get /metro_areas/{id}
Retrieve a single metro area from the Terminal49 API by metro area ID or by UN/LOCODE so you can resolve city and region metadata for shipments.
# Create a party
Source: https://terminal49.com/docs/api-docs/api-reference/parties/create-a-party
post /parties
Create a party in the Terminal49 API. Use its ID as the customer on a tracking request or assign it to shipments and containers via party roles.
# Edit a party
Source: https://terminal49.com/docs/api-docs/api-reference/parties/edit-a-party
patch /parties/{id}
Update the company name of an existing party in the Terminal49 API with a PATCH request. Roles already assigned to the party remain unchanged.
# Get a party
Source: https://terminal49.com/docs/api-docs/api-reference/parties/get-a-party
get /parties/{id}
Retrieve a single party by ID from the Terminal49 API. The response returns the company name for the party record stored in your account.
# List parties
Source: https://terminal49.com/docs/api-docs/api-reference/parties/list-parties
get /parties
List the parties in your Terminal49 account. Parties are companies you assign to tracking requests, shipments, and containers through party roles.
# Assign a container party role
Source: https://terminal49.com/docs/api-docs/api-reference/party-roles/assign-a-container-party-role
post /containers/{container_id}/party_roles
Assign a party to a container in the Terminal49 API as pickup dray carrier. Roles are additive, and duplicate party-role pairs return a 422 error.
Attaches a party from your account to the container in one role.
## Path parameters
| Parameter | Required | Description |
| -------------- | -------- | ----------------------- |
| `container_id` | Yes | The ID of the container |
## Roles
`pickup_dray_carrier` only
## Behavior
* Roles are additive. Posting a second party with the same role keeps both
* To replace a party, delete its role and create a new one
* Posting the same party and role twice returns `422`
* The party must belong to your account, otherwise the request returns `401`
* Any other role returns `422`
# Assign a shipment party role
Source: https://terminal49.com/docs/api-docs/api-reference/party-roles/assign-a-shipment-party-role
post /shipments/{shipment_id}/party_roles
Assign a party to a shipment in the Terminal49 API as shipper, consignee, notify party, customs broker, customer, freight forwarder, or dray carrier.
Attaches a party from your account to the shipment in one role.
## Path parameters
| Parameter | Required | Description |
| ------------- | -------- | ---------------------- |
| `shipment_id` | Yes | The ID of the shipment |
## Roles
`shipper`, `consignee`, `notify_party`, `customs_broker`, `customer`, `freight_forwarder`, `pickup_dray_carrier`
## Behavior
* Roles are additive. Posting a second party with the same role keeps both
* To replace a party, delete its role and create a new one
* Posting the same party and role twice returns `422`
* The party must belong to your account, otherwise the request returns `401`
# List container party roles
Source: https://terminal49.com/docs/api-docs/api-reference/party-roles/list-container-party-roles
get /containers/{container_id}/party_roles
List the party roles on a container in the Terminal49 API. Returns each role, such as pickup dray carrier, with the linked party in included.
Returns every party role on the container. The linked parties are returned in `included`.
## Path parameters
| Parameter | Required | Description |
| -------------- | -------- | ----------------------- |
| `container_id` | Yes | The ID of the container |
# List shipment party roles
Source: https://terminal49.com/docs/api-docs/api-reference/party-roles/list-shipment-party-roles
get /shipments/{shipment_id}/party_roles
List the party roles on a shipment in the Terminal49 API. Returns each role, such as shipper or consignee, with the linked party in included.
Returns every party role on the shipment. The linked parties are returned in `included`.
## Path parameters
| Parameter | Required | Description |
| ------------- | -------- | ---------------------- |
| `shipment_id` | Yes | The ID of the shipment |
# Remove a container party role
Source: https://terminal49.com/docs/api-docs/api-reference/party-roles/remove-a-container-party-role
delete /containers/{container_id}/party_roles/{id}
Remove a party role from a container in the Terminal49 API. Detaches the party from the container while keeping the party record in your account.
Detaches the party from the container. The party itself is not deleted.
## Path parameters
| Parameter | Required | Description |
| -------------- | -------- | ------------------------------------------------ |
| `container_id` | Yes | The ID of the container |
| `id` | Yes | The ID of the party role, from the list endpoint |
# Remove a shipment party role
Source: https://terminal49.com/docs/api-docs/api-reference/party-roles/remove-a-shipment-party-role
delete /shipments/{shipment_id}/party_roles/{id}
Remove a party role from a shipment in the Terminal49 API. Detaches the party from the shipment while keeping the party record in your account.
Detaches the party from the shipment. The party itself is not deleted.
## Path parameters
| Parameter | Required | Description |
| ------------- | -------- | ------------------------------------------------ |
| `shipment_id` | Yes | The ID of the shipment |
| `id` | Yes | The ID of the party role, from the list endpoint |
# Get a port
Source: https://terminal49.com/docs/api-docs/api-reference/ports/get-a-port-using-the-locode-or-the-id
get /ports/{id}
Retrieve a single port from the Terminal49 API by port ID or UN/LOCODE so you can resolve port names, country codes, and timezones for ocean shipments.
# Search
Source: https://terminal49.com/docs/api-docs/api-reference/search/search
get /search
Full-text search across shipments, containers, and tracking requests in your Terminal49 account by BL number, container number, or reference number.
# Create shipment custom field
Source: https://terminal49.com/docs/api-docs/api-reference/shipments/create-shipment-custom-field
post /shipments/{shipment_id}/custom_fields
Create or update a custom field value on a shipment in the Terminal49 API. Attach internal metadata like PO numbers, customer codes, or workflow tags.
Creates or updates a custom field on a shipment. If a custom field with the specified `api_slug` already exists, it will be updated.
## Path parameters
| Parameter | Required | Description |
| ------------- | -------- | ---------------------- |
| `shipment_id` | Yes | The ID of the shipment |
## Request body
| Parameter | Required | Description |
| -------------------------- | -------- | ------------------------------------------------------------- |
| `data.type` | Yes | Must be `custom_field` |
| `data.attributes.api_slug` | Yes | The slug of the custom field definition |
| `data.attributes.value` | Yes | The value to set (type depends on the definition's data type) |
The shipment is implied by the path, so do not send `data.relationships.entity` on this endpoint.
## Authorization
Requires `update` permission on the shipment.
## Response
Returns `201 Created` with the custom field resource on success.
## Behavior
* Uses `find_or_initialize_by` internally, so it creates if missing or updates if it exists
* Values are validated against the definition's data type
* For enum fields, values are validated against the definition's options
## Example request
```json theme={null}
{
"data": {
"type": "custom_field",
"attributes": {
"api_slug": "customer_reference_number",
"value": "ABC124"
}
}
}
```
## Example response
```json theme={null}
{
"data": {
"id": "YOUR_CUSTOM_FIELD_ID",
"type": "custom_field",
"attributes": {
"api_slug": "customer_reference_number",
"value": "ABC124",
"display_value": "ABC124"
},
"relationships": {
"entity": {
"data": {
"id": "YOUR_SHIPMENT_ID",
"type": "shipment"
}
},
"definition": {
"data": {
"id": "YOUR_DEFINITION_ID",
"type": "custom_field_definition"
}
}
}
}
}
```
# Delete shipment custom field
Source: https://terminal49.com/docs/api-docs/api-reference/shipments/delete-shipment-custom-field
delete /shipments/{shipment_id}/custom_fields/{api_slug}
Remove a custom field value from a shipment in the Terminal49 API by referencing the custom field definition's api_slug for that shipment record.
Deletes a specific custom field from a shipment by its `api_slug`.
## Path parameters
| Parameter | Required | Description |
| ------------- | -------- | -------------------------------------------- |
| `shipment_id` | Yes | The ID of the shipment |
| `api_slug` | Yes | The api\_slug of the custom field definition |
## Authorization
Requires `update` permission on the shipment.
## Response
Returns `204 No Content` on success.
# Edit a shipment
Source: https://terminal49.com/docs/api-docs/api-reference/shipments/edit-a-shipment
patch /shipments/{id}
Update editable shipment attributes in the Terminal49 API, including reference numbers, customer party, and operator-managed fields without retracking.
# Get a shipment
Source: https://terminal49.com/docs/api-docs/api-reference/shipments/get-a-shipment
get /shipments/{id}
Retrieve a single shipment from the Terminal49 API by ID, returning the full shipment record with carrier, ports, ETA, and references in one call.
# List shipment custom fields
Source: https://terminal49.com/docs/api-docs/api-reference/shipments/list-shipment-custom-fields
get /shipments/{shipment_id}/custom_fields
List every custom field value attached to a shipment in the Terminal49 API, including the api_slug, current value, and resolved option for enum fields.
Lists all custom fields attached to a specific shipment.
## Path parameters
| Parameter | Required | Description |
| ------------- | -------- | ---------------------- |
| `shipment_id` | Yes | The ID of the shipment |
## Authorization
Requires `show` permission on the shipment.
## Response
Returns a JSONAPI array of custom field resources including:
* `value` - The raw stored value
* `display_value` - Formatted value for display
* Relationships to the definition and user who last updated the field
# List shipments
Source: https://terminal49.com/docs/api-docs/api-reference/shipments/list-shipments
get /shipments
List all shipments in your Terminal49 account with filters for status, carrier, and date, plus pagination cursors for working through large result sets.
# Resume tracking a shipment
Source: https://terminal49.com/docs/api-docs/api-reference/shipments/resume-tracking-shipment
patch /shipments/{id}/resume_tracking
Resume tracking on a previously paused shipment in the Terminal49 API to start receiving milestone updates and webhook notifications again.
# Stop tracking a shipment
Source: https://terminal49.com/docs/api-docs/api-reference/shipments/stop-tracking-shipment
patch /shipments/{id}/stop_tracking
Stop tracking a shipment in the Terminal49 API to pause data refreshes and webhook notifications while preserving previously collected shipment history.
# Update shipment custom field
Source: https://terminal49.com/docs/api-docs/api-reference/shipments/update-shipment-custom-field
patch /shipments/{shipment_id}/custom_fields/{api_slug}
Update an existing custom field value on a shipment in the Terminal49 API, identified by the custom field definition's api_slug for that shipment record.
Updates a specific custom field on a shipment by its `api_slug`.
## Path parameters
| Parameter | Required | Description |
| ------------- | -------- | -------------------------------------------- |
| `shipment_id` | Yes | The ID of the shipment |
| `api_slug` | Yes | The api\_slug of the custom field definition |
## Request body
| Parameter | Required | Description |
| --------- | -------- | -------------------- |
| `value` | Yes | The new value to set |
## Authorization
Requires `update` permission on the shipment.
## Response
Returns `200 OK` with the updated custom field resource on success.
# Get a single shipping line
Source: https://terminal49.com/docs/api-docs/api-reference/shipping-lines/get-a-single-shipping-line
get /shipping_lines/{id}
Retrieve a single ocean shipping line from the Terminal49 API by ID, returning the carrier name, SCAC, and supported tracking number formats.
# List shipping lines
Source: https://terminal49.com/docs/api-docs/api-reference/shipping-lines/shipping-lines
get /shipping_lines
List every ocean shipping line supported by Terminal49, including SCAC codes and carrier names. This endpoint returns the full list without pagination.
# Get a terminal
Source: https://terminal49.com/docs/api-docs/api-reference/terminals/get-a-terminal-using-the-id
get /terminals/{id}
Retrieve a single terminal from the Terminal49 API by terminal ID, including the terminal name, port, country, timezone, and operator metadata.
# Infer Tracking Number (Beta)
Source: https://terminal49.com/docs/api-docs/api-reference/tracking-requests/auto-detect-carrier
post /tracking_requests/infer_number
Predict the carrier SCAC and tracking number type from a bill of lading, booking, or container number using the Terminal49 Infer endpoint.
**Beta Feature** - This endpoint is currently in beta. The API is stable, but
the schema and behavior may evolve based on feedback.
## What this endpoint does
Provide a tracking number (container, bill of lading, or booking). The endpoint returns:
* The **predicted VOCC carrier SCAC** to use for tracking
* The **predicted number type**
* A confidence-driven **decision** (`auto_select`, `needs_confirmation`, `no_prediction`)
Terminal49 uses machine learning prediction across container, bill of lading,
and booking numbers. For container numbers, Terminal49 leverages tens of
millions of historical container movements to predict which carrier is moving
the container (about 9 out of 10 times).
## How to use the result
This endpoint has its own rate-limit bucket: 200 requests per minute per API key.
Learn how to use Infer Tracking Number to reliably create tracking requests
# Create a tracking request
Source: https://terminal49.com/docs/api-docs/api-reference/tracking-requests/create-a-tracking-request
post /tracking_requests
Create a new tracking request in the Terminal49 API with a bill of lading, booking, or container number plus a carrier SCAC to start tracking a shipment.
**Don't know the SCAC?** Set `auto_detect_vocc_scac` to `true` and omit `scac`,
and Terminal49 will infer the carrier SCAC for you. Detection runs
asynchronously: the request is created immediately with `status: "pending"` and
`scac: null`, then resolves to `created` (with the detected `scac` populated) or
`failed` (`failed_reason: "scac_auto_detect_failed"`) — poll the tracking request
or use webhooks to observe the outcome. Use [Auto-Detect
Carrier](/docs/api-docs/api-reference/tracking-requests/auto-detect-carrier) first
when your workflow needs to preview or confirm carrier candidates before
submitting.
This endpoint has its own rate-limit bucket: 100 tracking requests per minute per API key/account.
## Setting custom field values before the shipment exists
Use `initial_custom_fields` to stage custom field values at creation time, before the shipment and containers exist. Terminal49 applies `shipment` entries to the shipment and `containers` entries to their containers once the tracking request resolves.
Each `api_slug` must match an existing [custom field definition](/docs/api-docs/api-reference/custom-fields/create-a-custom-field-definition) on your account — a `Shipment`-scoped definition for `shipment` entries, a `Container`-scoped definition for `containers` entries. Container entries without a `number` — or with `number` set to an empty string — are applied to every container on the shipment; include a non-empty `number` to target one specific container.
```json theme={null}
{
"data": {
"type": "tracking_request",
"attributes": {
"request_number": "MEDUAI047070",
"request_type": "bill_of_lading",
"scac": "MSCU",
"initial_custom_fields": {
"shipment": [
{ "api_slug": "booking_reference", "value": "BOOK-2026-001" }
],
"containers": [
{ "api_slug": "po_number", "value": "PO-123", "number": "MSCU1234567" },
{ "api_slug": "po_number", "value": "PO-999", "number": "TCLU7654321" },
{ "api_slug": "customs_broker", "value": "Acme Brokerage" },
{ "api_slug": "seal_number", "value": "SEAL-0001", "number": "" }
]
}
}
}
}
```
In this example, `booking_reference` is set on the shipment; `po_number` is set per container by `number`; and `customs_broker` and `seal_number` — which omit `number` or pass an empty string — are applied to every container on the shipment.
# Create tracking request custom field
Source: https://terminal49.com/docs/api-docs/api-reference/tracking-requests/create-tracking-request-custom-field
post /tracking_requests/{tracking_request_id}/custom_fields
Create or update a custom field value on a tracking request in the Terminal49 API. Attach internal metadata like references, intake tags, or workflow flags.
Creates or updates a custom field on a tracking request. If a custom field with the specified `api_slug` already exists, it will be updated.
## Path parameters
| Parameter | Required | Description |
| --------------------- | -------- | ------------------------------ |
| `tracking_request_id` | Yes | The ID of the tracking request |
## Request body
| Parameter | Required | Description |
| -------------------------- | -------- | ------------------------------------------------------------- |
| `data.type` | Yes | Must be `custom_field` |
| `data.attributes.api_slug` | Yes | The slug of the custom field definition |
| `data.attributes.value` | Yes | The value to set (type depends on the definition's data type) |
The tracking request is implied by the path, so do not send `data.relationships.entity` on this endpoint.
## Authorization
Requires `update` permission on the tracking request.
## Response
Returns `201 Created` with the custom field resource on success.
## Behavior
* Uses `find_or_initialize_by` internally, so it creates if missing or updates if it exists
* Values are validated against the definition's data type
* For enum fields, values are validated against the definition's options
## Example request
```json theme={null}
{
"data": {
"type": "custom_field",
"attributes": {
"api_slug": "customer_reference_number",
"value": "ABC124"
}
}
}
```
## Example response
```json theme={null}
{
"data": {
"id": "YOUR_CUSTOM_FIELD_ID",
"type": "custom_field",
"attributes": {
"api_slug": "customer_reference_number",
"value": "ABC124",
"display_value": "ABC124"
},
"relationships": {
"entity": {
"data": {
"id": "YOUR_TRACKING_REQUEST_ID",
"type": "tracking_request"
}
},
"definition": {
"data": {
"id": "YOUR_DEFINITION_ID",
"type": "custom_field_definition"
}
}
}
}
}
```
# Delete tracking request custom field
Source: https://terminal49.com/docs/api-docs/api-reference/tracking-requests/delete-tracking-request-custom-field
delete /tracking_requests/{tracking_request_id}/custom_fields/{api_slug}
Remove a custom field value from a tracking request in the Terminal49 API by referencing the custom field definition's api_slug for that tracking request.
Deletes a specific custom field from a tracking request by its `api_slug`.
## Path parameters
| Parameter | Required | Description |
| --------------------- | -------- | -------------------------------------------- |
| `tracking_request_id` | Yes | The ID of the tracking request |
| `api_slug` | Yes | The api\_slug of the custom field definition |
## Authorization
Requires `update` permission on the tracking request.
## Response
Returns `204 No Content` on success.
# Edit a tracking request
Source: https://terminal49.com/docs/api-docs/api-reference/tracking-requests/edit-a-tracking-request
patch /tracking_requests/{id}
Update an existing tracking request in the Terminal49 API, including its associated party reference, customer metadata, and operator-managed fields.
# Get a tracking request
Source: https://terminal49.com/docs/api-docs/api-reference/tracking-requests/get-a-single-tracking-request
get /tracking_requests/{id}
Retrieve a single tracking request from the Terminal49 API by ID, including its current status, failure reason, retry count, and associated shipment ID.
# List tracking request custom fields
Source: https://terminal49.com/docs/api-docs/api-reference/tracking-requests/list-tracking-request-custom-fields
get /tracking_requests/{tracking_request_id}/custom_fields
List every custom field value attached to a tracking request in the Terminal49 API, including the api_slug, current value, and resolved option for enum fields.
Lists all custom fields attached to a specific tracking request.
## Path parameters
| Parameter | Required | Description |
| --------------------- | -------- | ------------------------------ |
| `tracking_request_id` | Yes | The ID of the tracking request |
## Authorization
Requires `show` permission on the tracking request.
## Response
Returns a JSONAPI array of custom field resources including:
* `value` - The raw stored value
* `display_value` - Formatted value for display
* Relationships to the definition and user who last updated the field
# List tracking requests
Source: https://terminal49.com/docs/api-docs/api-reference/tracking-requests/list-tracking-requests
get /tracking_requests
List tracking requests in your Terminal49 account with filters for status, carrier SCAC, and date, sorted with the most recent tracking requests first.
# Update tracking request custom field
Source: https://terminal49.com/docs/api-docs/api-reference/tracking-requests/update-tracking-request-custom-field
patch /tracking_requests/{tracking_request_id}/custom_fields/{api_slug}
Update an existing custom field value on a tracking request in the Terminal49 API, identified by the custom field definition's api_slug for that record.
Updates a specific custom field on a tracking request by its `api_slug`.
## Path parameters
| Parameter | Required | Description |
| --------------------- | -------- | -------------------------------------------- |
| `tracking_request_id` | Yes | The ID of the tracking request |
| `api_slug` | Yes | The api\_slug of the custom field definition |
## Request body
| Parameter | Required | Description |
| --------- | -------- | -------------------- |
| `value` | Yes | The new value to set |
## Authorization
Requires `update` permission on the tracking request.
## Response
Returns `200 OK` with the updated custom field resource on success.
# Get a vessel by ID
Source: https://terminal49.com/docs/api-docs/api-reference/vessels/get-a-vessel-using-the-id
get /vessels/{id}
Retrieve a vessel from the Terminal49 API using its internal vessel ID, including name, IMO number, and optional position data for paid plan accounts.
# Get a vessel by IMO
Source: https://terminal49.com/docs/api-docs/api-reference/vessels/get-a-vessel-using-the-imo
get /vessels/{imo}
Retrieve a vessel from the Terminal49 API by IMO number, including the vessel name, internal ID, and optional AIS position data on paid plan accounts.
# Get vessel future positions
Source: https://terminal49.com/docs/api-docs/api-reference/vessels/get-vessel-future-positions
get /vessels/{id}/future_positions
Retrieve a vessel's estimated future route between two ports from the Terminal49 API, returning a sequence of positions spaced one minute apart.
This is a paid Routing Data endpoint. See [Entitlements and Paid Features](/docs/api-docs/useful-info/entitlements) for access requirements and non-entitled responses.
This endpoint requires the destination port ID (`port_id`) and the previous port ID (`previous_port_id`) for the vessel leg you want to forecast.
## How to find the port IDs
If you are starting from a tracked container, call [`GET /v2/containers/{id}/map_geojson`](/docs/api-docs/api-reference/containers/get-container-map-geojson) and inspect the `port` features. Each port feature includes:
* `properties.location_id` - the port UUID to pass as `port_id` or `previous_port_id`
* `properties.ports_sequence` - the route order
* `properties.label` - route label such as `POL`, `POD`, or transshipment labels
Use the destination port's `location_id` as `port_id`, and the preceding port's `location_id` as `previous_port_id`.
# Get vessel future positions from coordinates
Source: https://terminal49.com/docs/api-docs/api-reference/vessels/get-vessel-future-positions-with-coordinates
get /vessels/{id}/future_positions_with_coordinates
Retrieve a vessel's estimated future route between two coordinate pairs from the Terminal49 API, with one-minute spacing on returned position timestamps.
# Get a single webhook notification
Source: https://terminal49.com/docs/api-docs/api-reference/webhook-notifications/get-a-single-webhook-notification
get /webhook_notifications/{id}
Retrieve a single webhook notification record from the Terminal49 API to inspect its delivery status, payload, and the originating shipment or container event.
# Get webhook notification payload examples
Source: https://terminal49.com/docs/api-docs/api-reference/webhook-notifications/get-webhook-notification-payload-examples
get /webhook_notifications/examples
Get an example webhook notification payload from the Terminal49 API for a given event type. Use these payloads to build and test webhook consumers.
# List webhook notifications
Source: https://terminal49.com/docs/api-docs/api-reference/webhook-notifications/list-webhook-notifications
get /webhook_notifications
List webhook notifications from the Terminal49 API to reconcile delivered and undelivered events when your endpoint has been down or behind on processing.
# Create a webhook
Source: https://terminal49.com/docs/api-docs/api-reference/webhooks/create-a-webhook
post /webhooks
Register a new webhook endpoint with the Terminal49 API to receive real-time notifications for tracking request, shipment, and container event changes.
# Delete a webhook
Source: https://terminal49.com/docs/api-docs/api-reference/webhooks/delete-a-webhook
delete /webhooks/{id}
Permanently delete a webhook subscription from the Terminal49 API. Once deleted, the endpoint will stop receiving event notifications immediately.
# Edit a webhook
Source: https://terminal49.com/docs/api-docs/api-reference/webhooks/edit-a-webhook
patch /webhooks/{id}
Update an existing webhook subscription in the Terminal49 API, including its target URL, subscribed event types, and active status flag without losing history.
# Get a single webhook
Source: https://terminal49.com/docs/api-docs/api-reference/webhooks/get-single-webhook
get /webhooks/{id}
Retrieve a single webhook subscription from the Terminal49 API, including its target URL, subscribed event types, signing secret, and active status flag.
# List webhook events
Source: https://terminal49.com/docs/api-docs/api-reference/webhooks/list-webhook-events
get /webhooks/events
Retrieve a paginated list of webhook events delivered by the Terminal49 API, including delivery status, payload metadata, and endpoint details.
# List webhook IPs
Source: https://terminal49.com/docs/api-docs/api-reference/webhooks/list-webhook-ips
get /webhooks/ips
Retrieve the list of IP addresses Terminal49 uses to send webhook notifications. Use this list to whitelist Terminal49 traffic on your firewall or WAF.
# List webhooks
Source: https://terminal49.com/docs/api-docs/api-reference/webhooks/list-webhooks
get /webhooks
List every webhook subscription configured for your Terminal49 account, including each endpoint URL, subscribed event types, and active status flag.
# Trigger a webhook test delivery
Source: https://terminal49.com/docs/api-docs/api-reference/webhooks/trigger-a-webhook
post /webhooks/trigger
Send a one-time test webhook notification to a target HTTPS URL via the Terminal49 API without creating a persistent webhook endpoint configuration.
# List Shipments and Containers
Source: https://terminal49.com/docs/api-docs/getting-started/list-shipments-and-containers
List tracked shipments and containers via the Terminal49 API, filter results by status, and retrieve the tracking data your integration needs.
In this tutorial, you will list the shipment and container records created from your tracking requests.
Use this step after you have created at least one tracking request.
## Shipment and container data in Terminal49
After Terminal49 accepts a tracking request, it starts collecting available data from carriers and terminals. You can retrieve the latest stored data at any time with the Shipments and Containers endpoints.
Use these endpoints for on-demand lookups. For ongoing status monitoring, use webhooks instead of polling.
## Which object holds which field?
Tracking data is split across two resources. If you query the wrong endpoint you will not see the field you expect — for example, `pod_eta_at` is **not** returned by `GET /containers` because it lives on the shipment.
| Field | Object | Endpoint |
| ------------------------------------------------------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------ |
| `pod_eta_at` — current ETA at the port of discharge | `shipment` | `GET /shipments/{id}` |
| `pod_original_eta_at` — first ETA reported by the carrier | `shipment` | `GET /shipments/{id}` |
| `destination_eta_at` — ETA at the final destination (carrier view) | `shipment` | `GET /shipments/{id}` |
| `pod_ata_at` — actual arrival at the port of discharge | `shipment` | `GET /shipments/{id}` |
| `bill_of_lading_number` | `shipment` | `GET /shipments/{id}` |
| `port_of_lading_name` / `port_of_discharge_name` | `shipment` | `GET /shipments/{id}` |
| `shipping_line_scac` / `shipping_line_name` | `shipment` | `GET /shipments/{id}` |
| `ref_numbers` | `shipment` | `GET /shipments/{id}` |
| `number` — container number | `container` | `GET /containers/{id}` |
| `pod_arrived_at` / `pod_discharged_at` | `container` | `GET /containers/{id}` |
| `pod_full_out_at` — gated out of the port terminal | `container` | `GET /containers/{id}` |
| `empty_terminated_at` — empty returned | `container` | `GET /containers/{id}` |
| `pickup_lfd` — last free day | `container` | `GET /containers/{id}` |
| `holds_at_pod_terminal` / `fees_at_pod_terminal` | `container` | `GET /containers/{id}` |
| `available_for_pickup` / `availability_known` | `container` | `GET /containers/{id}` |
| `ind_eta_at` / `ind_ata_at` — rail carrier ETA/ATA at inland destination | `container` | `GET /containers/{id}` (see [Rail integration guide](/docs/api-docs/in-depth-guides/rail-integration-guide)) |
### Fetching shipment fields alongside a container
If you already have a container ID (or are filtering by container number) and want the shipment ETA fields in the same response, use the `include` query parameter to embed the related shipment:
```bash theme={null}
curl "https://api.terminal49.com/v2/containers/{id}?include=shipment" \
-H "Content-Type: application/vnd.api+json" \
-H "Authorization: Token YOUR_API_KEY"
```
The shipment record — including `pod_eta_at`, `pod_original_eta_at`, and `destination_eta_at` — is returned in the top-level `included` array. See [Include related resources](/docs/api-docs/in-depth-guides/including-resources) for the full syntax.
## Authentication
As in the previous steps, every request sends your API key in the `Authorization` header:
```http theme={null}
Authorization: Token YOUR_API_KEY
```
If you don't have an API key yet, get one from the [developer portal](https://app.terminal49.com/developers/api-keys) as described in [Start Here](/docs/api-docs/getting-started/start-here).
## List all your tracked shipments
If your tracking request was successful, you will now be able to list your tracked shipments. Replace `YOUR_API_KEY` with your API key:
```bash theme={null}
curl "https://api.terminal49.com/v2/shipments" \
-H "Content-Type: application/vnd.api+json" \
-H "Authorization: Token YOUR_API_KEY"
```
Sometimes it takes a few minutes for a new tracking request to appear as a shipment.
Copy the response into a text editor so you can inspect it while continuing the tutorial.
Responses follow the JSON:API format, which is why they are larger and more
structured than plain JSON. See the JSON:API note in [Track Shipments and
Containers](/docs/api-docs/getting-started/tracking-shipments-and-containers#anatomy-of-a-tracking-request-response)
for tips on parsing it.
## Inspect the shipment response
The `/shipments` response returns an array of `shipment` objects. Each shipment includes attributes, relationships to related records, and a `self` link.
For clarity, some fields have been replaced with ellipses (`...`), and inline comments call out the key fields.
The **data** attribute contains an array of objects. Each object is of type `shipment` and includes attributes such as bill of lading number and port of lading. Each shipment object also has relationships to structured data objects like ports and terminals, as well as a list of containers on the shipment.
You can access these structured elements through the API. Terminal49 cleans and enhances the data from the shipping line, so you get a pre-defined object for each port, terminal, and other entity.
```jsonc theme={null}
{
"data": [
{
/* this is an internal id that you can use to query the API directly, i.e by hitting https://api.terminal49.com/v2/shipments/123456789 */
"id": "123456789",
// the object type is a shipment, per below.
"type": "shipment",
"attributes": {
// Your BOL number that you used in the tracking request
"bill_of_lading_number": "99999999",
...
"shipping_line_scac": "MAEU",
"shipping_line_name": "Maersk",
"port_of_lading_locode": "INVTZ",
"port_of_lading_name": "Visakhapatnam",
...
},
"relationships": {
"port_of_lading": {
"data": {
"id": "bde5465a-1160-4fde-a026-74df9c362f65",
"type": "port"
}
},
"port_of_discharge": {
"data": {
"id": "3d892622-def8-4155-94c5-91d91dc42219",
"type": "port"
}
},
"pod_terminal": {
"data": {
"id": "99e1f6ba-a514-4355-8517-b4720bdc5f33",
"type": "terminal"
}
},
"destination": {
"data": null
},
"containers": {
"data": [
{
"id": "593f3782-cc24-46a9-a6ce-b2f1dbf3b6b9",
"type": "container"
}
]
}
},
"links": {
// this is a link to this specific shipment in the API.
"self": "/v2/shipments/7f8c52b2-c255-4252-8a82-f279061fc847"
}
},
...
],
...
}
```
## Sample code: listing tracked shipments in a Google Sheet
Below is code written in Google App Script that lists the current shipments into the current sheet of a spreadsheet. App Script is very similar to Javascript.
Because Google App Script does not have native JSON:API support, you need to parse the JSON directly, making this example an ideal real world application of the API.
```javascript theme={null}
function listTrackedShipments(){
// first we construct the request.
var options = {
"method" : "GET",
"headers" : {
"content-type": "application/vnd.api+json",
"authorization" : "Token YOUR_API_KEY"
},
"payload" : ""
};
try {
// note that URLFetchApp is a function of Google App Script, not a standard JS function.
var response = UrlFetchApp.fetch("https://api.terminal49.com/v2/shipments", options);
var json = response.getContentText();
var shipments = JSON.parse(json)["data"];
var shipment_values = [];
shipment_values = extractShipmentValues(shipments);
listShipmentValues(shipment_values);
} catch (error){
//In JS you would use console.log(), but App Script uses Logger.log().
Logger.log("error communicating with t49 / shipments: " + error);
}
}
function extractShipmentValues(shipments){
var shipment_values = [];
shipments.forEach(function(shipment){
// iterating through the shipments.
shipment_values.push(extractShipmentData(shipment));
});
return shipment_values;
}
function extractShipmentData(shipment){
var shipment_val = [];
//for each shipment I'm extracting some of the key info i want to display.
shipment_val.push(shipment["attributes"]["shipping_line_scac"],
shipment["attributes"]["shipping_line_name"],
shipment["attributes"]["bill_of_lading_number"],
shipment["attributes"]["pod_vessel_name"],
shipment["attributes"]["port_of_lading_name"],
shipment["attributes"]["pol_etd_at"],
shipment["attributes"]["pol_atd_at"],
shipment["attributes"]["port_of_discharge_name"],
shipment["attributes"]["pod_eta_at"],
shipment["attributes"]["pod_ata_at"],
shipment["relationships"]["containers"]["data"].length,
shipment["id"]
);
return shipment_val;
}
function listShipmentValues(shipment_values){
// now, list the data in the spreadsheet.
var ss = SpreadsheetApp.getActiveSpreadsheet();
var homesheet = ss.getActiveSheet();
var STARTING_ROW = 1;
var MAX_TRACKED = 500;
try {
// clear the contents of the sheet first.
homesheet.getRange(STARTING_ROW,1,MAX_TRACKED,shipment_values[0].length).clearContent();
// now insert all the shipment values directly into the sheet.
homesheet.getRange(STARTING_ROW,1,shipment_values.length,shipment_values[0].length).setValues(shipment_values);
} catch (error){
Logger.log("there was an error in listShipmentValues: " + error);
}
}
```
## List all your tracked containers
You can also list out all of your containers. Container data includes terminal availability, last free day, holds, fees, and other logistical information that you might use for drayage operations at port.
To learn how to use holds and fees data to determine if a container is ready
for pickup, see [Container Holds, Fees, and Release
Readiness](/docs/api-docs/in-depth-guides/holds-and-fees).
Replace `YOUR_API_KEY` with your API key:
```bash theme={null}
curl "https://api.terminal49.com/v2/containers" \
-H "Content-Type: application/vnd.api+json" \
-H "Authorization: Token YOUR_API_KEY"
```
We suggest copying the response into a text editor so you can examine it while continuing the tutorial.
## Anatomy of containers JSON response
Now that you've got a list of containers, let's examine the response you've received. The example below is partial: it shows a single container object from the `data` array, with some fields omitted and inline comments calling out the key fields.
```jsonc theme={null}
// We have an array of objects in the data returned.
"data": [
{
//
"id": "internalid",
// this object is of type Container.
"type": "container",
"attributes": {
// Here is your container number
"number": "OOLU-xxxx",
// Seal Numbers aren't always returned by the carrier.
"seal_number": null,
"created_at": "2020-09-13T19:16:47Z",
"equipment_type": "reefer",
"equipment_length": null,
"equipment_height": null,
"weight_in_lbs": 54807,
"fees_at_pod_terminal": [],
"holds_at_pod_terminal": [],
// here is your last free day.
"pickup_lfd": "2020-09-17T07:00:00Z",
"pickup_appointment_at": null,
"availability_known": true,
"available_for_pickup": false,
"pod_arrived_at": "2020-09-13T22:05:00Z",
"pod_discharged_at": "2020-09-15T05:27:00Z",
"location_at_pod_terminal": "CC1-162-B-3(Deck)",
"final_destination_full_out_at": null,
"pod_full_out_at": "2020-09-18T10:30:00Z",
"empty_terminated_at": null
},
"relationships": {
// linking back to the shipment object, found above.
"shipment": {
"data": {
"id": "894befec-e7e2-4e48-ab97-xxxxxxxxx",
"type": "shipment"
}
},
"pod_terminal": {
"data": {
"id": "39d09f18-cf98-445b-b6dc-xxxxxxxxx",
"type": "terminal"
}
},
...
}
},
...
```
## Next up: receive status updates
You can now list your tracked shipments and containers on demand. The final step is to register a webhook so Terminal49 pushes updates to you as they happen.
Register a webhook endpoint and handle your first notification.
# Receive shipment status updates with webhooks
Source: https://terminal49.com/docs/api-docs/getting-started/receive-status-updates
Set up Terminal49 webhooks to receive real-time shipment and container status updates, including milestone events, whenever tracking data changes.
In this tutorial, you will register a webhook endpoint and confirm the shape of the status updates Terminal49 sends.
Use webhooks for ongoing tracking updates. Polling is useful for on-demand lookups, but it adds latency and consumes API rate limits.
## Before you start
You need:
* A Terminal49 API key.
* A public HTTPS endpoint that can receive `POST` requests.
* At least one active tracking request.
For local testing, use a temporary endpoint from a tool such as webhook.site. For production, use an endpoint in your own application.
## Create a webhook endpoint
You can create a webhook from the dashboard or the API.
To use the dashboard:
Go to [Developer Webhooks](https://app.terminal49.com/developers/webhooks) in your Terminal49 dashboard.
Click **Create Webhook Endpoint** and enter your HTTPS endpoint URL.
Select the events you want to receive, then save the webhook.
To use the API, send:
```bash theme={null}
curl -X POST "https://api.terminal49.com/v2/webhooks" \
-H "Authorization: Token YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "webhook",
"attributes": {
"url": "https://example.com/webhooks/terminal49",
"active": true,
"events": ["tracking_request.succeeded", "container.updated"]
}
}
}'
```
The response includes the webhook `id` and `secret`. Store the `secret` securely; you use it to verify webhook signatures.
## Receive the first event
After Terminal49 detects a change for one of your tracked shipments or containers, it sends a `POST` request to your endpoint.
Every notification has the same top-level shape:
```json theme={null}
{
"data": {
"id": "87d4f5e3-df7b-4725-85a3-b80acc572e5d",
"type": "webhook_notification",
"attributes": {
"event": "tracking_request.succeeded",
"delivery_status": "pending",
"created_at": "2026-05-11T18:30:00Z"
}
},
"included": []
}
```
Check `data.attributes.event` first. This tells your handler which code path to run.
Common first events are:
* `tracking_request.succeeded`: Terminal49 found the shipment and created tracking records.
* `tracking_request.failed`: Terminal49 could not create tracking for the submitted number.
* `container.updated`: One or more container attributes changed.
## Return a successful response
Your endpoint should return a success status (200, 201, 202, or 204) after it durably accepts the event — persist or enqueue the payload first, then process it asynchronously.
```javascript theme={null}
app.post("/webhooks/terminal49", express.raw({ type: "*/*" }), (req, res) => {
const payload = JSON.parse(req.body.toString("utf8"));
queueWebhookForProcessing(payload);
res.sendStatus(202);
});
```
If Terminal49 receives another response code or the request times out, it retries the notification.
## Before you use webhooks in production
Production webhook handlers should:
1. Verify the `X-T49-Webhook-Signature` header against the raw request body.
2. Allowlist Terminal49 webhook IPs.
3. Deduplicate by `data.id`.
4. Process asynchronously when work may take more than a few seconds.
Follow [Setting up webhooks](/docs/api-docs/in-depth-guides/webhooks) for signature examples and [Webhook Best Practices](/docs/api-docs/webhooks/best-practices) for retry handling.
## Next steps
Select the events your integration should subscribe to.
Review the notification envelope and example payloads.
# Open the TypeScript SDK Quickstart
Source: https://terminal49.com/docs/api-docs/getting-started/sdk-quickstart
Redirect page for the Terminal49 TypeScript SDK quickstart. Track a container and retrieve live shipment data in a few lines of code.
The SDK documentation has moved to the top-level SDK Docs section.
* [Open the SDK quickstart](/docs/sdk/quickstart)
# Start Here: Track Shipments with the Terminal49 API
Source: https://terminal49.com/docs/api-docs/getting-started/start-here
Set up Terminal49 API access, generate an API key, and make your first tracking request to start monitoring ocean shipments and containers.
This getting-started path walks you through a first successful Terminal49 integration:
1. Get an API key.
2. Create a tracking request for a bill of lading, booking, or container number.
3. List the shipment and container data Terminal49 stores for you.
4. Register a webhook so your system receives updates when data changes.
By the end, you will have made an authenticated request to the API and seen the basic request/response shape used by the rest of the documentation.
## Before you start
You need:
* A Terminal49 account with API access.
* A shipment identifier from a carrier: master Bill of Lading (BOL), booking number, or container number.
* The carrier Standard Carrier Alpha Code (SCAC), unless you plan to use carrier auto-detection.
You can use any HTTP client. [Postman](https://www.postman.com/) is useful for a first pass because it can run the published OpenAPI collection with your API key.
Explore the Terminal49 API with the published Postman collection.
## Get an API key
Sign in to your Terminal49 account and go to the [developer portal](https://app.terminal49.com/developers/api-keys) to get your API key.
The full API key value is only shown once, right after you create it. Copy the complete token from that screen and store it somewhere safe (for example, a password manager or your deployment's secret manager) before you navigate away. Once you leave the page, the key is masked and cannot be revealed again.
If you did not capture the full value, create a new key and copy it immediately. You can then delete the older key from the [developer portal](https://app.terminal49.com/developers/api-keys).
## Send the Authorization header
The API uses a Token-prefixed API key in the `Authorization` header. Send it with every request:
```http theme={null}
Authorization: Token YOUR_API_KEY
```
Keep your API key on your server. Do not put it in browser code or public repositories.
## Continue the tutorial
Send your first shipment identifier to Terminal49.
# Track Shipments and Containers
Source: https://terminal49.com/docs/api-docs/getting-started/tracking-shipments-and-containers
Create tracking requests for bill of lading, booking, and container numbers in the Terminal49 API and start receiving shipment milestone updates.
In this tutorial, you will create a tracking request. A tracking request tells Terminal49 which shipment or container to monitor.
Each tracking request needs two values:
* A Bill of Lading (BOL), booking number, or container number from the carrier.
* The carrier Standard Carrier Alpha Code (SCAC). You can see a complete list of supported SCACs in the [ocean carrier coverage list](/docs/coverage/ocean-carriers).
**Don't know the SCAC?** Use the [Infer Tracking
Number](/docs/api-docs/in-depth-guides/auto-detect-carrier) endpoint (also called
Auto-Detect Carrier) to identify the shipping line from your tracking number.
## Choose a tracking number
**Supported numbers**
1. Master Bill of Lading number from the carrier (recommended)
2. Booking number from the carrier
3. Container number
Container number tracking support varies by ocean carrier. Check the [ocean
carrier coverage list](/docs/coverage/ocean-carriers) to see which carriers
support container number tracking.
**Unsupported numbers**
* House Bill of Lading (HBOL) numbers
* Customs entry numbers
* Seal numbers
* Internally generated numbers, such as purchase order numbers or customer reference numbers
## Authentication
Every request in this tutorial sends your API key in the `Authorization` header:
```http theme={null}
Authorization: Token YOUR_API_KEY
```
If you don't have an API key yet, get one from the [developer portal](https://app.terminal49.com/developers/api-keys) as described in [Start Here](/docs/api-docs/getting-started/start-here).
## Create a tracking request
Replace `YOUR_API_KEY`, `REQUEST_NUMBER`, and `SCAC` before running this example. The request number must be a master bill of lading, booking, or container number from the carrier.
```bash cURL 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_type": "bill_of_lading",
"request_number": "REQUEST_NUMBER",
"scac": "SCAC"
}
}
}'
```
```javascript JavaScript theme={null}
const response = await fetch("https://api.terminal49.com/v2/tracking_requests", {
method: "POST",
headers: {
"Content-Type": "application/vnd.api+json",
"Authorization": "Token YOUR_API_KEY"
},
body: JSON.stringify({
data: {
type: "tracking_request",
attributes: {
request_type: "bill_of_lading",
request_number: "REQUEST_NUMBER",
scac: "SCAC"
}
}
})
});
console.log(await response.json());
```
Rate limiting: You can create up to 100 tracking requests per minute.
## Anatomy of a tracking request response
The response confirms that Terminal49 accepted the request. A new request usually starts with `status: "pending"` while Terminal49 checks the carrier.
```json theme={null}
{
"data": {
"id": "478cd7c4-a603-4bdf-84d5-3341c37c43a3",
"type": "tracking_request",
"attributes": {
"request_number": "xxxxxx",
"request_type": "bill_of_lading",
"scac": "MAEU",
"ref_numbers": [],
"created_at": "2020-09-17T16:13:30Z",
"updated_at": "2020-09-17T17:13:30Z",
"status": "pending",
"failed_reason": null,
"is_retrying": false,
"retry_count": null
},
"relationships": {
"tracked_object": {
"data": null
}
},
"links": {
"self": "/v2/tracking_requests/478cd7c4-a603-4bdf-84d5-3341c37c43a3"
}
}
}
```
Note that if you try to track the same shipment again, you will receive an error like this:
```json theme={null}
{
"errors": [
{
"status": "422",
"source": {
"pointer": "/data/attributes/request_number"
},
"title": "Unprocessable Entity",
"detail": "Request number 'xxxxxxx' with scac 'MAEU' already exists in a tracking_request with a pending or created status",
"code": "duplicate"
}
]
}
```
**Why so much JSON? (A note on JSON:API)**
The Terminal49 API is JSON:API compliant. JSON:API libraries can translate the response into a full object model compatible with an ORM, which is powerful but produces larger, more structured payloads. If you parse JSON directly, this can feel verbose. For production use, consider adopting a [JSON:API client library](https://jsonapi.org/implementations/#client-libraries) to get the most out of the format. For this tutorial, you will work with the data directly.
## What happens after you create a tracking request
Terminal49 works asynchronously:
1. You send a tracking request with a shipment identifier and SCAC.
2. Terminal49 accepts the request and returns a `tracking_request` with `status: "pending"`.
3. Terminal49 monitors the carrier and creates shipment and container records as data becomes available.
4. You list shipments and containers at any time, or receive updates through a webhook.
A webhook is a callback URL that Terminal49 sends `POST` requests to whenever tracking data changes: you receive `tracking_request.succeeded` when the shipment is created, or `tracking_request.failed` if there is a problem. You will register a webhook in [step 4 of this path](/docs/api-docs/getting-started/receive-status-updates). Until then, you can poll the tracking request as shown below.
## Check your tracking request status
If you have not set up a webhook yet, poll the Tracking Requests endpoint to check whether your request succeeded or failed. Replace `YOUR_API_KEY` with your API key.
```bash theme={null}
curl "https://api.terminal49.com/v2/tracking_requests" \
-H "Content-Type: application/vnd.api+json" \
-H "Authorization: Token YOUR_API_KEY"
```
To check a single request, append the `id` from the create response: `GET /v2/tracking_requests/{id}`.
## Troubleshooting
**Tracking request troubleshooting**
The most common issue is entering the wrong number.
Check that you are entering a Bill of Lading number, booking number, or container number — not an internal reference from your company or freight forwarder. Verify the number by going to the carrier's website and tracking the shipment with it. If that works and Terminal49 supports the SCAC, you should be able to track it through the API.
If you are unsure of the correct SCAC, try the [Infer Tracking Number](/docs/api-docs/in-depth-guides/auto-detect-carrier) endpoint first.
Sometimes the issue is on the shipping line's side. Temporary network problems, unpopulated manifests, and other issues can occur. See [Tracking Request Retrying](/docs/api-docs/useful-info/tracking-request-retrying) for how Terminal49 handles these cases.
You can always email us at [support@terminal49.com](mailto:support@terminal49.com) if you have persistent
issues.
## Next up: get your shipments
Now that you've made a tracking request, the next step is to list your shipments and retrieve the tracking data.
Retrieve the shipment and container records Terminal49 created for you.
See [How to initiate shipment tracking on
Terminal49](https://help.terminal49.com/en/articles/8074102-how-to-initiate-shipment-tracking-on-terminal49)
for other ways of initiating shipment tracking.
# Add a Customer to a Tracking Request
Source: https://terminal49.com/docs/api-docs/in-depth-guides/adding-customer
Associate a customer party with a Terminal49 tracking request so new shipments automatically inherit the correct party relationship and metadata.
This guide covers the `customer` role. For shipper, consignee, freight forwarder, notify party, customs broker, and dray carrier, see [Assign Shipper, Consignee, and Other Parties](/docs/api-docs/in-depth-guides/assigning-parties).
## Why add a party to a tracking request?
Adding a party to a tracking request associates customer information with the request. The customer is assigned to the shipment when it is created, just like reference numbers and tags. This helps you organize and manage your shipments more effectively.
## How to get the party ID
You can either find an existing party or create a new one.
* To find an existing party, jump to [Listing all parties](#listing-all-parties) section.
* To create a new party, jump to [Adding party for a customer](#adding-party-for-a-customer) section.
## List all parties
You can list all parties associated with your account through the [API](/docs/api-docs/api-reference/parties/list-parties).
Endpoint: **GET** - [https://api.terminal49.com/v2/parties](/docs/api-docs/api-reference/parties/list-parties)
```json Response theme={null}
{
"data": [
{
"id": "PARTY_ID_1",
"type": "party",
"attributes": {
"company_name": "COMPANY NAME 1",
}
},
{
"id": "PARTY_ID_2",
"type": "party",
"attributes": {
"company_name": "COMPANY NAME 2",
}
}
],
"links": {
"last": "",
"next": "",
"prev": "",
"first": "",
"self": ""
},
"meta": {
"size": 2,
"total": 2
}
}
```
After you get all the parties you would filter the parties by `company_name` to find the correct ID, either by looking through the list manually or using code to automate the process.
## Add a party to a tracking request
To add a customer to a tracking request, include the party as a customer relationship when creating the request. Shipper, consignee, and the other roles are passed the same way, as relationships named after the role. To change roles on an existing shipment, use [party roles](/docs/api-docs/in-depth-guides/assigning-parties).
Endpoint: **POST** - [https://api.terminal49.com/v2/tracking\_requests](/docs/api-docs/api-reference/tracking-requests/create-a-tracking-request)
```json Request theme={null}
{
"data": {
"type": "tracking_request",
"attributes": {
"request_type": "bill_of_lading",
"request_number": "MEDUFR030802",
"ref_numbers": [
"PO12345",
"HBL12345",
"CUSREF1234"
],
"shipment_tags": [
"camembert"
],
"scac": "MSCU"
},
"relationships": {
"customer": {
"data": {
"id": "PARTY_ID",
"type": "party"
}
}
}
}
}
```
The response carries the tracking request ID. Its `customer` relationship references the party's linked account and is `null` when the party has none, which is the case for parties created through this API. Once the shipment exists, read the assigned party with [`GET /v2/shipments/SHIPMENT_ID/party_roles`](/docs/api-docs/api-reference/party-roles/list-shipment-party-roles).
```json Response theme={null}
{
"data": {
"id": "TRACKING_REQUEST_ID",
"type": "tracking_request",
"attributes": {
"request_type": "bill_of_lading",
"request_number": "MEDUFR030802",
"ref_numbers": [
"PO12345",
"HBL12345",
"CUSREF1234"
],
"shipment_tags": [
"camembert"
],
"scac": "MSCU"
},
"relationships": {
"tracked_object": {
"data": null
},
"customer": {
"data": null
}
},
"links": {
"self": "/v2/tracking_requests/TRACKING_REQUEST_ID"
}
}
}
```
## Create a party for a customer
To add a customer to a tracking request, you first need to create a party. You can create a party through the [API](/docs/api-docs/api-reference/parties/create-a-party).
Endpoint: **POST** - [https://api.terminal49.com/v2/parties](/docs/api-docs/api-reference/parties/create-a-party)
```json Request theme={null}
{
"data": {
"type": "party",
"attributes": {
"company_name": "COMPANY NAME"
}
}
}
```
After you send a **POST** request to create a party, you will receive a response with the Party ID. You can use this Party ID to add the customer to a tracking request.
```json Response theme={null}
{
"data": {
"id": "PARTY_ID",
"type": "party",
"attributes": {
"company_name": "COMPANY NAME"
}
}
}
```
## Edit a party
You can update existing parties through the [API](/docs/api-docs/api-reference/parties/edit-a-party).
Endpoint: **PATCH** - [https://api.terminal49.com/v2/parties/PARTY\_ID](/docs/api-docs/api-reference/parties/edit-a-party)
## Read a party
You can retrieve the details of an existing party through the [API](/docs/api-docs/api-reference/parties/get-a-party).
Endpoint: **GET** - [https://api.terminal49.com/v2/parties/PARTY\_ID](/docs/api-docs/api-reference/parties/get-a-party)
# Assign Shipper, Consignee, and Other Parties
Source: https://terminal49.com/docs/api-docs/in-depth-guides/assigning-parties
Set the shipper, consignee, freight forwarder, notify party, customs broker, or dray carrier on shipments and containers through the Terminal49 API.
Use this guide to assign parties to your shipments from code instead of the dashboard bulk update.
## How parties and roles work
* A **party** is a company in your account, managed through [`/v2/parties`](/docs/api-docs/api-reference/parties/list-parties).
* A **party role** links one party to one shipment or container in one role.
* Roles are a list, not a field. A shipment can carry two parties as `consignee`. To replace a party, remove its role and assign a new one.
| Role | Tracking request creation | Shipment | Container |
| --------------------- | ------------------------- | -------- | --------- |
| `customer` | Yes | Yes | No |
| `shipper` | Yes | Yes | No |
| `consignee` | Yes | Yes | No |
| `notify_party` | Yes | Yes | No |
| `customs_broker` | Yes | Yes | No |
| `freight_forwarder` | Yes | Yes | No |
| `pickup_dray_carrier` | Yes | Yes | Yes |
You need an API key. See [Start here](/docs/api-docs/getting-started/start-here).
## Find or create the party
Search your parties by name:
```bash theme={null}
curl -s "https://api.terminal49.com/v2/parties?query=ACME" \
-H "Authorization: Token YOUR_API_KEY"
```
If the party does not exist, [create it](/docs/api-docs/api-reference/parties/create-a-party):
```json Request theme={null}
{
"data": {
"type": "party",
"attributes": {
"company_name": "ACME LOGISTICS"
}
}
}
```
Keep the returned `data.id`. It is the `PARTY_ID` below.
## Assign roles when creating the tracking request
Pass each party as a relationship named after its role. One party per role.
Endpoint: **POST** - [https://api.terminal49.com/v2/tracking\_requests](/docs/api-docs/api-reference/tracking-requests/create-a-tracking-request)
```json Request theme={null}
{
"data": {
"type": "tracking_request",
"attributes": {
"request_type": "bill_of_lading",
"request_number": "MEDUFR030802",
"scac": "MSCU"
},
"relationships": {
"customer": { "data": { "id": "CUSTOMER_PARTY_ID", "type": "party" } },
"shipper": { "data": { "id": "SHIPPER_PARTY_ID", "type": "party" } },
"consignee": { "data": { "id": "CONSIGNEE_PARTY_ID", "type": "party" } },
"freight_forwarder": { "data": { "id": "FORWARDER_PARTY_ID", "type": "party" } }
}
}
}
```
The roles are copied to the shipment when it is created. The tracking request response does not list them; read them on the shipment as shown below. A party from another account fails the whole request with `422` and a pointer to the relationship.
## Assign roles to an existing shipment
Use this to add roles later, or to add a second party in the same role.
Endpoint: **POST** - [https://api.terminal49.com/v2/shipments/SHIPMENT\_ID/party\_roles](/docs/api-docs/api-reference/party-roles/assign-a-shipment-party-role)
```json Request theme={null}
{
"data": {
"type": "party_role",
"attributes": {
"role": "shipper"
},
"relationships": {
"party": {
"data": {
"id": "PARTY_ID",
"type": "party"
}
}
}
}
}
```
```json Response theme={null}
{
"data": {
"id": "PARTY_ROLE_ID",
"type": "party_role",
"attributes": {
"role": "shipper",
"roleable_type": "Shipment",
"roleable_id": "SHIPMENT_ID",
"created_at": "2026-09-01T14:02:11Z",
"updated_at": "2026-09-01T14:02:11Z"
},
"relationships": {
"party": {
"data": {
"id": "PARTY_ID",
"type": "party"
}
}
}
},
"included": [
{
"id": "PARTY_ID",
"type": "party",
"attributes": {
"company_name": "ACME LOGISTICS"
}
}
]
}
```
Send one request per role. Repeat with `"role": "consignee"` and `"role": "freight_forwarder"`.
## Read the roles on the shipment
Request the shipment with `flag[parties]=true` and include the parties:
```bash theme={null}
curl -sg "https://api.terminal49.com/v2/shipments/SHIPMENT_ID?flag[parties]=true&include=party_roles.party" \
-H "Authorization: Token YOUR_API_KEY"
```
The shipment carries a `party_roles` relationship and the `included` array holds each `party_role` and its `party`. The same flag works on `GET /v2/shipments`. `-g` stops curl from treating the brackets in `flag[parties]` as a range.
To list the roles alone, use [`GET /v2/shipments/SHIPMENT_ID/party_roles`](/docs/api-docs/api-reference/party-roles/list-shipment-party-roles).
## Replace a party
To replace the consignee:
1. List the roles and find the `party_role` with `"role": "consignee"`.
2. **DELETE** [https://api.terminal49.com/v2/shipments/SHIPMENT\_ID/party\_roles/PARTY\_ROLE\_ID](/docs/api-docs/api-reference/party-roles/remove-a-shipment-party-role). Returns `204`.
3. **POST** the new consignee.
## Assign a dray carrier to a container
Containers accept one role, `pickup_dray_carrier`:
Endpoint: **POST** - [https://api.terminal49.com/v2/containers/CONTAINER\_ID/party\_roles](/docs/api-docs/api-reference/party-roles/assign-a-container-party-role)
Read it back with [`GET /v2/containers/CONTAINER_ID/party_roles`](/docs/api-docs/api-reference/party-roles/list-container-party-roles).
## Errors
| Status | Cause | Fix |
| ------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------- |
| `401` | The party or the record belongs to another account | Use a party from `GET /v2/parties` and a shipment you created |
| `404` | Unknown shipment, container, or party role ID | Check the ID |
| `422` `Party has already been taken` | The same party already has this role on the record | Nothing to do, the role is set |
| `422` `'x' is not a valid role` | Unknown role name | Use a role from the table above |
| `422` `Role is not allowed for Cargo` | A role other than `pickup_dray_carrier` on a container | Assign it on the shipment instead |
# Identify your carrier with Infer Tracking Number
Source: https://terminal49.com/docs/api-docs/in-depth-guides/auto-detect-carrier
Use the Terminal49 Infer Tracking Number endpoint to identify a carrier SCAC from a bill of lading or container number before you create a tracking request.
**Beta Feature** — This guide covers the [Infer Tracking
Number](/docs/api-docs/api-reference/tracking-requests/auto-detect-carrier)
endpoint (also known as Auto-Detect Carrier), currently in beta. The API is
stable for production use, but features may expand based on feedback.
Every tracking request requires two things: **your tracking number** and **the shipping line's (carrier's) SCAC code**.
But what if you don't know the SCAC? That's where Infer Tracking Number comes in.
You've seen this feature in action — when you enter a number, Terminal49 auto-suggests
the carrier. Now this same intelligence is available via API.
## Why SCAC matters
To track a shipment or container, Terminal49 needs to know **which shipping line to ask** (also called the vessel-operating common carrier (VOCC)).
The SCAC (Standard Carrier Alpha Code) used here is the **shipping line SCAC for tracking** — that is, the carrier operating the move you're querying for events and shipment data.
| You Have | You Need | The Challenge |
| ------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| Bill of Lading: `MAEU123456789` | Shipping line SCAC (VOCC SCAC) | Many MBOLs **do not include a prefix**, and even when they do, it may not reliably identify the shipping line you need for tracking. |
| Container: `WHLU1234560` | Shipping line SCAC (VOCC SCAC) | The container owner code / leasing company is not always the carrier moving it, so the prefix alone is not enough. |
| Booking: `987654321` | Shipping line SCAC (VOCC SCAC) | Booking formats vary widely and often contain no carrier identifier. |
Without the correct **shipping line SCAC (VOCC SCAC)**, your tracking request can fail even if the
number is valid. Infer Tracking Number predicts the shipping line SCAC + number type to
increase the likelihood your tracking request succeeds.
## How Infer Tracking Number helps
Submit any tracking number, and the API returns:
* **The predicted shipping line (SCAC)** — so you don't have to guess
* **The number type** — container, bill of lading, or booking
* **Validation results** — catches typos and invalid formats before you submit
Just the number — no need to specify the shipping line or type
Terminal49 uses machine learning and historical data from millions of
shipments to predict the shipping line.
Use high-confidence results automatically, or prompt users to confirm
With the right SCAC, your tracking request is far more likely to succeed
## Examples by number type
Container numbers follow the ISO 6346 format. While the first three letters (owner code) often indicate the owner, the container might be moved by a different shipping line (VOCC).
Terminal49 analyzes the number against tens of millions of historical records to predict which shipping line is moving the container.
**Example Input:** `MSCU1234567`
```json Request theme={null}
{
"number": "MSCU1234567"
}
```
```json Response theme={null}
{
"data": {
"attributes": {
"number_type": "container",
"validation": {
"is_valid": true,
"type": "container",
"check_digit_passed": true
},
"shipping_line": {
"decision": "auto_select",
"selected": {
"scac": "MSCU",
"name": "Mediterranean Shipping Company",
"confidence": 1.0
},
"candidates": [
{
"scac": "MSCU",
"name": "Mediterranean Shipping Company",
"confidence": 1.0
}
]
}
}
}
}
```
For container numbers, Terminal49 uses historical data to identify the shipping line with high accuracy (9/10 times).
Always check the `decision` field to know if you should ask the user for confirmation.
**What to do next:**
```bash theme={null}
# Create tracking request with the detected SCAC
POST /tracking_requests
{
"data": {
"type": "tracking_request",
"attributes": {
"request_number": "MSCU1234567",
"scac": "MSCU",
"request_type": "container"
}
}
}
```
Bill of lading numbers vary by carrier. Some contain prefixes, but others don't. The API uses machine learning to identify the carrier pattern.
**Example Input:** `MAEU123456789`
```json Request theme={null}
{
"number": "MAEU123456789"
}
```
```json Response theme={null}
{
"data": {
"attributes": {
"number_type": "bill_of_lading",
"validation": {
"is_valid": true,
"type": "shipment"
},
"shipping_line": {
"decision": "auto_select",
"selected": {
"scac": "MAEU",
"name": "Maersk",
"confidence": 0.98
},
"candidates": [
{
"scac": "MAEU",
"name": "Maersk",
"confidence": 0.98
}
]
}
}
}
}
```
Maersk BLs typically start with `MAEU`, but other carriers may not have prefixes. The API analyzes the full format.
**What to do next:**
```bash theme={null}
# Create tracking request with the detected SCAC
POST /tracking_requests
{
"data": {
"type": "tracking_request",
"attributes": {
"request_number": "MAEU123456789",
"scac": "MAEU",
"request_type": "bill_of_lading"
}
}
}
```
Booking numbers are the **hardest to identify** — they often don't contain carrier identifiers.
**Example Input:** `987654321`
```json Request theme={null}
{
"number": "987654321"
}
```
```json Response theme={null}
{
"data": {
"attributes": {
"number_type": "booking",
"validation": {
"is_valid": null,
"type": "shipment"
},
"shipping_line": {
"decision": "needs_confirmation",
"selected": {
"scac": "HLCU",
"name": "Hapag-Lloyd",
"confidence": 0.72
},
"candidates": [
{
"scac": "HLCU",
"name": "Hapag-Lloyd",
"confidence": 0.72
},
{
"scac": "ONE",
"name": "Ocean Network Express",
"confidence": 0.18
}
]
}
}
}
}
```
When `decision` is `needs_confirmation`, show the suggestion but **ask the user to verify**.
Display the `candidates` list as options.
**What to do next:**
```bash theme={null}
# Show user the suggested carrier and candidates
# After user confirms, create tracking request
POST /tracking_requests
{
"data": {
"type": "tracking_request",
"attributes": {
"request_number": "987654321",
"scac": "HLCU",
"request_type": "booking"
}
}
}
```
## Understanding the response
The `decision` field tells you how confident the prediction is and what action to take:
| Decision | When it's used | What to do |
| -------------------- | ---------------------------- | ----------------------------------------------------- |
| `auto_select` | Confidence ≥ 95% | ✅ Safe to use automatically without user confirmation |
| `needs_confirmation` | Confidence 70-95% | ⚠️ Show suggestion, ask user to confirm |
| `no_prediction` | Confidence \< 70% or unknown | ❌ User must select carrier manually |
For the best user experience, always handle all three decision types. Even
when `no_prediction` is returned, you can still show the list of `candidates`
as suggestions.
The API validates numbers before returning predictions:
| Field | Description |
| -------------------- | ---------------------------------------------------------------- |
| `is_valid` | `true` if format is valid, `false` if invalid, `null` if unknown |
| `check_digit_passed` | For containers: ISO 6346 check digit verification |
| `reason` | If invalid, explains why (e.g., "Invalid check digit") |
Invalid numbers may still return a carrier prediction, but you should validate
the format before creating a tracking request.
| Field | Type | Description |
| -------------------------- | ------ | ---------------------------------------------------------- |
| `number_type` | string | Detected type: `container`, `bill_of_lading`, or `booking` |
| `shipping_line.decision` | string | Confidence level for the prediction |
| `shipping_line.selected` | object | Best match: `scac`, `name`, `confidence` |
| `shipping_line.candidates` | array | All possible matches, ranked by confidence |
See the [API Reference](/docs/api-docs/api-reference/tracking-requests/auto-detect-carrier) for complete schema details.
## Integration guide
If you're implementing this API, here are code examples in different languages:
```javascript theme={null}
async function getCarrierForNumber(trackingNumber, apiKey) {
const response = await fetch(
"https://api.terminal49.com/v2/tracking_requests/infer_number",
{
method: "POST",
headers: {
Authorization: `Token ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ number: trackingNumber }),
}
);
const { data } = await response.json();
const { decision, selected, candidates } = data.attributes.shipping_line;
return {
scac: selected?.scac,
carrier: selected?.name,
confidence: selected?.confidence,
autoSelect: decision === "auto_select",
needsConfirmation: decision === "needs_confirmation",
candidates: candidates,
};
}
// Usage
const result = await getCarrierForNumber("MSCU1234567", "YOUR_API_KEY");
if (result.autoSelect) {
// Auto-fill carrier dropdown
carrierDropdown.value = result.scac;
} else if (result.needsConfirmation) {
// Show suggestion with confirmation prompt
showCarrierSuggestion(result.carrier, result.candidates);
}
```
```python theme={null}
import requests
def get_carrier_for_number(tracking_number: str, api_key: str) -> dict:
"""Get carrier prediction for a tracking number."""
response = requests.post(
'https://api.terminal49.com/v2/tracking_requests/infer_number',
headers={
'Authorization': f'Token {api_key}',
'Content-Type': 'application/json'
},
json={'number': tracking_number}
)
result = response.json()
shipping_line = result['data']['attributes']['shipping_line']
return {
'scac': shipping_line['selected']['scac'] if shipping_line.get('selected') else None,
'carrier': shipping_line['selected']['name'] if shipping_line.get('selected') else None,
'confidence': shipping_line['selected']['confidence'] if shipping_line.get('selected') else None,
'auto_select': shipping_line['decision'] == 'auto_select',
'needs_confirmation': shipping_line['decision'] == 'needs_confirmation',
'candidates': shipping_line.get('candidates', [])
}
# Usage
result = get_carrier_for_number("MSCU1234567", "YOUR_API_KEY")
if result['auto_select']:
# Auto-fill carrier dropdown
carrier_dropdown.value = result['scac']
elif result['needs_confirmation']:
# Show suggestion with confirmation prompt
show_carrier_suggestion(result['carrier'], result['candidates'])
```
```bash theme={null}
curl -X POST https://api.terminal49.com/v2/tracking_requests/infer_number \
-H "Authorization: Token YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"number": "MSCU1234567"}'
```
## Rate limits
| Setting | Value |
| ------------------- | ------------------------------------------- |
| Requests per minute | 200 |
| Scope | Per API key, in an endpoint-specific bucket |
| Rate limit header | `Retry-After` (seconds) |
Rate limit errors return HTTP 429 with a `Retry-After` header. Respect this
header before sending another Infer Tracking Number request.
## What's next?
Full API specification with request/response schemas and try it in the
playground
Use the detected SCAC to start tracking your shipment
See which carriers Terminal49 supports and their data availability
Learn what happens after you submit a tracking request
# Terminal49 Container Statuses
Source: https://terminal49.com/docs/api-docs/in-depth-guides/container-statuses
Understand every Terminal49 container status value, from on-ship to empty-returned, so your integration can interpret each shipment lifecycle stage.
The `current_status` attribute on container objects provides a high-level view of where a container is in its journey. This guide explains the different status values and their meanings.
The API returns the raw backend value (for example `grounded`). The Terminal49 dashboard often displays a friendlier label for the same value (for example **At Terminal**), so each status below lists both. Integrations should always key off the backend `current_status` value, not the dashboard label.
## Quick reference
| `current_status` (API) | Dashboard label | Meaning |
| -------------------------- | ------------------------ | ----------------------------------------------- |
| `new` | No Status | Tracking started, no status milestone yet |
| `on_ship` | On Ship | On the vessel |
| `grounded` | At Terminal | Discharged at the terminal |
| `available` | Available | Available for pickup |
| `not_available` | Not available | At the terminal but held / not released |
| `awaiting_inland_transfer` | Awaiting Inland Transfer | Discharged at POD, awaiting the inland rail leg |
| `on_rail` | On Rail | On a rail car to the inland destination |
| `off_dock` | Available at Shippers | Moved to an off-dock / shipper's-own yard |
| `picked_up` | Picked up | Picked up by a trucker |
| `delivered` | Delivered | Delivered (manually marked) |
| `empty_returned` | Empty Returned | Returned empty; tracking ends |
## Status values
### new
**Dashboard label: "No Status"**
**Default state** — The container is being tracked but no status milestone has been received yet. This is the initial state when tracking begins, typically before the container has been loaded onto a vessel at the port of lading.
### on\_ship
**Dashboard label: "On Ship"**
**In transit by vessel** — The container is on a vessel. In the lifecycle this status first appears when the container is loaded or the vessel departs the port of lading, and it persists through the ocean voyage until the container is discharged at the Port of Discharge (POD).
Triggered by the *vessel loaded* or *vessel departed* milestone (and kept by subsequent on-vessel milestones such as *vessel arrived*, *vessel berthed*, and transshipment events).
### grounded
**Dashboard label: "At Terminal"**
**At the terminal** — The container has been discharged and is physically at the terminal, but its availability for pickup has not yet been confirmed (the terminal isn't yet providing availability data).
* For containers **without** an inland destination: discharged at the POD terminal. Triggered by the *vessel discharged* milestone.
* For containers **with** an inland destination: unloaded from the rail car at the inland terminal. Triggered by the *rail unloaded* milestone.
### available
**Dashboard label: "Available"**
**Ready for pickup** — The container has arrived at the POD or inland destination and is confirmed available for pickup, with no clearance issues preventing it from leaving the facility. Triggered by the *available* milestone (or terminal availability data). For a definitive readiness check that combines `available_for_pickup` with hold data, see [Container Holds, Fees, and Release Readiness](/docs/api-docs/in-depth-guides/holds-and-fees).
### not\_available
**Dashboard label: "Not available"**
**At the terminal but not ready** — The container is at the POD or inland destination but has not been cleared to leave. This could be due to:
* Terminal holds
* Customs holds
* Line holds
* Documentation requirements
* Other restrictions
While a hold is in place, the terminal will not release the container for pickup until it is cleared, and the LFD countdown continues (which can result in demurrage fees). Triggered by the *not available* milestone from the terminal. See [Container Holds, Fees, and Release Readiness](/docs/api-docs/in-depth-guides/holds-and-fees) for details on specific hold types and how to determine when the container is released.
### awaiting\_inland\_transfer
**Dashboard label: "Awaiting Inland Transfer"**
**Awaiting the inland leg** — The container has been discharged at the POD but still has an inland destination, so it is waiting to be loaded onto rail for the inland move. Triggered by the *vessel discharged* milestone when the shipment has an inland destination distinct from the POD. This status is specific to shipments with inland rail movements.
### on\_rail
**Dashboard label: "On Rail"**
**In transit by rail** — The container has been loaded onto a rail car and is being transported toward its inland destination after being discharged from the POD. Triggered by the *rail departed* or *rail loaded* milestone.
### off\_dock
**Dashboard label: "Available at Shippers"**
**At an off-dock facility** — The terminal reported that the container has moved off the main terminal to an off-dock or shipper's-own yard, where it's available for pickup. When this happens, Terminal49 attempts to identify the off-dock facility and re-point tracking to it, so this status can also appear briefly during that transition before status is pulled from the new facility. This value comes from terminal data (not the steamship line) and is uncommon.
### picked\_up
**Dashboard label: "Picked up"**
**Out for delivery** — The container has been picked up by a trucker from the POD or inland destination and is on its way to the warehouse. Triggered by the *full out* milestone.
### delivered
**Dashboard label: "Delivered"**
**Delivery confirmed** — The container has been delivered to the warehouse. This status is only set when delivery is [manually marked as delivered](https://help.terminal49.com/articles/4713318249-how-to-mark-containers-as-delivered) through the Terminal49 dashboard, because delivery date/time data is only available to the customer (Terminal49 does not have access to it).
### empty\_returned
**Dashboard label: "Empty Returned"**
**Container returned empty** — The container has been emptied and returned to the shipping line or designated return location, completing its journey. Terminal49 stops tracking the container at this point. Triggered by the *empty returned* or *empty in* milestone.
## Important considerations
### Status accuracy
The logic to derive container statuses is complex and involves processing data from multiple sources, including:
* Shipping line updates
* Terminal systems
* Rail carrier feeds
* Manual updates
**There can sometimes be errors in the reported `current_status`.** When making critical, time-sensitive business decisions, consider:
* Cross-referencing with the container's transport events
* Contacting the terminal directly for time-sensitive pickups
### Status transitions
Containers don't always follow a linear path through these statuses. For example:
* A container may go from `on_ship` directly to `available` if terminal data arrives quickly
* A container might alternate between `available` and `not_available` as holds are placed and removed
* The status may remain `new` for some time if data from the shipping line is delayed
### API usage
To get the current status of a container, read the container's `current_status` attribute in your API responses:
```bash theme={null}
GET /v2/containers/{id}
```
The response will include:
```json theme={null}
{
"data": {
"id": "ff77a822-23a7-4ccd-95ca-g534c071baaf3",
"type": "container",
"attributes": {
"number": "KOCU4959010",
"current_status": "available",
...
}
}
}
```
# Direct Links to Shipments and Containers
Source: https://terminal49.com/docs/api-docs/in-depth-guides/dashboard-deep-linking
Link directly to a shipment or container tracking page using container numbers, BOL numbers, or references — no internal IDs needed.
You can link directly to any shipment or container in Terminal49 using an identifier you already have — a container number, bill of lading, booking number, or any reference number you've attached to a shipment. The link resolves to the correct tracking page automatically, so your team doesn't need to know Terminal49's internal UUIDs. This is especially useful when linking from a TMS, ERP, spreadsheet, internal tool, or automated notification.
## URL format
The deep link URL pattern is:
```
https://app.terminal49.com/shipments/find?q={identifier}
```
Replace `{identifier}` with the container number, BOL, booking number, or reference number you want to look up.
| Identifier type | Example | URL |
| --------------------- | --------------- | ----------------------------------------------------------- |
| Container number | `TCLU6718159` | `https://app.terminal49.com/shipments/find?q=TCLU6718159` |
| Bill of lading number | `MEDUFR030802` | `https://app.terminal49.com/shipments/find?q=MEDUFR030802` |
| Booking number | `BKG12345678` | `https://app.terminal49.com/shipments/find?q=BKG12345678` |
| Reference number | `PO-2024-00123` | `https://app.terminal49.com/shipments/find?q=PO-2024-00123` |
The query is case-insensitive and normalizes formatting — spaces and dashes in container numbers are stripped automatically, so `MSCU 1234567` and `MSCU1234567` both resolve to the same container.
## How resolution works
The deep link searches your Terminal49 account and redirects to the best match, in this order:
1. **Exact container number match** → Opens the shipment detail page with that container selected
2. **Exact shipment number or reference number match** → Opens that shipment's detail page
3. **First container result (partial match)** → Opens the associated shipment with that container selected
4. **First shipment result (partial match)** → Opens that shipment
5. **No match found** → Falls back to the shipments list with the search query pre-filled, so the user can refine manually
The deep link only matches shipments and containers tracked in your Terminal49 account. If an identifier isn't found, check that the shipment is actively tracked and that the reference number has been added.
When multiple shipments match (e.g., the same reference number on two shipments), the deep link resolves to the first match. Use unique reference numbers to ensure deterministic resolution.
## Use cases
### Link from a TMS or ERP
If your TMS or ERP stores container numbers or purchase order numbers, you can build a deep link for each record. This gives your logistics team one-click access to real-time tracking data without leaving their primary workflow.
For example, if your system stores a container number per shipment record, construct the URL as:
```
https://app.terminal49.com/shipments/find?q={container_number}
```
Most TMS platforms support configurable URL fields or "external link" columns — configure one pointing to this URL pattern.
### Link from a spreadsheet
Add a formula column to your shipment spreadsheet that generates a clickable Terminal49 link. Works in Google Sheets, Excel, and most spreadsheet tools.
```
=HYPERLINK("https://app.terminal49.com/shipments/find?q=" & A2, "View in Terminal49")
```
Where `A2` contains the container number, BOL, or reference number.
### Link from Slack or email notifications
Include deep links in automated alerts so recipients can jump directly to the relevant shipment:
```
Container TCLU6718159 has been discharged at port.
View details: https://app.terminal49.com/shipments/find?q=TCLU6718159
```
This works in Slack messages, email templates, PagerDuty alerts, or any notification channel that renders URLs as clickable links.
### Link from webhook handlers
When processing Terminal49 webhook events, you already have the container number or BOL in the payload. Construct a deep link to include in your internal tools, ticketing systems, or dashboards:
```javascript theme={null}
function buildDashboardLink(containerNumber) {
return `https://app.terminal49.com/shipments/find?q=${encodeURIComponent(containerNumber)}`;
}
```
### Link from internal dashboards or BI tools
Embed deep links in Looker, Metabase, Retool, or similar tools. For example, in a SQL-based dashboard, construct the link in your query:
```sql theme={null}
SELECT
container_number,
CONCAT('https://app.terminal49.com/shipments/find?q=', container_number) AS terminal49_link
FROM shipments
```
### Customer portal integration
If you build a customer-facing portal, deep link your customers directly to their shipment status in Terminal49 using the PO number or reference they already know. No need to store or expose Terminal49 internal IDs.
## Supported identifiers
The deep link matches against these identifier types:
* **Container number** — the standard ISO container number (e.g., `TCLU6718159`). Matched against tracked containers in your account.
* **Master bill of lading** — the original BOL number used to create the tracking request.
* **Booking number** — the carrier booking reference.
* **Reference numbers** — any custom reference numbers you've added to the shipment or container (e.g., purchase order numbers, house bill of lading numbers, internal IDs).
## Adding reference numbers
To deep link using your internal identifiers, attach reference numbers to shipments or containers in Terminal49. There are three ways to do this.
### When creating a tracking request
Include `ref_numbers` in the tracking request payload:
```json theme={null}
{
"data": {
"type": "tracking_request",
"attributes": {
"request_type": "bill_of_lading",
"request_number": "MEDUFR030802",
"scac": "MSCU",
"ref_numbers": ["PO-2024-00123", "HBL-5678"]
}
}
}
```
See [Create a tracking request](/docs/api-docs/api-reference/tracking-requests/create-a-tracking-request) for full details.
### By editing a shipment
```bash theme={null}
curl -X PATCH https://api.terminal49.com/v2/shipments/{shipment_id} \
-H "Content-Type: application/vnd.api+json" \
-H "Authorization: Token YOUR_API_KEY" \
-d '{
"data": {
"type": "shipment",
"attributes": {
"ref_numbers": ["PO-2024-00123", "HBL-5678"]
}
}
}'
```
See [Edit a shipment](/docs/api-docs/api-reference/shipments/edit-a-shipment) for full details.
### By editing a container
```bash theme={null}
curl -X PATCH https://api.terminal49.com/v2/containers/{container_id} \
-H "Content-Type: application/vnd.api+json" \
-H "Authorization: Token YOUR_API_KEY" \
-d '{
"data": {
"type": "container",
"attributes": {
"ref_numbers": ["PO-2024-00123"]
}
}
}'
```
See [Edit a container](/docs/api-docs/api-reference/containers/edit-a-container) for full details.
Reference numbers added at tracking request creation are propagated to both the shipment and its containers. You can also add different reference numbers to individual containers for more granular deep linking.
## Related guides
Embed tracking on your website
Get notified when shipments update
Understand request statuses
# Direct Upload for Documents
Source: https://terminal49.com/docs/api-docs/in-depth-guides/direct-upload-documents
Upload files with the Terminal49 direct upload flow, retrieve a signed_id, and create document resources linked to your shipments or containers.
Use this guide when your app needs to upload a file first and then create a Terminal49 document.
Any client stack can use this flow as long as it can make standard HTTP requests.
## Overview
1. Request a direct upload blob payload from Terminal49.
2. Upload the file bytes to the returned `direct_upload.url` using the returned headers.
3. Store the returned `signed_id`.
4. Create a Terminal49 document with `attached_document = signed_id`.
## 1) Request a direct upload blob
Endpoint: `POST /rails/active_storage/direct_uploads`
Send metadata for the file you want to upload:
```json theme={null}
{
"blob": {
"filename": "1462486 order.pdf",
"content_type": "application/pdf",
"byte_size": 35672,
"checksum": "tZTfawHSrI1hiuOZQ0cQRg=="
}
}
```
### Blob attributes explained
| Attribute | Type | What it is | How to create it |
| -------------- | ------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
| `filename` | string | Original file name users see. | Use the file name from the uploaded file (for example `1462486 order.pdf`). |
| `content_type` | string | MIME type of the file. | Detect from file extension or file bytes (for PDF use `application/pdf`). |
| `byte_size` | integer | Exact file size in bytes. | Read the file size from your filesystem or uploaded file object. |
| `checksum` | string | Base64-encoded MD5 digest of the raw file bytes. | Compute MD5 on file bytes, then Base64-encode the binary MD5 result. |
### Example ways to generate values
Get file size in bytes:
```bash theme={null}
wc -c < "1462486 order.pdf"
```
Compute checksum (`Base64(MD5(file_bytes))`):
```bash theme={null}
openssl md5 -binary "1462486 order.pdf" | openssl base64
```
`checksum` must match the exact bytes you upload in step 2, or the upload will fail.
Example response:
```json theme={null}
{
"id": "96b6d878-0341-4ce3-8b3c-06767f6f08eb",
"key": "883c4cf4-b086-4698-a620-5ffa16cc95ef/pqjug51un0gobs6x72ez0q9e4so4",
"filename": "1462486 order.pdf",
"content_type": "application/pdf",
"metadata": {},
"service_name": "amazon",
"byte_size": 35672,
"checksum": "tZTfawHSrI1hiuOZQ0cQRg==",
"created_at": "2026-03-26T18:49:37Z",
"signed_id": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaEpJaWs1Tm1JMlpEZzNPQzB3TXpReExUUmpaVE10T0dJell5MHdOamMyTjJZMlpqQTRaV0lHT2daRlZBPT0iLCJleHAiOm51bGwsInB1ciI6ImJsb2JfaWQifX0=--f5605d2c1e90ce3b54ef1a193f84530f954184a5",
"direct_upload": {
"url": "https://...s3.amazonaws.com/...signature...",
"headers": {
"Content-Type": "application/pdf",
"Content-MD5": "tZTfawHSrI1hiuOZQ0cQRg==",
"Content-Disposition": "inline; filename=\"1462486 order.pdf\"; filename*=UTF-8''1462486%20order.pdf"
}
}
}
```
## 2) Upload the file bytes to `direct_upload.url`
Use `direct_upload.url` to send the file, and send the `direct_upload.headers` object as request headers. Use whatever response is returned by that upload request.
```bash theme={null}
curl -X PUT "$DIRECT_UPLOAD_URL" \
-H "Content-Length: 35672" \
-H "Content-Type: application/pdf" \
-H "Content-MD5: tZTfawHSrI1hiuOZQ0cQRg==" \
-H "Content-Disposition: inline; filename=\"1462486 order.pdf\"; filename*=UTF-8''1462486%20order.pdf" \
--data-binary @"/path/to/1462486 order.pdf"
```
## 3) Persist `signed_id` in your app
Save the `signed_id` with your internal record. You will use this value in the next step.
Do not send the S3 URL to `POST /documents`. Send `signed_id` in `attached_document`.
## 4) Create the document using `attached_document`
```json theme={null}
{
"data": {
"type": "document",
"attributes": {
"name": "1462486 order.pdf",
"attached_document": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaEpJaWs1Tm1JMlpEZzNPQzB3TXpReExUUmpaVE10T0dJell5MHdOamMyTjJZMlpqQTRaV0lHT2daRlZBPT0iLCJleHAiOm51bGwsInB1ciI6ImJsb2JfaWQifX0=--f5605d2c1e90ce3b54ef1a193f84530f954184a5"
}
}
}
```
Endpoint: [`POST /documents`](/docs/api-docs/api-reference/documents/upload-a-document)
# Document processing workflows
Source: https://terminal49.com/docs/api-docs/in-depth-guides/document-processing-workflows
Submit shipping documents by email or API, then consume structured extraction results via webhooks in your Terminal49 document processing workflow.
Shipping documents (House Bills of Lading, Master Bills of Lading, arrival notices, delivery orders, and more) arrive in shared inboxes and have traditionally required manual classification, data entry, and filing. This integration automates that workflow: Terminal49 receives each document by email, classifies it, extracts structured fields, and delivers the results to your system via webhook.
The outcome is less manual re-keying, faster time-to-file, and fewer errors from misfiled or delayed documents, freeing your team from routine data entry to focus on true exceptions.
Submit documents by emailing attachments to your account docs alias. Terminal49 then handles the rest: classify -> extract -> webhook result.
## Coming soon
* **`email_submission.created` event:** a webhook fired immediately on email receipt, before classification and extraction complete.
* **Action required flow:** documentation in progress.
## Before you start
Make sure you have the following in place before building:
Confirm you can log in and switch between the test and production accounts (see [Environments](#environments) below).
Generate an API key from **User > Developers > API Keys**. You'll need this to register your webhook and call the API.
Your server needs a reachable HTTPS URL to receive webhook POST requests from Terminal49. For local development, use a tool like [ngrok](https://ngrok.com) to expose a local port.
Register your endpoint and subscribe to `document_representation.created` and `document_representation.failed` (see [Subscribing to events](#subscribing-to-events) below).
## Environments
Your Terminal49 account may have separate test and production environments, accessible from the account switcher in the top-left corner when you log in.
Both environments make live calls. Documents submitted under either account are processed and costs will be incurred. There is no free sandbox for document processing at this time.
## Authentication
All API calls require an API key passed as a Bearer token:
```
Authorization: Token YOUR_API_KEY
```
To get your API key, go to **User > Developers > API Keys** (click your username in the bottom-left corner of the navigation). For more detail, see [Start Here](/docs/api-docs/getting-started/start-here).
## Subscribing to events
Register a webhook endpoint to receive document processing notifications:
```bash theme={null}
curl -X POST https://api.terminal49.com/v2/webhooks \
-H "Authorization: Token YOUR_API_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "webhook",
"attributes": {
"url": "https://your-server.com/webhooks/t49-documents",
"active": true,
"events": [
"document_representation.created",
"document_representation.failed"
]
}
}
}'
```
You can also configure webhooks from the Terminal49 dashboard:
1. Click your username in the bottom-left corner of the navigation.
2. Go to **User > Developers > Webhooks**.
3. To add a new endpoint, click **Create Webhook**, fill in your URL, and select the relevant events under **Document Events**.
4. To update an existing endpoint, click into it and toggle on the document events you need.
## Webhook endpoint requirements
Your endpoint must meet the following requirements to reliably receive webhook notifications:
**Response codes:** Return HTTP `200`, `201`, `202`, or `204`. Any other response (including a timeout) is treated as a delivery failure and will trigger retries.
**Retries:** Terminal49 will retry failed deliveries multiple times. Design your endpoint to be idempotent. Use `data.id` (the `webhook_notification` UUID) as your idempotency key to avoid processing the same event twice.
**HTTPS:** Your endpoint must be accessible over HTTPS.
**IP allowlist:** Webhook notifications are sent from the following IP addresses. Allowlist these if your infrastructure restricts inbound traffic:
```
35.222.62.171
3.230.67.145
44.217.15.129
```
**Signature verification (recommended):** Each webhook is signed using HMAC SHA-256. The signature is included in the `X-T49-Webhook-Signature` header. To verify, retrieve the `secret` from your webhook configuration and compute the HMAC digest of the raw request body; it should match the header value.
```ruby theme={null}
secret = ENV.fetch('T49_WEBHOOK_SECRET')
hmac = OpenSSL::HMAC.hexdigest('SHA256', secret, request.body.read)
verified = request.headers['X-T49-Webhook-Signature'] == hmac
```
## Workflow diagrams
```mermaid theme={null}
flowchart LR
A[Email sent to docs alias] --> B[Terminal49 receives document]
B --> C{Duplicate?}
C -- Yes --> Z[Ignored, no webhook fired]
C -- No --> D[Terminal49 classifies document]
D --> E[Terminal49 extracts structured data]
E --> F{Extraction outcome}
F -- Success --> G[document_representation.created]
F -- Failure --> H[document_representation.failed]
```
## Workflow: step-by-step
Email attachments to your account's unique docs alias (for example, `youraccount-42@docs.terminal49.com`). Find your alias under **User > Developers > API Keys**.
**Supported file types:** PDF, PNG, JPEG, XLSX, XLS, CSV, Word (.doc, .docx).
**Multiple attachments:** Each attachment in a single email is processed independently and generates its own webhook event. All resulting webhooks reference the same `email_submission`.
**Unsupported files:** Encrypted or password-protected files cannot be processed and will result in a `document_representation.failed` event.
Terminal49 classifies and extracts structured data asynchronously. Processing typically completes within seconds to a few minutes depending on document complexity.
You receive `document_representation.created` (extraction succeeded) or `document_representation.failed` (extraction could not complete). Parse the payload, route by `document_type`, store the extracted fields, and trigger your downstream processes.
Treat submission as fire-and-forget. Do not poll or wait for a response after sending the email. The webhook is the signal that processing is complete.
If the same file content has already been processed for your account, it is treated as a duplicate and ignored. No webhook is fired.
## Webhooks you should handle
A `document_representation` is the structured extraction result for a document. `document_representation.created` means extraction succeeded and structured data is available in the payload. `document_representation.failed` means Terminal49 could not produce an extraction result.
| Event | Meaning | Signal |
| --------------------------------- | --------------------------------- | ------------------------------------------------------------------------ |
| `document_representation.created` | Extraction completed successfully | `document_type` is set; `payload` contains extracted fields |
| `document_representation.failed` | Extraction did not complete | `document_type` is `"unknown"`; `last_document_representation` is `null` |
## Webhook payload structure
Every document webhook follows the same envelope structure. The `payload` object inside `document_representation` contains the extracted fields and varies by document type. See [Document Types in Scope](#document-types-in-scope) for full examples.
`document_representation.created` envelope:
```json theme={null}
{
"data": {
"id": "89ec3520-cea3-447d-8404-341e0bfd3aa6",
"type": "webhook_notification",
"attributes": {
"event": "document_representation.created",
"delivery_status": "pending",
"created_at": "2026-03-27T20:05:39Z"
},
"relationships": {
"reference_object": {
"data": {
"id": "e75541c0-9ad5-408b-9747-23415adfbca0",
"type": "document"
}
}
}
},
"included": [
{
"id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f",
"type": "document_representation",
"attributes": {
"schema_version": "draft_house_bill_of_lading@2026-03-23",
"payload": {},
"created_at": "2026-03-27T20:05:39Z",
"updated_at": "2026-03-27T20:05:39Z"
}
},
{
"id": "e75541c0-9ad5-408b-9747-23415adfbca0",
"type": "document",
"attributes": {
"document_type": "draft_house_bill_of_lading",
"source": "email",
"file_name": "invoice.pdf",
"file_url": "https://t49-documents-prod.s3.amazonaws.com/..."
},
"relationships": {
"email_submission": {
"data": {
"id": "7de2c356-5d2a-4d6e-99f4-6f0d2d63e357",
"type": "email_submission"
}
},
"last_document_representation": {
"data": {
"id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f",
"type": "document_representation"
}
}
}
}
]
}
```
When the webhook document is a child document (split from a larger packet), the document include also contains `attributes.parsed.packetSegment` and a `parent_document` relationship:
```json theme={null}
{
"id": "child-document-id",
"type": "document",
"attributes": {
"document_type": "arrival_notice",
"file_name": "packet_child_1.pdf",
"file_url": "https://t49-documents-prod.s3.amazonaws.com/...",
"parsed": {
"packetSegment": {
"startPage": 3,
"endPage": 7
}
}
},
"relationships": {
"parent_document": {
"data": {
"id": "parent-document-id",
"type": "document"
}
},
"last_document_representation": {
"data": {
"id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f",
"type": "document_representation"
}
}
}
}
```
`file_url` is a pre-signed S3 URL and expires after 1 hour. Download the file promptly after receiving the webhook, or fetch a fresh URL using the endpoint below.
### Fetching a fresh download URL
If the `file_url` from the webhook has expired, request a new one:
```bash theme={null}
curl -X GET https://api.terminal49.com/v2/documents/{id}/download_url \
-H "Authorization: Token YOUR_API_KEY"
```
Replace `{id}` with the document `id` from the webhook payload. Response:
```json theme={null}
{
"download_url": "https://t49-documents-prod.s3.amazonaws.com/..."
}
```
### Schema versioning
Every webhook payload includes a `schema_version` field that identifies the document type and the schema date in use:
```
"schema_version": "draft_house_bill_of_lading@2026-03-23"
```
Your account is pinned to a specific schema date. All document types will use the latest schema version up to and including that date. Terminal49 can update your pinned version when you are ready to migrate.
**What changes the version:**
* Breaking changes (fields removed, renamed, or restructured) increment the date. Terminal49 will either support parallel versions during a migration window or coordinate a cutover date with you.
* Non-breaking additions (new optional fields) do not change the version.
Use `schema_version` to route your parsing logic. If you support multiple versions, branch on this field.
### Persisting extracted data
Use the `document_type` and `schema_version` to look up the expected `payload` fields for that document type, then store the extracted data in your system.
### Handling a failed extraction
If extraction fails, you will receive `document_representation.failed` instead. The key signal is `"document_type": "unknown"` means the document was received but could not be classified or extracted. There is no `document_representation` in `included` and `last_document_representation` will be `null`.
```json theme={null}
{
"data": {
"id": "014551bd-32c8-46c1-b17c-3a9f1984e39f",
"type": "webhook_notification",
"attributes": {
"event": "document_representation.failed",
"delivery_status": "pending",
"created_at": "2026-03-27T20:46:56Z"
},
"relationships": {
"reference_object": {
"data": {
"id": "31e9df4a-7539-4b44-8409-8e9c350d2ac7",
"type": "document"
}
}
}
},
"included": [
{
"id": "90df411d-b836-498b-bf0d-b320d56ab311",
"type": "email_submission",
"attributes": {
"subject": "[ediDocManager SHP HBL MBL HLCUSHA2601APKY2 / HBL CGGMSGH5110912]",
"from": ["sender@example.com"],
"sent_at": "2026-03-27T13:46:32-07:00"
}
},
{
"id": "31e9df4a-7539-4b44-8409-8e9c350d2ac7",
"type": "document",
"attributes": {
"document_type": "unknown",
"file_name": "f134a7229b5cf7b6c241c566448b9293_fail-1234.pdf",
"file_url": "https://t49-documents-prod.s3.amazonaws.com/..."
},
"relationships": {
"last_document_representation": {
"data": null
},
"email_submission": {
"data": {
"id": "90df411d-b836-498b-bf0d-b320d56ab311",
"type": "email_submission"
}
}
}
}
]
}
```
`document_type: "unknown"` means Terminal49 could not classify or extract the document. Log the document `id` and `file_url` for investigation. If failures recur on the same document type, contact Terminal49 support.
## Document types in scope
The table below lists the document types Terminal49 currently classifies and extracts, along with the `document_type` value returned by the API.
| Document type | `document_type` value | Notes |
| -------------------------- | ---------------------------- | --------------------------------------------------------------- |
| Draft House Bill of Lading | `draft_house_bill_of_lading` | `hbl_type: "DRAFT"` in payload |
| Final House Bill of Lading | `final_house_bill_of_lading` | `hbl_type: "TELEX"` in payload; often issued as a Sea Waybill |
| Importer Security Filing | `importer_security_filing` | |
| Master Bill of Lading | `master_bill_of_lading` | Often issued as a Sea Waybill |
| Delivery Order | `dray_delivery_order` | |
| Arrival Notice | `arrival_notice` | |
| General Notice | `general_notice` | Includes container available notices |
| Customs Entry | `customs_entry` | Includes in-bond documents |
| Other | `other` | Used when no dedicated schema exists yet, e.g. freight invoices |
Additional document types will be added in future phases.
**Null fields are intentional.** A `null` value means Terminal49 looked for that field in the source document but did not find it. Treat `null` as "checked, not present" rather than "field not supported" or "not checked".
### Draft House Bill of Lading: full webhook payload
`schema_version: draft_house_bill_of_lading@2026-03-23`
```json theme={null}
{
"data": {
"id": "925298f4-dd2e-43c7-bdc6-690d92cc55bc",
"type": "webhook_notification",
"attributes": {
"event": "document_representation.created",
"delivery_status": "pending",
"created_at": "2026-03-27T20:05:03Z"
},
"relationships": {
"reference_object": {
"data": { "id": "1272e0d7-7989-4d3a-88a9-fceac6c9d239", "type": "document" }
}
}
},
"included": [
{
"id": "86bd0705-6478-4e14-9a8a-2cea84329636",
"type": "document_representation",
"attributes": {
"schema_version": "draft_house_bill_of_lading@2026-03-23",
"created_at": "2026-03-27T20:05:03Z",
"updated_at": "2026-03-27T20:05:03Z",
"payload": {
"hbl_type": "DRAFT",
"hbl_number": "CGGMSGH5110912",
"carrier_booking_number": "HLCUSHA2601APKY2",
"reference_number_isf": "CGGMSGH5110912",
"fmc_oti_number": "026564N",
"date_of_issue": "13-02-2026",
"shipped_on_board_date": "17-Feb-26",
"freight_payment_terms": "PREPAID",
"service_mode": "CFS/CY",
"vessel_name": "GUSTAV MAERSK",
"voyage_number": "606E",
"port_of_loading": "SHANGHAI,CHINA",
"port_of_discharge": "LOS ANGELES, CALIFORNIA, USA",
"place_of_receipt": "SHANGHAI,CHINA",
"place_of_delivery": "PERRIS, CALIFORNIA, USA",
"point_and_country_of_origin": "SHANGHAI,CHINA",
"signed_at": "SHANGHAI",
"signed_by": "MAERSK LOGISTICS & SERVICE CHINA LIMITED as agent of the Carrier",
"shipper": {
"name": "FUZHOU LIGHT INDUSTRY IMPORT & EXPORT CO.,LTD",
"address": "8/F.,TAIYANG PLAZA,NO.278,HUDONG AVENUE FUZHOU,CHINA"
},
"consignee": {
"name": "GOLDENSEE LIMITED",
"address": "C/O ACME LOGISTICS INC 100 MAIN ST, ANYTOWN, CA 90001"
},
"notify_party": {
"name": "MOHAWK GLOBAL LOGISTICS",
"email": "NJIMPORTS@MOHAWKGLOBAL.COM",
"phone": "732-218-9164",
"address": "105 FIELDCREST AVE, SUITE 404 EDISON, NJ 08837"
},
"containers": [
{
"container_number": "FDCU0184438",
"seal_number": "HLK6247958",
"container_size": "40HIGH",
"gross_weight": "1116.75",
"weight_unit": "KGS",
"measurement": "5.736",
"measurement_unit": "CBM",
"number_of_packages": "24",
"package_unit": "CARTONS",
"cargo_references": ["WFH1G9800163", "WFH1G9800162"]
}
],
"line_items": [
{
"description": "THE SOFA",
"quantity": "24",
"quantity_unit": "CARTONS",
"weight": "1116.75",
"weight_unit": "KGS",
"measurement": "5.736",
"measurement_unit": "CBM",
"hts_codes": ["9401616031"],
"cargo_references": ["WFH1G9800163", "WFH1G9800162"]
}
],
"totals": {
"number_of_packages": "24",
"package_unit": "CARTONS",
"gross_weight": "1116.75 KGS",
"weight_unit": "KGS",
"measurement": "5.736 CBM",
"measurement_unit": "CBM"
}
}
}
},
{
"id": "3bdf78ea-a86c-40b5-b650-a1d79542808a",
"type": "email_submission",
"attributes": {
"subject": "[ediDocManager SHP HBL MBL HLCUSHA2601APKY2 / HBL CGGMSGH5110912]",
"from": ["sender@example.com"],
"sent_at": "2026-03-27T13:03:05-07:00"
}
},
{
"id": "1272e0d7-7989-4d3a-88a9-fceac6c9d239",
"type": "document",
"attributes": {
"document_type": "draft_house_bill_of_lading",
"source": "email",
"file_name": "f134a7229b5cf7b6c241c566448b9293.pdf",
"file_url": "https://t49-documents-prod.s3.amazonaws.com/..."
},
"relationships": {
"last_document_representation": {
"data": { "id": "86bd0705-6478-4e14-9a8a-2cea84329636", "type": "document_representation" }
},
"email_submission": {
"data": { "id": "3bdf78ea-a86c-40b5-b650-a1d79542808a", "type": "email_submission" }
}
}
}
]
}
```
### Final House Bill of Lading: full webhook payload
`schema_version: final_house_bill_of_lading@2026-03-23`
```json theme={null}
{
"data": {
"id": "89ec3520-cea3-447d-8404-341e0bfd3aa6",
"type": "webhook_notification",
"attributes": {
"event": "document_representation.created",
"delivery_status": "pending",
"created_at": "2026-03-27T20:05:39Z"
},
"relationships": {
"reference_object": {
"data": { "id": "e75541c0-9ad5-408b-9747-23415adfbca0", "type": "document" }
}
}
},
"included": [
{
"id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f",
"type": "document_representation",
"attributes": {
"schema_version": "final_house_bill_of_lading@2026-03-23",
"created_at": "2026-03-27T20:05:39Z",
"updated_at": "2026-03-27T20:05:39Z",
"payload": {
"hbl_type": "TELEX",
"hbl_number": "CGGMXIM1554317",
"carrier_booking_number": "MAEU263829038",
"reference_number_isf": "CGGMXIM1554317",
"fmc_oti_number": "026564N",
"date_of_issue": "13-02-2026",
"shipped_on_board_date": "31-Jan-26",
"freight_payment_terms": "PREPAID",
"service_mode": "CFS/CY",
"vessel_name": "SYNERGY BUSAN",
"voyage_number": "604N",
"port_of_loading": "XIAMEN,CHINA",
"port_of_discharge": "JACKSONVILLE,FLORIDA,USA",
"place_of_receipt": "XIAMEN,CHINA",
"place_of_delivery": "JACKSONVILLE,FLORIDA,USA",
"point_and_country_of_origin": "XIAMEN,CHINA",
"signed_at": "XIAMEN",
"signed_by": "MAERSK LOGISTICS & SERVICE CHINA LIMITED as agent of the Carrier",
"shipper": {
"name": "TOTAL WIN HOME PRODUCTS CO.,LTD",
"address": "ROOM 601-604, ZHONGXI TIMES TOWER, NO. 26 OF HONGQI ROAD, NANCHENG DISTRICT, DONGGUAN CITY GUANGDONG PROVINCE, CHINA"
},
"consignee": {
"name": "UNIVERSE HOME INC.",
"address": "1546 NW 56TH STREET, SEATTLE, WA, 98107, UNITED STATES"
},
"notify_party": {
"name": "MOHAWK GLOBAL LOGISTICS",
"email": "NJIMPORTS@MOHAWKGLOBAL.COM",
"phone": "732-218-9164",
"address": "105 FIELDCREST AVE, SUITE 404 EDISON, NJ 08837"
},
"containers": [
{
"container_number": "CAAU4786387",
"seal_number": "CN5274554",
"container_size": "40HIGH",
"gross_weight": "451.7",
"weight_unit": "KGS",
"measurement": "2.412",
"measurement_unit": "CBM",
"number_of_packages": "37",
"package_unit": "CARTONS",
"cargo_references": ["ACN SPO WHS-41414-42378538"]
},
{
"container_number": "CAAU4786387",
"seal_number": "CN5274554",
"container_size": "40HIGH",
"gross_weight": "862",
"weight_unit": "KGS",
"measurement": "4.724",
"measurement_unit": "CBM",
"number_of_packages": "26",
"package_unit": "CARTONS",
"cargo_references": ["ACN SPO WHS-41414-42378549"]
}
],
"line_items": [
{
"description": "PET GATE",
"weight": "1313.7",
"weight_unit": "KGS",
"measurement": "7.136",
"measurement_unit": "CBM",
"hts_codes": ["442199"]
},
{
"description": "PET RAMP",
"hts_codes": ["442199"],
"cargo_references": ["ACI SPO: WHS-41414-42267479"]
},
{
"description": "DOG HOUSE",
"hts_codes": ["442199"]
},
{
"description": "COMMODITY SHELF",
"hts_codes": ["442199"],
"cargo_references": ["ACI SPO: WHS-41414-42267481"]
}
],
"totals": {
"number_of_packages": "63",
"package_unit": "CARTONS",
"gross_weight": "1313.7 KGS",
"weight_unit": "KGS",
"measurement": "7.136 CBM",
"measurement_unit": "CBM"
}
}
}
},
{
"id": "048bfec4-1249-4239-ab5b-63ad1e8f70cf",
"type": "email_submission",
"attributes": {
"subject": null,
"from": ["sender@example.com"],
"sent_at": "2026-03-27T23:03:18+03:00"
}
},
{
"id": "e75541c0-9ad5-408b-9747-23415adfbca0",
"type": "document",
"attributes": {
"document_type": "final_house_bill_of_lading",
"source": "email",
"file_name": "c4b16d8360049ea688367f6192dba07b.pdf",
"file_url": "https://t49-documents-prod.s3.amazonaws.com/..."
},
"relationships": {
"last_document_representation": {
"data": { "id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f", "type": "document_representation" }
},
"email_submission": {
"data": { "id": "048bfec4-1249-4239-ab5b-63ad1e8f70cf", "type": "email_submission" }
}
}
}
]
}
```
### ISF (Importer Security Filing): full webhook payload
`schema_version: importer_security_filing@2026-03-30`
```json theme={null}
{
"data": {
"id": "89ec3520-cea3-447d-8404-341e0bfd3aa6",
"type": "webhook_notification",
"attributes": {
"event": "document_representation.created",
"delivery_status": "pending",
"created_at": "2026-03-27T20:05:39Z"
},
"relationships": {
"reference_object": {
"data": { "id": "e75541c0-9ad5-408b-9747-23415adfbca0", "type": "document" }
}
}
},
"included": [
{
"id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f",
"type": "document_representation",
"attributes": {
"schema_version": "importer_security_filing@2026-03-30",
"created_at": "2026-03-27T20:05:39Z",
"updated_at": "2026-03-27T20:05:39Z",
"payload": {
"importer_name": "Hulk International Limited",
"importer_of_record_number": null,
"po_numbers": ["WHS-64134-41828780"],
"description_of_goods": null,
"bol_type": null,
"hbl_number": "CGGMSGH5115217",
"mbl_number": "MAEU265407596",
"etd": "17-Feb-26",
"eta": "04-Mar-26",
"consignee_name": "Hulk International Limited",
"consignee_address_1": "C/O ACME LOGISTICS INC 100 MAIN ST, ANYTOWN, CA 90001",
"consignee_address_2": null,
"consignee_city": null,
"consignee_state": null,
"consignee_postal_code": null,
"consignee_country": "US",
"consignee_irs_tax_id": null,
"buyer_name": "Hulk International Limited",
"buyer_address_1": "C/O ACME LOGISTICS INC 100 MAIN ST, ANYTOWN, CA 90001",
"buyer_address_2": null,
"buyer_city": null,
"buyer_state": null,
"buyer_postal_code": null,
"buyer_country": "US",
"buyer_duns": null,
"buyer_duns4": null,
"ship_to_name": "Acme Distribution Center 2",
"ship_to_address_1": "100 Main St, Anytown, CA 90001",
"ship_to_address_2": null,
"ship_to_city": null,
"ship_to_state": null,
"ship_to_postal_code": null,
"ship_to_country": "US",
"seller_name": "Jili Creation Technology Co., Ltd",
"seller_address_1": "Room(2803), Aidu international,#72,Jianshe Dong Street,Tiexi District,Shenyang,Liaoning,110000",
"seller_address_2": null,
"seller_city": null,
"seller_state_province": null,
"seller_postal_code": "110000",
"seller_country": "China",
"seller_duns": null,
"seller_duns4": null,
"consolidator_name": "A.P. Moller – Maersk",
"consolidator_address_1": "1-3/F, D3, Tianfu Software Park, Chengdu, China, 610041",
"consolidator_address_2": null,
"consolidator_city": null,
"consolidator_province": null,
"consolidator_postal_code": "610041",
"consolidator_country": "China",
"consolidator_duns": null,
"consolidator_duns4": null,
"stuffing_location_name": "Shanghai Yangshan Free Trade Port Area Logistics Service Co.,Ltd",
"stuffing_location_address_1": "No 666, Tongshun Avenue, Pudong District, Shanghai",
"stuffing_location_address_2": null,
"stuffing_location_city": null,
"stuffing_location_province": null,
"stuffing_location_postal_code": "201306",
"stuffing_location_country": "China",
"stuffing_location_duns": null,
"stuffing_location_duns4": null,
"manufacturer_name": "ZHEJIANG ANJI SHUYE FURNITURE CO.,LTD",
"manufacturer_address_1": "DISTRICT 2, SUNSHINE INDUSTRIAL PARK OF DIPU SUBDISTRICT, ANJI COUNTY, HUZHOU CITY, ZHEJIANG PROVINCE, CHINA",
"manufacturer_address_2": null,
"manufacturer_city": null,
"manufacturer_province": null,
"manufacturer_postal_code": "313300",
"manufacturer_country": "China",
"manufacturer_duns": null,
"manufacturer_duns4": null,
"hts_code": "9401719000",
"country_of_origin": "China"
}
}
},
{
"id": "e75541c0-9ad5-408b-9747-23415adfbca0",
"type": "document",
"attributes": {
"document_type": "importer_security_filing",
"source": "email",
"file_name": "isf.pdf",
"file_url": "https://t49-documents-prod.s3.amazonaws.com/..."
},
"relationships": {
"last_document_representation": {
"data": { "id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f", "type": "document_representation" }
},
"email_submission": {
"data": { "id": "048bfec4-1249-4239-ab5b-63ad1e8f70cf", "type": "email_submission" }
}
}
}
]
}
```
### Master Bill of Lading: full webhook payload
`schema_version: master_bill_of_lading@2026-03-23`
```json theme={null}
{
"data": {
"id": "89ec3520-cea3-447d-8404-341e0bfd3aa6",
"type": "webhook_notification",
"attributes": {
"event": "document_representation.created",
"delivery_status": "pending",
"created_at": "2026-03-27T20:05:39Z"
},
"relationships": {
"reference_object": {
"data": { "id": "e75541c0-9ad5-408b-9747-23415adfbca0", "type": "document" }
}
}
},
"included": [
{
"id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f",
"type": "document_representation",
"attributes": {
"schema_version": "master_bill_of_lading@2026-03-23",
"created_at": "2026-03-27T20:05:39Z",
"updated_at": "2026-03-27T20:05:39Z",
"payload": {
"document_state": "NON-NEGOTIABLE",
"waybill_number": "235600052920",
"booking_number": "EGLV235600052920",
"carrier_reference": null,
"carrier_name": "Evergreen Line",
"scac_code": "EGLV",
"export_references": null,
"service_contract_number": null,
"service_type": "FCL/FCL",
"movement_type": "O/O",
"number_of_original_waybills": "NIL (0)",
"rider_pages": null,
"shipper": {
"name": "MAERSK LOGISTICS & SERVICES VIETNAM COMPANY LIMITED",
"address": "FLOOR 16&17,OFFICE - COMMERCIAL - SERVICE BUILDING AT LOT 5.5,NO.8-10 MAI CHI THO STREET,AN KHANH WARD,HO CHI MINH CITY,VIETNAM",
"email": null,
"phone": null
},
"consignee": {
"name": "ACME LOGISTICS INC",
"address": "4 COPLEY PLACE FLOOR 7 BOSTON MA 02116 UNITED STATES",
"email": "OCEANIMPORT@ACMELOGISTICS.COM",
"phone": "1 617-532-5100"
},
"notify_party": {
"name": "ACME LOGISTICS INC.",
"address": "4 COPLEY PLACE FLOOR 7 BOSTON MA 02116 UNITED STATES",
"email": "OCEANIMPORT@ACMELOGISTICS.COM",
"phone": "1 617-532-5100"
},
"also_notify": null,
"actual_shipper": null,
"forwarding_agent_references": null,
"point_and_country_of_origin": null,
"pre_carriage_by": null,
"place_of_receipt": "HO CHI MINH CITY",
"date_of_receipt": null,
"ocean_vessel": "EVER MAGNA",
"voyage_number": "1440-003E",
"port_of_loading": "CAI MEP",
"transshipment_port": null,
"port_of_discharge": "LOS ANGELES, CA",
"place_of_delivery": "LOS ANGELES, CA",
"onward_inland_routing": null,
"marks_and_numbers": "N/M",
"containers": [
{
"container_number": "EGSU9849000",
"seal_number": "EMCCYG3555",
"container_size": "40H",
"slac": null,
"gross_weight": null,
"weight_unit": null,
"measurement": null,
"measurement_unit": null,
"number_of_packages": null,
"package_unit": null
}
],
"description_of_goods": "SOFA",
"hts_codes": ["9401616011"],
"invoice_references": [],
"hbl_reference": null,
"cargo_references": ["SPO#WHS-58624-41973686"],
"total_packages": 135,
"package_unit": "CARTONS",
"gross_weight": 6439.5,
"tare_weight": null,
"tare_weight_unit": null,
"weight_unit": "KGS",
"measurement": 64.8,
"measurement_unit": "CBM",
"total_containers_received": 1,
"total_containers_in_words": "ONE(1) CONTAINER ONLY",
"freight_payment_terms": "COLLECT",
"prepaid_at": null,
"collect_at": "DESTINATION",
"freight_payable_at": null,
"place_of_issue": "HO CHI MINH",
"date_of_issue": "FEB.02,2026",
"laden_on_board_date": "FEB.02,2026",
"signed_by": "EVERGREEN SHIPPING AGENCY (VIETNAM) COMPANY LTD. As agent for the Carrier and the Vessel Provider Evergreen Marine (Asia) Pte. Ltd. doing business as \"Evergreen Line\""
}
}
},
{
"id": "e75541c0-9ad5-408b-9747-23415adfbca0",
"type": "document",
"attributes": {
"document_type": "master_bill_of_lading",
"source": "email",
"file_name": "c4b16d8360049ea688367f6192dba07b.pdf",
"file_url": "https://t49-documents-prod.s3.amazonaws.com/..."
},
"relationships": {
"last_document_representation": {
"data": { "id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f", "type": "document_representation" }
},
"email_submission": {
"data": { "id": "048bfec4-1249-4239-ab5b-63ad1e8f70cf", "type": "email_submission" }
}
}
}
]
}
```
### Delivery Order: full webhook payload
`schema_version: dray_delivery_order@2026-03-30`
```json theme={null}
{
"data": {
"id": "89ec3520-cea3-447d-8404-341e0bfd3aa6",
"type": "webhook_notification",
"attributes": {
"event": "document_representation.created",
"delivery_status": "pending",
"created_at": "2026-03-27T20:05:39Z"
},
"relationships": {
"reference_object": {
"data": { "id": "e75541c0-9ad5-408b-9747-23415adfbca0", "type": "document" }
}
}
},
"included": [
{
"id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f",
"type": "document_representation",
"attributes": {
"schema_version": "dray_delivery_order@2026-03-30",
"created_at": "2026-03-27T20:05:39Z",
"updated_at": "2026-03-27T20:05:39Z",
"payload": {
"issuer_name": "Acme Logistics Inc",
"issuer_fmc_oti_number": "026564N",
"document_date": null,
"document_number": null,
"consol_number": null,
"prepared_by": null,
"prepared_date": null,
"pickup_terminal_name": "APM Terminal, New Jersey",
"pickup_address_name": "New York APM Terminal, New Jersey",
"pickup_address": null,
"pickup_address_phone": null,
"delivery_name": "Acme Distribution Center",
"delivery_address": "100 Main St, Anytown, NJ 08512 US",
"ocean_carrier": "Hapag-Lloyd",
"mbol": "HLCUSGN2512AWAC8",
"hbol": null,
"vessel_name": "CAUTIN",
"voyage_number": "2516E",
"transport_mode": null,
"port_of_loading": "Vung Tau",
"port_of_discharge": "New York",
"eta": "02/23/2026 EST",
"door_eta": null,
"entry_number": null,
"freight_payment_terms": null,
"service_type": null,
"movement_type": null,
"customer": "Haomaijia Technology (Shenzhen) Co., LTD",
"goods_description": null,
"package_count": null,
"measurement_cbm": null,
"measurement_cft": null,
"cargo_references": ["WHS-58624-41985386"],
"delivery_notes": null,
"delivery_carrier": "Cargomatic",
"routing_legs": null,
"containers": [
{
"container_number": "HAMU1717557",
"seal_number": "HLC3251157",
"container_type": "ISO_45G0",
"gross_weight_kg": null,
"gross_weight_lb": 16762.0
}
]
}
}
},
{
"id": "e75541c0-9ad5-408b-9747-23415adfbca0",
"type": "document",
"attributes": {
"document_type": "dray_delivery_order",
"source": "email",
"file_name": "delivery_order_2902654.pdf",
"file_url": "https://t49-documents-prod.s3.amazonaws.com/..."
},
"relationships": {
"last_document_representation": {
"data": { "id": "b3abc297-624a-4eaa-a0e9-4ac4ebbd064f", "type": "document_representation" }
},
"email_submission": {
"data": { "id": "048bfec4-1249-4239-ab5b-63ad1e8f70cf", "type": "email_submission" }
}
}
}
]
}
```
## Use these endpoints while integrating
* [`GET /webhook_notifications/examples`](/docs/api-docs/api-reference/webhook-notifications/get-webhook-notification-payload-examples)
* [`POST /webhooks/trigger`](/docs/api-docs/api-reference/webhooks/trigger-a-webhook)
You can trigger a test payload for a specific document type without sending an email:
```bash theme={null}
curl -X POST https://api.terminal49.com/v2/webhooks/trigger \
-H "Authorization: Token YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-endpoint.example/webhooks",
"event": "document_representation.created",
"sample": {
"document_type": "dray_delivery_order"
}
}'
```
Webhook event availability depends on your account configuration. If you are not receiving expected events, contact Terminal49 support.
## APIs involved
* [`GET /documents`](/docs/api-docs/api-reference/documents/list-documents)
* [`GET /documents/{id}`](/docs/api-docs/api-reference/documents/get-a-document)
* [`GET /documents/{id}/download_url`](/docs/api-docs/api-reference/documents/get-a-document-download-url)
* [`GET /email_submissions`](/docs/api-docs/api-reference/email-submissions/list-email-submissions)
* [`GET /email_submissions/{id}`](/docs/api-docs/api-reference/email-submissions/get-an-email-submission)
* [`GET /document_schemas/{id}`](/docs/api-docs/api-reference/document-schemas/get-a-document-schema)
* [`Document representations resource`](/docs/api-docs/api-reference/document-representations/document-representations-resource)
# Event Timestamps
Source: https://terminal49.com/docs/api-docs/in-depth-guides/event-timestamps
Learn how Terminal49 stores transport event timestamps in UTC and how to convert them to local time using the matching IANA timezone field.
Through the typical container lifecycle, events occur across multiple timezones. Wherever you see a timestamp for a transport event, there should be a corresponding [IANA timezone](https://www.iana.org/time-zones).
Event timestamps are stored and returned in UTC. If you wish to present them in the local time you need to convert that UTC timestamp using the corresponding timezone.
### Example
If you receive a container model with the attributes
```
'pod_arrived_at': '2022-12-22T07:00:00Z',
'pod_timezone': 'America/Los_Angeles',
```
then the local time of the `pod_arrived_at` timestamp would be `2022-12-21T23:00:00 PST -08:00`
## When the corresponding timezone is null
When an event occurs and Terminal49 cannot determine the location (and therefore the timezone), the system cannot store the event in true UTC.
In this scenario, Terminal49 takes the timestamp as given from the source and parses it in UTC.
### Example
```
'pod_arrived_at': '2022-12-22T07:00:00Z',
'pod_timezone': null,
```
then the local time of the `pod_arrived_at` timestamp would be `2022-12-22T07:00:00` and the timezone is unknown. (Assuming the source was returning localized timestamps)
## When `location` and `location_locode` are null
On transport events, both `location_locode` (and any related `location` object) are nullable. They may be `null` when:
* The event is **estimated** (`estimated: true`) and the carrier does not publish a location for the prediction.
* The carrier or data provider omits the location field on a given event. This varies by carrier and event type.
* Terminal49 could not normalize the source location to a known UNLOCODE.
This is expected behavior, not a delivery error. The location is not back-filled later for the same event — if a subsequent actual event (for example, `container.transport.vessel_arrived` replacing `container.transport.estimated.vessel_arrived`) includes a location, it will be delivered as a new event. Treat `location_locode == null` as "location unknown" rather than waiting for an update on the original event.
When `location_locode` is null, `timezone` is typically null as well; see [When the corresponding timezone is null](#when-the-corresponding-timezone-is-null) for how timestamps are stored in that case.
## System timestamps
Timestamps representing changes within the Terminal49 system (e.g. `created_at`, `updated_at`, `terminal_checked_at`) are stored and represented in UTC and do not have a time zone.
# Container Holds, Fees, and Release Readiness
Source: https://terminal49.com/docs/api-docs/in-depth-guides/holds-and-fees
Determine when an import container is released for pickup by reading holds, fees, last free day, and availability data from the Terminal49 API.
After a container is discharged at the Port of Discharge (POD), the terminal and government agencies may place holds or assess fees before the container can be picked up. For shipments with inland rail moves, holds and fees can also apply at the inland destination. Your integration needs to monitor these fields to determine when a container is actually released and ready for pickup.
Terminal49 normalizes hold and fee data from supported terminal sources into two structured arrays on the container object: `holds_at_pod_terminal` and `fees_at_pod_terminal`. This guide shows you how to use them.
The field names reference `pod_terminal` for historical reasons, but these fields report hold and fee data regardless of whether the container is at a port terminal or an inland rail destination. The same readiness logic applies in both scenarios.
## Determine if a container is ready for pickup
The most common question is straightforward: **can I pick up this container?** You need two fields from the container's `attributes` to answer it:
* `available_for_pickup` — a boolean the terminal sets when the container is cleared for release
* `holds_at_pod_terminal` — an array of active holds blocking pickup
Use them together. A container is ready for pickup when `available_for_pickup` is `true` **and** there are no active holds:
```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;
}
```
Here is the full decision logic:
```mermaid theme={null}
flowchart TD
Start["Container discharged at POD"] --> CheckAvailable{"available_for_pickup\n== true?"}
CheckAvailable -->|Yes| VerifyHolds{"holds array\nempty?"}
CheckAvailable -->|No| CheckHolds{"Any active\nholds?"}
VerifyHolds -->|Yes| Ready["Ready for pickup"]
VerifyHolds -->|No| OutOfSync["Data may be out of sync\nWait for next update"]
CheckHolds -->|Yes| Blocked["Blocked — resolve\nthe active holds"]
CheckHolds -->|No| NotYet["Not yet released\nMonitor for updates"]
```
Treat `available_for_pickup: true` with an empty holds array as the definitive signal that the container is ready. When holds and `available_for_pickup` disagree — for example, holds are cleared but `available_for_pickup` is still `false` — wait for the next `container.updated` webhook or poll the container again. Terminal data is sourced from multiple systems on varying schedules, so brief inconsistencies can occur.
## Where to find holds and fees
Both fields live on the container's `attributes` object in the V2 API:
* `holds_at_pod_terminal` — active holds blocking or flagging pickup
* `fees_at_pod_terminal` — fees assessed at the terminal
[`GET /v2/containers/{id}`](/docs/api-docs/api-reference/containers/get-a-container)
```json theme={null}
{
"data": {
"id": "3cd51f0e-eb18-4399-9f90-4c8a22250f63",
"type": "container",
"attributes": {
"number": "COSU1186800",
"available_for_pickup": false,
"holds_at_pod_terminal": [
{
"name": "customs",
"status": "hold",
"description": "CBP HOLD"
}
],
"fees_at_pod_terminal": [
{
"type": "demurrage",
"amount": 850.00,
"currency_code": "USD"
}
]
}
}
}
```
An empty array (`[]`) means there are no active holds or fees of that type.
## Hold types at a glance
Each item in `holds_at_pod_terminal` is a `terminal_hold` object:
| Field | Type | Description |
| ------------- | -------------- | ----------------------------------------- |
| `name` | string | The canonical hold type (see table below) |
| `status` | string | `"hold"` or `"pending"` |
| `description` | string \| null | Raw text from the terminal, if provided |
When a hold is cleared, the object is removed from the array entirely. There is no `"released"` status. An empty array means no active holds.
| Hold name | Description | Who resolves it |
| --------- | ------------------------------------ | -------------------------------------- |
| `freight` | Carrier freight charges unpaid | Shipping line or freight forwarder |
| `customs` | CBP hold — docs, exam, or inspection | Licensed customs broker |
| `USDA` | USDA phytosanitary inspection | Customs broker or USDA compliance team |
| `VACIS` | Non-intrusive X-ray / gamma-ray scan | Customs broker |
| `TMF` | Terminal management fee (pier pass) | Pay terminal directly |
| `other` | Unmapped hold — check `description` | Terminal or broker |
Hold names are case-sensitive. `USDA`, `VACIS`, and `TMF` are uppercase. `freight`, `customs`, and `other` are lowercase. Match values exactly in your code.
The ocean carrier has placed a freight hold because the freight charges have not been paid or confirmed. The container will not be released until the carrier lifts this hold.
```json theme={null}
{
"name": "freight",
"status": "hold",
"description": null
}
```
**Who resolves it:** Contact the shipping line or your freight forwarder to confirm payment status.
US Customs and Border Protection (CBP) has placed a hold. This can occur due to documentation issues, a random examination, or a targeted inspection. The container cannot be released until CBP clears it.
```json theme={null}
{
"name": "customs",
"status": "hold",
"description": "CBP HOLD"
}
```
**Who resolves it:** Your licensed customs broker. Resolution time varies from hours to several days depending on the examination type.
The US Department of Agriculture (USDA) has flagged the shipment for a phytosanitary inspection. Common for shipments containing food, plants, wood packaging, or agricultural products.
```json theme={null}
{
"name": "USDA",
"status": "hold",
"description": null
}
```
**Who resolves it:** Your customs broker or USDA compliance team. Inspections typically happen at the terminal or a USDA-approved facility.
The container has been flagged for a VACIS (Vehicle and Cargo Inspection System) scan — a non-intrusive gamma-ray or X-ray inspection conducted by CBP. You may also see this referred to as an NII (Non-Intrusive Inspection) exam.
```json theme={null}
{
"name": "VACIS",
"status": "hold",
"description": "VACIS EXAM"
}
```
**Who resolves it:** Your customs broker. The exam fee (if assessed) will appear separately in `fees_at_pod_terminal` as type `"exam"`.
A Terminal Management Fee (TMF) hold is placed by the terminal itself — sometimes called a pier pass or terminal gate fee. This hold is typically resolved by paying the fee directly to the terminal.
```json theme={null}
{
"name": "TMF",
"status": "hold",
"description": null
}
```
**Who resolves it:** Pay the terminal fee. Your drayage carrier or port agent can assist.
A hold that Terminal49 could not map to a specific type. The raw terminal text, when available, appears in the `description` field.
```json theme={null}
{
"name": "other",
"status": "hold",
"description": "TERMINAL HOLD - SEE CUSTOMER SERVICE"
}
```
**What to do:** Use the `description` to identify the specific issue and contact the terminal or your broker for resolution.
A `status` of `"pending"` means the terminal has flagged a hold as expected but not yet active. Treat it as a warning that a hold is likely incoming. When the hold becomes active, the status changes to `"hold"` and you receive a `container.updated` webhook notification.
## Fee types at a glance
Each item in `fees_at_pod_terminal` is a `terminal_fee` object:
| Field | Type | Description |
| --------------- | ------ | ----------------------------------------- |
| `type` | string | The canonical fee type (see table below) |
| `amount` | number | Fee amount in local currency |
| `currency_code` | string | ISO 4217 currency code, typically `"USD"` |
| Fee type | Description | Charged by |
| --------------------- | -------------------------------------------------------- | ------------------------- |
| `demurrage` | Daily charge after carrier free time expires | Ocean carrier |
| `extended_dwell_time` | Terminal charge for prolonged dwell | Terminal |
| `exam` | CBP/USDA inspection cost | Terminal or exam facility |
| `total` | Combined total of all fees (may overlap with line items) | See individual items |
| `other` | Unmapped fee type | Varies |
A daily charge assessed by the **ocean carrier** when the container is not picked up within the free time period. Demurrage starts accruing after the carrier's free time expires and increases every day.
```json theme={null}
{
"type": "demurrage",
"amount": 1250.00,
"currency_code": "USD"
}
```
**Note:** Demurrage is charged by the carrier, not the terminal. The terminal reports it, but you pay the carrier.
An Extended Dwell Time (EDT) fee charged by the **terminal** (separate from carrier demurrage) when a container sits at the terminal beyond a threshold. Common at major US gateways like the Ports of LA and Long Beach.
```json theme={null}
{
"type": "extended_dwell_time",
"amount": 300.00,
"currency_code": "USD"
}
```
Covers the cost of a physical or non-intrusive (VACIS) inspection by CBP or USDA. Exam fees are typically paid to the terminal or a government-approved exam facility. Amounts vary widely — from a few hundred to several thousand dollars depending on the exam type.
```json theme={null}
{
"type": "exam",
"amount": 450.00,
"currency_code": "USD"
}
```
A combined total of all fees at the terminal, reported as a single line item. Some terminals report only a total rather than individual fee breakdowns. If you see a `total` fee in the array alongside individual line items, filter it out when summing to avoid double-counting.
```json theme={null}
{
"type": "total",
"amount": 2000.00,
"currency_code": "USD"
}
```
A fee that Terminal49 could not map to a specific type.
```json theme={null}
{
"type": "other",
"amount": 75.00,
"currency_code": "USD"
}
```
## Full example: container with multiple holds and fees
```json theme={null}
{
"holds_at_pod_terminal": [
{
"name": "customs",
"status": "hold",
"description": "CBP HOLD"
},
{
"name": "freight",
"status": "hold",
"description": null
}
],
"fees_at_pod_terminal": [
{
"type": "demurrage",
"amount": 850.00,
"currency_code": "USD"
},
{
"type": "exam",
"amount": 450.00,
"currency_code": "USD"
}
]
}
```
In this example, the container has two active holds (`customs` and `freight`) and two fees. Both holds must be resolved before the container can be released. The demurrage fee will continue increasing daily until the container is picked up.
## Getting notified when holds or fees change
Subscribe to the `container.updated` webhook to run your release-readiness check in real time whenever holds or fees change. The `changeset` on the `container_updated_event` shows the old value and new value side by side — old first, new second.
For full details on setting up webhooks, see [Webhooks](/docs/api-docs/in-depth-guides/webhooks).
A customs hold appeared on the container:
```json theme={null}
{
"changeset": {
"holds_at_pod_terminal": [
[],
[
{
"name": "customs",
"status": "hold",
"description": "CBP HOLD"
}
]
]
}
}
```
The customs hold was lifted — the container is now clear:
```json theme={null}
{
"changeset": {
"holds_at_pod_terminal": [
[
{
"name": "customs",
"status": "hold",
"description": "CBP HOLD"
}
],
[]
]
}
}
```
A pending hold escalated to an active hold:
```json theme={null}
{
"changeset": {
"holds_at_pod_terminal": [
[
{
"name": "customs",
"status": "pending",
"description": null
}
],
[
{
"name": "customs",
"status": "hold",
"description": "CBP HOLD"
}
]
]
}
}
```
Demurrage increased as another day accrued:
```json theme={null}
{
"changeset": {
"fees_at_pod_terminal": [
[
{
"type": "demurrage",
"amount": 850.00,
"currency_code": "USD"
}
],
[
{
"type": "demurrage",
"amount": 1250.00,
"currency_code": "USD"
}
]
]
}
}
```
## Edge cases
* **Empty arrays mean no holds or fees.** An empty `holds_at_pod_terminal: []` or `fees_at_pod_terminal: []` is the normal state for most containers. Do not treat it as missing data or an error.
**Avoid double-counting when `total` is present.** Some terminals report a `total` fee alongside individual line items. Filter it out before summing:
```javascript theme={null}
const lineItems = container.fees_at_pod_terminal.filter(f => f.type !== 'total');
const totalAmount = lineItems.reduce((acc, f) => acc + f.amount, 0);
```
**Fee amount of `0` is valid.** A fee amount of `0` means the terminal reported the fee type but has not yet calculated or posted the dollar amount. This is common for demurrage in the first day or two after discharge. Poll the container or wait for the next `container.updated` event.
**The `description` field is raw terminal text.** The `description` on hold objects is unstructured text scraped directly from the terminal. It is useful context for humans but should not be used for programmatic decision-making. Use the `name` field to drive automation logic.
## Frequently asked questions
Check two fields together: `available_for_pickup` must be `true` **and** the `holds_at_pod_terminal` array must have no items with `status: "hold"`. See the [decision logic and code example](#determine-if-a-container-is-ready-for-pickup) above.
Yes. Holds and fees are independent. Holds block pickup. Your container cannot be released until all holds are cleared. Fees are charges you owe (demurrage, exam costs, etc.) that may continue accruing whether or not holds are present.
The hold object is removed from the `holds_at_pod_terminal` array entirely. There is no `"released"` status. An empty array means no active holds. You receive a `container.updated` webhook when this happens.
Terminal data is sourced from multiple systems on varying schedules. A hold can clear before the terminal updates `available_for_pickup`, or vice versa. Wait for the next `container.updated` webhook or poll the container again. Treat `available_for_pickup: true` with an empty holds array as the definitive readiness signal.
Yes. The `holds_at_pod_terminal` and `fees_at_pod_terminal` fields report data regardless of whether the container is at a port terminal or an inland rail destination. The field names reference `pod_terminal` for historical reasons, but the same readiness logic applies in both scenarios.
No. The Terminal49 API reports holds, fees, and last free day as read-only data. It does not expose payment endpoints and cannot request an LFD extension. Pay demurrage or terminal fees directly with the carrier or terminal, and request LFD extensions through the carrier or your freight forwarder. Once the terminal updates its records, the change flows back into `holds_at_pod_terminal`, `fees_at_pod_terminal`, and `pickup_lfd` on the next `container.updated` webhook.
Some terminals report a `total` fee alongside individual line items. Filter it out before summing:
```javascript theme={null}
const lineItems = container.fees_at_pod_terminal.filter(f => f.type !== 'total');
const totalAmount = lineItems.reduce((acc, f) => acc + f.amount, 0);
```
## Related guides
How `available_for_pickup` and `current_status` are derived
Subscribe to `container.updated` events
When terminal data was captured
Inland rail moves and container tracking at rail destinations
# Include related resources in API responses
Source: https://terminal49.com/docs/api-docs/in-depth-guides/including-resources
Use the include query parameter to return related resources like shipments, containers, terminals, and transport events in a single API response.
Throughout the documentation you will notice that many of the endpoints include a `relationships` object inside of the `data` attribute.
For example, if you are [requesting a container](/docs/api-docs/api-reference/containers/get-a-container) the relationships will include `shipment`, and possibly `pod_terminal` and `transport_events`
If you want to load the `shipment` and `pod_terminal` without making any additional requests you can add the query parameter `include` and provide a comma delimited list of the related resources:
```
containers/{id}?include=shipment,pod_terminal
```
You can even traverse the relationships up or down. For example if you wanted to know the port of lading for the container you could get that with:
```
containers/{id}?include=shipment,shipment.port_of_lading
```
# Terminal49 MCP Server Quickstart
Source: https://terminal49.com/docs/api-docs/in-depth-guides/mcp
Set up the Terminal49 MCP server in Claude, ChatGPT, Cursor, Copilot, or any MCP client to query live shipment and container tracking data from your AI tool.
This guide covers everything you need to connect an MCP client to Terminal49's container tracking data.
Just want to get started fast? See the per-tool setup guides for [Claude](/docs/mcp/setup/claude), [Claude Code](/docs/mcp/setup/claude-code), [ChatGPT](/docs/mcp/setup/chatgpt), [Cursor](/docs/mcp/setup/cursor), [Microsoft Copilot](/docs/mcp/setup/microsoft-copilot), and [VS Code](/docs/mcp/setup/vs-code), or the [MCP Overview](/docs/mcp/home) for a 5-minute setup.
## Prerequisites
Before you begin, make sure you have:
You sign in with your Terminal49 credentials during the OAuth flow — no API key needed
Only needed for clients without OAuth support or the local stdio server — create one in the [developer portal](https://app.terminal49.com/developers/api-keys)
Required if running the MCP server locally
Claude, ChatGPT, Cursor, Microsoft Copilot, VS Code, or any MCP-compatible client
**Technical Details:**
* **MCP SDK**: `@modelcontextprotocol/sdk ^1.29.0`
* **TypeScript SDK**: `@terminal49/sdk 0.3.0`
* **Sentry MCP Monitoring**: `@sentry/node ^10.55.0` (optional)
* **Runtime**: Node.js 24.x
***
## Transports
| Transport | Endpoint | Best For |
| ----------------- | --------------------------------- | -------------------------------- |
| HTTP (streamable) | `POST https://mcp.terminal49.com` | Serverless, short-lived requests |
**Authentication**: OAuth 2.1 (recommended) or API key.
* **OAuth 2.1** – no API key needed. Add `https://mcp.terminal49.com` to your client; it discovers the authorization server (`https://auth.terminal49.com`) via protected resource metadata, registers itself with Dynamic Client Registration, and opens your browser so you can sign in with your Terminal49 credentials. Tokens are stored and refreshed by the client.
* **API key** – for clients that can't run a browser OAuth flow, pass `Authorization: Token YOUR_API_KEY`. Use the `Token` scheme for API keys; the `Bearer` scheme is used for OAuth access tokens, which OAuth clients obtain automatically.
Only the [local stdio server](#local-stdio-development) reads the `T49_API_TOKEN` environment variable instead of a header.
For hosted production usage, connect to `https://mcp.terminal49.com`. The root origin is the canonical connector URL and OAuth resource identifier.
***
## Observability
The MCP server supports optional [Sentry MCP Monitoring](https://docs.sentry.io/ai/monitoring/mcp/). Set `SENTRY_DSN` to capture MCP server connections, tool executions, resource access, prompts, performance spans, and errors in Sentry.
```bash theme={null}
SENTRY_DSN=___PUBLIC_DSN___
SENTRY_TRACES_SAMPLE_RATE=1.0
SENTRY_MCP_RECORD_INPUTS=false
SENTRY_MCP_RECORD_OUTPUTS=false
SENTRY_SEND_DEFAULT_PII=false
```
`SENTRY_MCP_RECORD_INPUTS` and `SENTRY_MCP_RECORD_OUTPUTS` are disabled by default because MCP payloads can include shipment identifiers, references, and customer data. Enable them only if your Sentry project is approved for that data.
***
## Configure your MCP client
### OAuth setup (recommended)
Most clients connect with just the server URL and a browser sign-in — no API key. Follow the guide for your tool:
* [Claude](/docs/mcp/setup/claude) – claude.ai and Claude Desktop
* [Claude Code](/docs/mcp/setup/claude-code) – one `claude mcp add` command
* [ChatGPT](/docs/mcp/setup/chatgpt) – install from the ChatGPT Plugins Directory
* [Cursor](/docs/mcp/setup/cursor) – `mcp.json` or Cursor Settings → MCP
* [Microsoft Copilot](/docs/mcp/setup/microsoft-copilot) – Copilot Studio agent tools
* [VS Code](/docs/mcp/setup/vs-code) – GitHub Copilot agent mode
* [Agent plugins](/docs/mcp/setup/agent-plugins) – Terminal49 plugin for Claude Code, Cursor, Codex, and GitHub Copilot CLI
* [Other MCP clients](/docs/mcp/setup/other-clients) – generic configuration
The manual configurations below use an API key instead. Use them for clients or environments where the browser OAuth flow isn't practical.
claude.ai and Claude Desktop cannot send a static API-key header — they always authenticate through the OAuth connector flow. Follow the [Claude setup guide](/docs/mcp/setup/claude) to connect them. For API-key-based local development in Claude Desktop, use the [local stdio server](#local-stdio-development) below instead.
### Cursor IDE (manual, API key)
Add to `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for all projects):
```json theme={null}
{
"mcpServers": {
"terminal49": {
"url": "https://mcp.terminal49.com",
"headers": {
"Authorization": "Token YOUR_API_KEY"
}
}
}
}
```
### Local stdio (development)
For local development without a hosted server:
```json theme={null}
{
"mcpServers": {
"terminal49": {
"command": "node",
"args": ["/path/to/API/packages/mcp/dist/index.js"],
"env": {
"T49_API_TOKEN": "YOUR_API_KEY"
}
}
}
}
```
Build the MCP server first:
`cd packages/mcp && npm install && T49_SDK_SOURCE=published npm run sdk:setup && npm run build`
Use published SDK by default:
```bash theme={null}
cd packages/mcp
T49_SDK_SOURCE=published npm run sdk:setup
```
Use local SDK build during development:
```bash theme={null}
cd packages/mcp
T49_SDK_SOURCE=local npm run sdk:setup
```
***
## Test your setup
Once configured, verify everything works:
Close and reopen Claude Desktop or Cursor to load the new config.
> "List the tools available in the Terminal49 MCP server."
Claude should respond with a list of 10 tools including `search_container`, `track_container`, and list tools.
> "Using the Terminal49 MCP server, search for container TCLU1234567 and summarize its status."
If configured correctly, Claude will call `search_container` and return container details.
> "Using Terminal49, find container CAIU1234567, check its demurrage risk, and tell me if I need to pick it up urgently."
Claude should chain multiple tools together to answer.
Need test container numbers? See [Test Numbers](/docs/api-docs/useful-info/test-numbers) for containers you can use during development.
***
## Troubleshooting
| Symptom | Likely Cause | How to Fix |
| ------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| "Cannot connect to MCP server" | Wrong URL or config path | Confirm URL is `https://mcp.terminal49.com` and config file path matches your OS |
| `401 Unauthorized` | Expired OAuth session, or missing/invalid API key | Disconnect and reconnect the connector to re-run the OAuth sign-in. If using an API key, create a new one in the [developer portal](https://app.terminal49.com/developers/api-keys) and ensure the `Authorization: Token YOUR_API_KEY` header is set |
| `429 Too Many Requests` | Rate limit exceeded | See [Rate Limiting](/docs/api-docs/in-depth-guides/rate-limiting); use webhooks instead of polling |
| Tools list is empty | Config not loaded | Restart Claude/Cursor; check MCP inspector for errors |
| "Tool not found" | Typo in tool name | Use exact names: `search_container`, `get_container`, etc. |
| Slow responses | Large data requests | Use `include` parameter to load only what you need |
If using the hosted server, check your Terminal49 dashboard for API logs.
If running locally:
```bash theme={null}
cd packages/mcp
T49_API_TOKEN=YOUR_API_KEY npm run mcp:stdio 2>&1 | head -20
```
***
## MCP capabilities
### Tools
| Tool | Description | Parameters |
| -------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `search_container` | Find containers by number, BL, booking, or ref | `query: string` |
| `track_container` | Start tracking a container | `number`, `numberType?`, `scac?`, `refNumbers?` |
| `get_container` | Get container with optional includes | `id: uuid`, `include?: ['shipment', 'pod_terminal', 'transport_events']` |
| `get_shipment_details` | Get shipment and containers | `id: uuid`, `include_containers?: boolean` |
| `get_container_transport_events` | Get event timeline | `id: uuid` |
| `get_supported_shipping_lines` | List carriers with SCAC codes | `search?: string` |
| `get_container_route` | Get multi-leg routing (paid feature) | `id: uuid` |
| `list_shipments` | List shipments with filters + pagination | `status?`, `port?`, `carrier?`, `updated_after?`, `include_containers?`, `page?`, `page_size?` |
| `list_containers` | List containers with filters + pagination | `status?`, `port?`, `carrier?`, `updated_after?`, `include?`, `page?`, `page_size?` |
| `list_tracking_requests` | List tracking requests with filters | `filters?`, `status?`, `request_type?`, `page?`, `page_size?` |
### Prompts
| Prompt | Description | Arguments |
| ----------------- | ----------------------- | ------------------------------ |
| `track-shipment` | Quick tracking workflow | `container_number`, `carrier?` |
| `check-demurrage` | Demurrage risk analysis | `container_id` |
| `analyze-delays` | Journey delay analysis | `container_id` |
### Resources
| URI | Description |
| -------------------------------------- | -------------------------- |
| `terminal49://container/{id}` | Container data as resource |
| `terminal49://docs/milestone-glossary` | Event/milestone reference |
For detailed examples and response formats, see [MCP Overview → Tools Reference](/docs/mcp/home#tools-reference).
***
## SDK usage
The TypeScript SDK provides the same capabilities as MCP tools, plus additional APIs not yet exposed via MCP.
```bash theme={null}
npm install @terminal49/sdk
```
```typescript theme={null}
import { Terminal49Client } from '@terminal49/sdk';
const client = new Terminal49Client({
apiToken: process.env.T49_API_TOKEN!,
defaultFormat: 'mapped'
});
// Get container with shipment and terminal
const container = await client.containers.get(
'container-uuid',
['shipment', 'pod_terminal']
);
// Search for containers
const results = await client.search('CAIU1234567');
// List shipments with filters (not available via MCP)
const shipments = await client.shipments.list({
status: 'in_transit',
carrier: 'MAEU'
});
```
### Response formats
| Format | Description |
| -------- | ------------------------------------------------------------ |
| `raw` | JSON:API response with `data`, `attributes`, `relationships` |
| `mapped` | Simplified, camelCase objects with IDs resolved |
| `both` | `{ raw, mapped }` for debugging |
**Raw format:**
```json theme={null}
{
"data": {
"type": "container",
"id": "abc-123",
"attributes": {
"container_number": "CAIU1234567",
"available_for_pickup": true
}
}
}
```
**Mapped format:**
```json theme={null}
{
"id": "abc-123",
"containerNumber": "CAIU1234567",
"availableForPickup": true
}
```
***
## Deployment
### Vercel (production)
The `vercel.json` configures the MCP server (excerpt):
```json theme={null}
{
"installCommand": "npm ci",
"buildCommand": "npm run build --workspace @terminal49/sdk && npm run build --workspace @terminal49/mcp",
"functions": {
"api/mcp.ts": { "maxDuration": 30 }
},
"rewrites": [
{ "source": "/mcp", "destination": "/api/mcp" },
{ "source": "/", "destination": "/api/mcp" }
]
}
```
### Environment variables
| Variable | Required | Description |
| --------------------------- | --------------- | --------------------------------------------------------------------------------------------------------- |
| `T49_API_TOKEN` | For local stdio | Terminal49 API key. The hosted HTTP endpoint authenticates callers via the `Authorization` header instead |
| `T49_API_BASE_URL` | No | Override API URL (default: `https://api.terminal49.com/v2`) |
| `T49_MCP_ALLOWED_HOSTS` | No | Comma-separated host allowlist for request Host validation |
| `T49_MCP_ALLOWED_ORIGINS` | No | Comma-separated origin allowlist for request Origin validation |
| `SENTRY_DSN` | No | Enables Sentry MCP Monitoring |
| `SENTRY_ENVIRONMENT` | No | Sentry environment name; defaults to `NODE_ENV` |
| `SENTRY_RELEASE` | No | Sentry release identifier; defaults to `VERCEL_GIT_COMMIT_SHA` |
| `SENTRY_TRACES_SAMPLE_RATE` | No | Trace sampling rate from `0` to `1`; defaults to `1.0` |
| `SENTRY_MCP_RECORD_INPUTS` | No | Records MCP inputs in Sentry when set to `true`; defaults to `false` |
| `SENTRY_MCP_RECORD_OUTPUTS` | No | Records MCP outputs in Sentry when set to `true`; defaults to `false` |
| `SENTRY_SEND_DEFAULT_PII` | No | Enables Sentry default PII behavior; defaults to `false` |
***
## Testing locally
```bash theme={null}
# Build the MCP server
cd packages/mcp
npm install
npm run build
# Test tools/list
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | T49_API_TOKEN=YOUR_API_KEY npm run mcp:stdio
# Test search_container
echo '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"search_container","arguments":{"query":"CAIU1234567"}},"id":2}' | T49_API_TOKEN=YOUR_API_KEY npm run mcp:stdio
# Test the hosted endpoint
curl -X POST https://mcp.terminal49.com \
-H "Authorization: Token $T49_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-06-18" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
```
***
## Related guides
* [MCP Overview](/docs/mcp/home) — Quick start and tools reference
* Setup guides: [Claude](/docs/mcp/setup/claude), [Claude Code](/docs/mcp/setup/claude-code), [ChatGPT](/docs/mcp/setup/chatgpt), [Cursor](/docs/mcp/setup/cursor), [Microsoft Copilot](/docs/mcp/setup/microsoft-copilot), [VS Code](/docs/mcp/setup/vs-code), [Agent plugins](/docs/mcp/setup/agent-plugins), [Other clients](/docs/mcp/setup/other-clients)
* [Rate Limiting](/docs/api-docs/in-depth-guides/rate-limiting) — API limits (same for MCP)
* [Test Numbers](/docs/api-docs/useful-info/test-numbers) — Containers for testing
* [Webhooks](/docs/api-docs/in-depth-guides/webhooks) — Real-time updates
* [Data Coverage](/docs/coverage/home) — Data freshness and coverage
# Terminal49 API Quickstart
Source: https://terminal49.com/docs/api-docs/in-depth-guides/quickstart
Follow the Terminal49 API quickstart to create a tracking request, monitor your first shipment, and retrieve live container and shipment data.
## Before you begin
You need four things to get started.
1. **A Bill of Lading (BOL) number.** This is issued by your carrier. BOL numbers are found on your [bill of lading](https://en.wikipedia.org/wiki/Bill_of_lading) document. Ideally, this will be a shipment that is currently on the water or in terminal, but this is not necessary.
2. **The SCAC of the carrier that issued your bill of lading.** The Standard Carrier Alpha Code of your carrier is used to identify carriers in computer systems and in shipping documents. You can learn more about these [here](https://en.wikipedia.org/wiki/Standard_Carrier_Alpha_Code).
3. **A Terminal49 Account.** If you don't have one yet, [sign up here.](https://app.terminal49.com/register)
4. **An API key.** Sign in to your Terminal49 account and go to your [developer portal page](https://app.terminal49.com/developers/api-keys) to get your API key.
Not sure which SCAC to use? The [Infer Tracking
Number](/docs/api-docs/in-depth-guides/auto-detect-carrier) endpoint (Auto-Detect
Carrier) can identify it from your tracking number.
## Track a shipment
Use the request example below as a starting point, or copy the same values into Postman or cURL.
1. Replace `YOUR_API_KEY` in the `Authorization` header with your API key.
2. Replace `request_number` and `scac` with your shipment details. The request number must be a shipping line booking number, master bill of lading number, or container number. The SCAC must be a shipping line SCAC. See [Data Coverage](/docs/coverage/home) for coverage details.
```json POST /tracking_requests theme={null}
{
"method": "post",
"url": "https://api.terminal49.com/v2/tracking_requests",
"headers": {
"Content-Type": "application/vnd.api+json",
"Authorization": "Token YOUR_API_KEY"
},
"body": {
"data": {
"attributes": {
"request_type": "bill_of_lading",
"request_number": "",
"scac": ""
},
"type": "tracking_request"
}
}
}
```
## Check your tracking request succeeded
If you have not set up a webhook to receive status updates from the Terminal49 API, you need to poll manually to check whether the tracking request succeeded or failed.
**Tracking request troubleshooting**
The most common issue is entering the wrong number.
Check that you are entering a Bill of Lading number, booking number, or container number — not an internal reference from your company or freight forwarder. Verify the number by going to the carrier's website and tracking the shipment with it. If that works and Terminal49 supports the SCAC, you should be able to track it through the API.
Email [support@terminal49.com](mailto:support@terminal49.com) if you have persistent issues.
Use this request to list your recent tracking requests. Replace `YOUR_API_KEY` in the `Authorization` header with your API key.
```json GET /tracking_requests theme={null}
{
"method": "get",
"url": "https://api.terminal49.com/v2/tracking_requests",
"headers": {
"Content-Type": "application/vnd.api+json",
"Authorization": "Token YOUR_API_KEY"
}
}
```
## List your tracked shipments
If your tracking request was successful, you will now be able to list your tracked shipments.
Use this request to list tracked shipments. Replace `YOUR_API_KEY` in the `Authorization` header with your API key.
Sometimes it may take a while for the tracking request to show up, but usually no more than a few minutes.
If you had trouble adding your first shipment, try adding a few more.
```json GET /shipments theme={null}
{
"method": "get",
"url": "https://api.terminal49.com/v2/shipments",
"headers": {
"Content-Type": "application/vnd.api+json",
"Authorization": "Token YOUR_API_KEY"
}
}
```
## List all your tracked containers
You can also list out all of your containers, if you'd like to track at that level.
Use this request to list tracked containers. Replace `YOUR_API_KEY` in the `Authorization` header with your API key.
```json GET /containers theme={null}
{
"method": "get",
"url": "https://api.terminal49.com/v2/containers",
"headers": {
"Content-Type": "application/vnd.api+json",
"Authorization": "Token YOUR_API_KEY"
}
}
```
## Listening for updates with webhooks
The real power of Terminal49's API is that it is asynchronous. You can register a webhook — a callback URL that Terminal49 sends HTTP POST requests to when updates occur.
To try this, first set up a URL on the open web to receive POST requests. Once configured, you receive status updates from containers and shipments as they happen, so you do not need to poll for updates.
Choose the events you want to subscribe to (for example vessel departed, arrived, or discharged). Terminal49 sends those events to your webhook endpoint as soon as they happen.
You can test your endpoint before creating the actual webhook by sending a sample notification with the Trigger endpoint:
```json POST /webhooks/trigger theme={null}
{
"method": "post",
"url": "https://api.terminal49.com/v2/webhooks/trigger",
"headers": {
"Content-Type": "application/json",
"Authorization": "Token YOUR_API_KEY"
},
"body": {
"url": "https://webhook.site/",
"event": "container.transport.vessel_arrived",
"secret": "optional-test-secret"
}
}
```
Trigger sends an example payload for the event you choose.
Once tested, create the actual webhook to receive the real notifications as they happen:
```json POST /webhooks theme={null}
{
"method": "post",
"url": "https://api.terminal49.com/v2/webhooks",
"headers": {
"Content-Type": "application/vnd.api+json",
"Authorization": "Token YOUR_API_KEY"
},
"body": {
"data": {
"type": "webhook",
"attributes": {
"url": "https://webhook.site/",
"active": true,
"events": ["container.transport.vessel_arrived"]
}
}
}
}
```
Learn more about [Webhooks](/docs/api-docs/in-depth-guides/webhooks).
# Integrate rail container tracking data
Source: https://terminal49.com/docs/api-docs/in-depth-guides/rail-integration-guide
Integrate North American rail container tracking data with Terminal49 for unified shipment visibility across Class I rail and intermodal carriers.
This is a technical article about rail data within Terminal49's API and DataSync.
For a broader overview, including the reasons why you'd want rail visibility and how to use it in the Terminal49 dashboard,
[read the Terminal49 announcement post](https://www.terminal49.com/blog/launching-north-american-intermodal-rail-visibility-on-terminal49/).
## Table of contents
* [Supported rail carriers](#supported-rail-carriers)
* [Supported rail events and data attributes](#supported-rail-events-and-data-attributes)
* [Rail-specific transport events](#rail-specific-transport-events)
* [Webhook notifications](#webhook-notifications)
* [Rail container attributes](#rail-container-attributes)
* [Integration methods](#integration-methods)
* [Integration via API](#a-integration-via-api)
* [Integration via DataSync](#b-integration-via-datasync)
## Supported rail carriers
Terminal49's container tracking platform integrates with all North American Class I railroads that handle container shipping, providing comprehensive visibility into your rail container movements.
* BNSF Railway
* Canadian National Railway (CN)
* Canadian Pacific Railway (CP)
* CSX Transportation
* Norfolk Southern Railway (NS)
* Union Pacific Railroad (UP)
By integrating with these carriers, Terminal49 ensures that you have direct access to critical tracking data, enabling better decision-making and operational efficiency.
## Supported rail events and data attributes
Terminal49 seamlessly tracks your containers as they go from container ship, to ocean terminal, to rail carrier.
Terminal49 provides a [set of transport events](#webhook-notifications) that let you track the status of your containers as they move through the rail system. You can receive webhook notifications whenever these events occur.
Terminal49 also provides a set of attributes [on the container model](/docs/api-docs/api-reference/containers/get-a-container) with the current status of your container at any given time, including ETA, pickup facility, and availability information. For details on hold types, fee types, and how to determine release readiness at the port or an inland destination, see [Container Holds, Fees, and Release Readiness](/docs/api-docs/in-depth-guides/holds-and-fees).
### Rail-specific transport events
Several core transport events occur on most rail journeys. Some rail carriers do not share all events, but in general these are the key events for a container.
```mermaid theme={null}
graph LR
A[Rail Loaded] --> B[Rail Departed]
B --> C[Arrived at Inland Destination]
C --> D[Rail Unloaded]
D --> G[Available for Pickup]
G --> E[Full Out]
E --> F[Empty Return]
```
`Available for Pickup`, `Full Out` and `Empty Return` are not specific to rail, but are included here since they are a key part of the rail journey.
### Webhook notifications
Terminal49 provides webhook notifications to keep you updated on key transport events in a container's rail journey. These notifications let you integrate near real-time tracking data directly into your applications.
Here's a list of the rail-specific events which support webhook notifications:
| Transport Event | Webhook Notification | Description | Example |
| ----------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Rail Loaded | `container.transport.rail_loaded` | The container is loaded onto a railcar. | [Example](/docs/api-docs/useful-info/webhook-events-examples#container-transport-rail_loaded) |
| Rail Departed | `container.transport.rail_departed` | The container departs on the railcar (not always from port of discharge). | [Example](/docs/api-docs/useful-info/webhook-events-examples#container-transport-rail_departed) |
| Rail Arrived | `container.transport.rail_arrived` | The container arrives at a rail terminal (not always at the destination terminal). | [Example](/docs/api-docs/useful-info/webhook-events-examples#container-transport-rail_arrived) |
| Arrived At Inland Destination | `container.transport.arrived_at_inland_destination` | The container arrives at the destination terminal. | [Example](/docs/api-docs/useful-info/webhook-events-examples#container-transport-arrived_at_inland_destination) |
| Rail Unloaded | `container.transport.rail_unloaded` | The container is unloaded from a railcar. | [Example](/docs/api-docs/useful-info/webhook-events-examples#container-transport-rail_unloaded) |
| Rail LFD Changed | `container.pickup_lfd_rail.changed` | The Rail Last Free Day (LFD) for the container has changed ([Rail Plan only](/docs/api-docs/useful-info/entitlements)). | [Example](/docs/api-docs/useful-info/webhook-events-examples#container-pickup_lfd_rail-changed) |
An additional set of events triggers when the container's status changes at the destination rail terminal. For containers without rail, these events fire at the ocean terminal instead.
| Transport Event | Webhook Notification | Description | Example |
| --------------- | ------------------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| Full Out | `container.transport.full_out` | The full container leaves the rail terminal. | [Example](/docs/api-docs/useful-info/webhook-events-examples#container-transport-full_out) |
| Empty In | `container.transport.empty_in` | The empty container is returned to the terminal. | [Example](/docs/api-docs/useful-info/webhook-events-examples#container-transport-empty_in) |
Finally, there is a webhook notification for when the destination ETA changes.
| Transport Event | Webhook Notification | Description | Example |
| ----------------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| Estimated Destination Arrival | `container.transport.estimated.arrived_at_inland_destination` | Estimated time of arrival for the container at the destination rail terminal. | [Example](/docs/api-docs/useful-info/webhook-events-examples#container-transport-estimated-arrived_at_inland_destination) |
Integrate these notifications by subscribing to the webhooks and handling the incoming data to update your systems.
#### Set up a webhook for rail events
Rail events use the same webhook infrastructure as ocean events. There is no separate rail-only endpoint or configuration. Create one webhook and subscribe it to the rail events you care about.
You can create a webhook either from the [Developer Webhooks](https://app.terminal49.com/developers/webhooks) page in the dashboard or by calling [`POST /webhooks`](/docs/api-docs/api-reference/webhooks/create-a-webhook) with the rail events in the `events` array:
```json theme={null}
{
"data": {
"type": "webhook",
"attributes": {
"url": "https://your-endpoint.example.com/webhooks/terminal49",
"active": true,
"events": [
"container.transport.rail_loaded",
"container.transport.rail_departed",
"container.transport.rail_arrived",
"container.transport.arrived_at_inland_destination",
"container.transport.rail_unloaded",
"container.transport.estimated.arrived_at_inland_destination",
"container.pickup_lfd_rail.changed"
]
}
}
}
```
Notes:
* `container.pickup_lfd_rail.changed` requires the [Rail Plan entitlement](/docs/api-docs/useful-info/entitlements). Other rail events are available on standard API access.
* `container.transport.rail_arrived` fires **every time** a container arrives at a rail terminal — including intermediate interchanges, not just the final inland destination. Use the included location data (terminal name, city, FIRMS code, LOCODE) to identify each stop.
* Store the `secret` returned in the create response and verify the `X-T49-Webhook-Signature` header on each delivery. See the [Webhooks in-depth guide](/docs/api-docs/in-depth-guides/webhooks) and [Webhooks best practices](/docs/api-docs/webhooks/best-practices).
### Rail container attributes
The following attributes are specific to rail container tracking and live on the [container object](/docs/api-docs/api-reference/containers/get-a-container).
* **pod\_rail\_loaded\_at**: Time when the container is loaded onto a railcar at the POD.
* **pod\_rail\_departed\_at**: Time when the container departs from the POD.
* **ind\_eta\_at**: Estimated Time of Arrival at the inland destination, sourced from the rail carrier.
* **ind\_ata\_at**: Actual Time of Arrival at the inland destination, sourced from the rail carrier.
* **ind\_rail\_unloaded\_at**: Time when the container is unloaded from rail at the inland destination.
* **ind\_facility\_lfd\_on**: **Deprecated.** Last Free Day for demurrage charges at the inland destination terminal. Use `import_deadlines.pickup_lfd_rail` instead (timezone: `final_destination_timezone`).
* **pod\_rail\_carrier\_scac**: SCAC code of the rail carrier that picks up the container from the POD (this could be different than the rail carrier that delivers to the inland destination).
* **ind\_rail\_carrier\_scac**: SCAC code of the rail carrier that delivers the container to the inland destination.
#### Inland destination ETA/ATA: rail carrier vs. shipping line
For an inland (rail) move, two pairs of arrival fields exist and can differ:
| Field | Lives on | Source | Notes |
| ------------------------------------------- | --------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ind_eta_at` / `ind_ata_at` | container | Rail carrier | Estimated and actual arrival at the inland destination, reported directly by the rail carrier. |
| `destination_eta_at` / `destination_ata_at` | shipment | Shipping line (SSL) | Estimated and actual arrival at the shipment's destination as reported by the ocean carrier. For inland moves, this is the SSL's view of the inland destination. |
Use `ind_*` when you want the rail carrier's view (typically more granular and updated more frequently for inland legs). Use `destination_*` when you want the SSL-reported view on the shipment. The corresponding timezone for `ind_*` fields is `final_destination_timezone` (on the container); for `destination_*` fields it is `destination_timezone` (on the shipment).
#### Rail Last Free Day (LFD)
The container's top-level `pickup_lfd` attribute is a coalesced value derived from the `import_deadlines` object, in this order of preference:
1. `import_deadlines.pickup_lfd_line` — LFD as reported by the shipping line (preferred).
2. `import_deadlines.pickup_lfd_terminal` — LFD from the POD terminal (timezone: `pod_timezone`).
3. `import_deadlines.pickup_lfd_rail` — LFD from the rail carrier at the inland destination (timezone: `final_destination_timezone`).
For rail moves, subscribe to the `container.pickup_lfd_rail.changed` webhook to be notified when the rail carrier updates the inland LFD. The legacy `ind_facility_lfd_on` field is deprecated. Read `import_deadlines.pickup_lfd_rail` instead.
## Integration methods
There are two methods to integrate Terminal49's rail tracking data programmatically: via API and DataSync.
### A. Integration via API
Terminal49 provides a robust API that allows you to programmatically access rail container tracking data and receive updates via webhooks. You will receive rail events and attributes alongside events and attributes from the ocean terminal and carrier.
[Here's a step-by-step guide to get started](/docs/api-docs/getting-started/start-here).
### B. Integration via DataSync
Terminal49's DataSync service automatically syncs up-to-date tracking data with your system. The rail data lives in the same tables alongside the ocean terminal and carrier data.
[Learn more about DataSync](/docs/datasync/overview)
# Rate Limiting
Source: https://terminal49.com/docs/api-docs/in-depth-guides/rate-limiting
Understand Terminal49 API rate limits, HTTP 429 responses, and how to design clients that handle throttling with retries and exponential backoff.
## Overview
Terminal49 API implements rate limiting to ensure fair usage and maintain service quality for all users.
The default API limit is 100 requests per minute per API key/account on a rolling 60-second window. Some high-volume or expensive endpoints use their own bucket.
## Rate limit details
All limits apply per API key/account on a rolling 60-second window.
| Bucket | Endpoint | Limit |
| ----------------------- | ----------------------------------------- | -------------------------------- |
| Default API requests | All endpoints without a dedicated bucket | 100 requests per minute |
| Create Tracking Request | `POST /v2/tracking_requests` | 100 tracking requests per minute |
| Infer Tracking Number | `POST /v2/tracking_requests/infer_number` | 200 requests per minute |
| Refresh Container | `PATCH /v2/containers/{id}/refresh` | 10 requests per minute |
## Rate limit response
When you exceed the rate limit, the API will return:
**HTTP Status Code**: `429 Too Many Requests`
**Response Headers**:
* `Retry-After`: Number of seconds to wait before making another request against the same rate-limit bucket
`Retry-After` is the only rate-limit header the API documents. Base your 429 handling on it.
**Response Body**:
```json theme={null}
{
"errors": [
{
"status": "429",
"title": "Too Many Requests",
"detail": "Your account has exceeded its API rate limit. Please reduce request frequency or contact support to increase your limit. Consider using webhooks for real-time updates instead of polling."
}
]
}
```
## Best practices
### 1. Use webhooks instead of polling
The most effective way to avoid rate limits is to use **webhooks** for real-time updates instead of repeatedly polling the API:
* Configure webhooks to receive push notifications when shipment data changes
* Eliminates the need for frequent polling
* Provides instant updates without consuming your rate limit
* See the [Webhooks](/docs/api-docs/in-depth-guides/webhooks) section for setup instructions
### 2. Implement exponential backoff
If you receive a `429` response:
1. Check the `Retry-After` header and, when present, wait at least that many seconds
2. If `Retry-After` is missing, back off exponentially (for example 1s, 2s, 4s) and add jitter so retries don't synchronize
3. Cap the number of retries and surface an error once the cap is reached
4. Don't retry immediately, as this will consume your limit further
### 3. Batch your requests
* Use list endpoints with filtering instead of multiple individual requests
* Leverage the [`include` parameter](/docs/api-docs/in-depth-guides/including-resources) to fetch related resources in a single request
* Cache responses when appropriate to reduce redundant calls
### 4. Monitor your usage
* Track your request patterns
* Identify and optimize high-frequency operations
* Consider spreading requests over time rather than bursting
## Need a higher limit?
If your use case requires a higher rate limit:
1. **Evaluate webhook usage first** - Most polling use cases can be replaced with webhooks
2. **Contact support** at [support@terminal49.com](mailto:support@terminal49.com)
3. **Provide details** about your use case and expected request volume
4. **Our team will work with you** to find an appropriate solution
## Example: handling rate limits
Here's an example of how to properly handle rate limit responses in Python. It honors `Retry-After` when the server provides it and falls back to exponential backoff with jitter otherwise:
```python theme={null}
import random
import time
import requests
def make_request_with_retry(url, headers, max_retries=5):
"""
Make an API request with automatic retry on rate limit.
Honors the Retry-After header when present; otherwise falls back
to exponential backoff with jitter.
Args:
url: The API endpoint URL
headers: Request headers including Authorization
max_retries: Maximum number of retry attempts
Returns:
Response object if successful
Raises:
Exception: If max retries exceeded
"""
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code != 429:
# Return response for any other status code
return response
retry_after = response.headers.get('Retry-After')
if retry_after is not None:
# Honor the server's instruction
wait_seconds = int(retry_after)
else:
# Exponential backoff with jitter: ~1s, 2s, 4s, 8s, ...
wait_seconds = (2 ** attempt) + random.uniform(0, 1)
print(f"Rate limited. Waiting {wait_seconds:.1f} seconds "
f"(attempt {attempt + 1} of {max_retries})...")
time.sleep(wait_seconds)
raise Exception("Max retries exceeded")
# Example usage
headers = {
'Authorization': 'Token YOUR_API_KEY'
}
response = make_request_with_retry(
'https://api.terminal49.com/v2/shipments',
headers
)
```
## Tips for high-volume applications
If you're building a high-volume application:
* **Design for webhooks from the start**: Don't rely on polling for data updates
* **Implement request queuing**: Spread your requests evenly across the rate limit window
* **Use pagination efficiently**: Fetch larger pages less frequently rather than small pages frequently
* **Cache aggressively**: Store and reuse data that doesn't change frequently
* **Honor `Retry-After`**: When a `429` response includes `Retry-After`, wait at least that many seconds before retrying against the same bucket.
# Vessel and Container Route Data
Source: https://terminal49.com/docs/api-docs/in-depth-guides/routing
Access detailed container route segments and vessel position data from the Terminal49 API to build richer map and shipment visibility experiences.
This is a technical article describing how to use the Routing Data feature, using the map as an example.
Routing Data (Container Map GeoJSON API) is a paid feature. These APIs are subject to additional terms of usage and pricing. See [Entitlements and Paid Features](/docs/api-docs/useful-info/entitlements) for the required entitlement and non-entitled error response.
## Table of contents
* [Overview](#overview)
* [Getting started](#getting-started)
* [Understanding the response](#understanding-the-response)
* [GeoJSON FeatureCollection structure](#geojson-featurecollection-structure)
* [Feature types](#feature-types)
* [Port](#port)
* [Current vessel](#current-vessel)
* [Past vessel locations](#past-vessel-locations)
* [Estimated full leg](#estimated-full-leg)
* [Estimated partial leg](#estimated-partial-leg)
* [Building your map](#building-your-map)
* [Use cases](#use-cases)
* [Recommendations and best practices](#recommendations-and-best-practices)
* [Frequently asked questions](#frequently-asked-questions)
## Overview
The `GET /v2/containers/{id}/map_geojson` endpoint provides all the map-related data for a container in a single GeoJSON response.
The endpoint returns a GeoJSON FeatureCollection containing:
* **Port locations** (Point geometries): Port of lading (POL), port of discharge (POD), and transshipment ports (TS1, TS2, etc.)
* **Current vessel location** (Point geometry): The current position of the vessel if the container is currently at sea
* **Past vessel paths** (LineString geometries): Historical positions of vessels for completed and in-progress legs of the journey
* **Estimated future paths** (LineString geometries): Predicted vessel routes for upcoming or in-progress legs
## Getting started
To retrieve the map data for a container, make a simple GET request to the endpoint:
```shell Request theme={null}
curl --request GET \
--url https://api.terminal49.com/v2/containers/{id}/map_geojson \
--header "Authorization: Token YOUR_API_KEY"
```
The response is a standard GeoJSON FeatureCollection that can be directly used with most mapping libraries (Leaflet, Mapbox GL, Google Maps, etc.).
```json theme={null}
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
100.896831042,
13.065302386
]
},
"properties": {
"feature_type": "port",
"ports_sequence": 1,
"ports_total": 3,
"label": "POL",
"name": "Laem Chabang",
// ... more properties
}
},
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
100.868768333,
13.07306
],
[
100.839155,
13.079318333
],
// ... more coordinates
[
118.03862,
24.440998333
]
]
},
"properties": {
"feature_type": "past_vessel_locations",
"vessel_id": "87a12f43-766c-4078-89bc-ac6595082f7b",
// ... more path properties
}
},
// ... more features
]
}
```
## Understanding the response
### GeoJSON FeatureCollection structure
The response follows the [GeoJSON specification](https://geojson.org/) and contains:
* `type`: Always `"FeatureCollection"`
* `features`: An array of GeoJSON Feature objects, each representing a map element (port, vessel, or route path)
Each feature contains:
* `type`: Always `"Feature"`
* `geometry`: A GeoJSON geometry object (Point or LineString)
* `properties`: An object containing metadata specific to the feature type
### Feature types
The `properties.feature_type` field identifies what each feature represents. The following feature types are available:
#### Port
Geometry Type: `Point`
Port features represent all ports in the container's route: the port of lading (POL), port of discharge (POD), and any transshipment ports (TS1, TS2, etc.).
```json theme={null}
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
100.896831042,
13.065302386
]
},
"properties": {
"feature_type": "port",
"ports_sequence": 1,
"ports_total": 3,
"location_id": "c5adae24-6fd4-4720-8813-976cf206feb1",
"location_type": "Port",
"name": "Laem Chabang",
"state_abbr": "20",
"state": null,
"country_code": "TH",
"country": "Thailand",
"time_zone": "Asia/Bangkok",
"inbound_eta_at": null,
"inbound_ata_at": null,
"outbound_etd_at": null,
"outbound_atd_at": "2025-11-08T00:44:52Z",
"label": "POL",
"updated_at": "2025-12-11T09:01:08Z"
}
}
```
| Property | Type | Description |
| ----------------- | -------------- | ------------------------------------------------------------------- |
| `feature_type` | string | Always `"port"` |
| `ports_sequence` | integer | The sequence number of this port in the route (1 = POL, last = POD) |
| `ports_total` | integer | Total number of ports in the route |
| `location_id` | string | Unique identifier for the port location |
| `location_type` | string | Always `"Port"` |
| `name` | string | Name of the port |
| `state_abbr` | string \| null | State abbreviation (if applicable) |
| `state` | string \| null | State name (if applicable) |
| `country_code` | string | ISO country code |
| `country` | string | Country name |
| `time_zone` | string | IANA timezone identifier |
| `label` | string | Port label: `"POL"`, `"POD"`, or `"TS1"`, `"TS2"`, etc. |
| `inbound_eta_at` | string \| null | Estimated time of arrival (ISO 8601) |
| `inbound_ata_at` | string \| null | Actual time of arrival (ISO 8601) |
| `outbound_etd_at` | string \| null | Estimated time of departure (ISO 8601) |
| `outbound_atd_at` | string \| null | Actual time of departure (ISO 8601) |
| `updated_at` | string \| null | Last update timestamp from the shipment (ISO 8601) |
#### Current vessel
Geometry Type: `Point`
This feature is only present when the container is currently on a vessel at sea. It represents the vessel's current position.
```json theme={null}
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [
-131.128473333,
31.023033333
]
},
"properties": {
"feature_type": "current_vessel",
"ports_sequence": 2,
"vessel_id": "93fc5dce-4c7f-4089-bd28-f20cd9202ab0",
"vessel_name": "ZIM BANGKOK",
"vessel_imo": "9936525",
"voyage_number": "13E",
"vessel_location_timestamp": "2025-12-11T11:46:03Z",
"vessel_location_heading": 108,
"vessel_location_speed": 21,
"departure_port_id": "ed64d446-9098-420c-ab08-c127e62509fe",
"departure_port_name": "Xiamen",
"departure_port_state_abbr": "FJ",
"departure_port_state": null,
"departure_port_country_code": "CN",
"departure_port_country": "China",
"departure_port_label": "TS1",
"departure_port_atd": "2025-11-19T16:00:00Z",
"departure_port_time_zone": "Asia/Shanghai",
"arrival_port_id": "6129528d-846e-4571-ae16-b5328a4285ab",
"arrival_port_name": "Savannah",
"arrival_port_state_abbr": "GA",
"arrival_port_state": "Georgia",
"arrival_port_country_code": "US",
"arrival_port_country": "United States",
"arrival_port_label": "POD",
"arrival_port_eta": "2025-12-31T05:00:00Z",
"arrival_port_time_zone": "America/New_York"
}
}
```
| Property | Type | Description |
| ----------------------------- | -------------- | ----------------------------------------------------- |
| `feature_type` | string | Always `"current_vessel"` |
| `ports_sequence` | integer | Sequence number of the departure port for this leg |
| `vessel_id` | string | Unique identifier for the vessel |
| `vessel_name` | string | Name of the vessel |
| `vessel_imo` | string | IMO number of the vessel |
| `voyage_number` | string \| null | Voyage number for this leg |
| `vessel_location_timestamp` | string | Timestamp of the vessel position (ISO 8601) |
| `vessel_location_heading` | number \| null | Vessel heading in degrees (0-360) |
| `vessel_location_speed` | number \| null | Vessel speed in knots |
| `departure_port_id` | string | ID of the port the vessel departed from |
| `departure_port_name` | string | Name of the departure port |
| `departure_port_state_abbr` | string \| null | State abbreviation of departure port |
| `departure_port_state` | string \| null | State name of departure port |
| `departure_port_country_code` | string | Country code of departure port |
| `departure_port_country` | string | Country name of departure port |
| `departure_port_label` | string | Label of departure port (POL, POD, TS1, etc.) |
| `departure_port_atd` | string \| null | Actual time of departure from the port (ISO 8601) |
| `departure_port_time_zone` | string | Timezone of departure port |
| `arrival_port_id` | string \| null | ID of the next port the vessel is heading to |
| `arrival_port_name` | string \| null | Name of the arrival port |
| `arrival_port_state_abbr` | string \| null | State abbreviation of arrival port |
| `arrival_port_state` | string \| null | State name of arrival port |
| `arrival_port_country_code` | string \| null | Country code of arrival port |
| `arrival_port_country` | string \| null | Country name of arrival port |
| `arrival_port_label` | string \| null | Label of arrival port (POL, POD, TS1, etc.) |
| `arrival_port_eta` | string \| null | Estimated time of arrival at the next port (ISO 8601) |
| `arrival_port_time_zone` | string \| null | Timezone of arrival port |
#### Past vessel locations
Geometry Type: `LineString`
These features represent the actual historical paths taken by vessels for completed and in-progress legs of the journey. Each LineString contains a series of coordinates showing where the vessel traveled between two ports.
```json theme={null}
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
100.868768333,
13.07306
],
[
100.839155,
13.079318333
],
// ... many more coordinates
[
118.03862,
24.440998333
]
]
},
"properties": {
"feature_type": "past_vessel_locations",
"ports_sequence": 1,
"vessel_id": "87a12f43-766c-4078-89bc-ac6595082f7b",
"start_time": "2025-11-08T00:44:52Z",
"end_time": "2025-11-15T16:00:00Z",
"point_count": 546,
"outbound_atd_at": "2025-11-08T00:44:52Z",
"inbound_ata_at": "2025-11-15T16:00:00Z",
"inbound_eta_at": null
}
}
```
| Property | Type | Description |
| ----------------- | -------------- | ------------------------------------------------------------ |
| `feature_type` | string | Always `"past_vessel_locations"` |
| `ports_sequence` | integer | Sequence number of the departure port for this leg |
| `vessel_id` | string | Unique identifier for the vessel that traveled this path |
| `start_time` | string | Start timestamp of the path (ISO 8601) |
| `end_time` | string | End timestamp of the path (ISO 8601) |
| `point_count` | integer | Number of coordinate points in the LineString |
| `outbound_atd_at` | string \| null | Actual time of departure from the origin port (ISO 8601) |
| `inbound_ata_at` | string \| null | Actual time of arrival at the destination port (ISO 8601) |
| `inbound_eta_at` | string \| null | Estimated time of arrival at the destination port (ISO 8601) |
#### Estimated full leg
Geometry Type: `LineString`
These features represent predicted vessel paths for future legs that have not yet started. The LineString shows the estimated route between two ports.
```json theme={null}
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[55.059917502, 24.987353081],
[55.234, 24.856],
[56.123, 24.567],
// ... intermediate estimated points
[79.851136851, 6.942742853]
]
},
"properties": {
"feature_type": "estimated_full_legs",
"ports_sequence": 2,
"previous_port_id": "94892d07-ef8f-4f76-a860-97a398c2c177",
"next_port_id": "818ef299-aed3-49c9-b3f7-7ee205f697f6",
"point_count": 87
}
}
```
| Property | Type | Description |
| ------------------ | ------- | -------------------------------------------------- |
| `feature_type` | string | Always `"estimated_full_legs"` |
| `ports_sequence` | integer | Sequence number of the departure port for this leg |
| `previous_port_id` | string | ID of the origin port |
| `next_port_id` | string | ID of the destination port |
| `point_count` | integer | Number of coordinate points in the LineString |
#### Estimated partial leg
Geometry Type: `LineString`
This feature represents the predicted path from the vessel's current position to the next port. It is only present when the container is currently on a vessel at sea.
```json theme={null}
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [
[
-131.128473333,
31.023033333
],
[
-130.9177,
30.67224
],
// ... many more coordinates
[
-80.91232,
32.03728
]
]
},
"properties": {
"feature_type": "estimated_partial_leg",
"ports_sequence": 2,
"current_port_id": "ed64d446-9098-420c-ab08-c127e62509fe",
"next_port_id": "6129528d-846e-4571-ae16-b5328a4285ab",
"point_count": 364
}
}
```
| Property | Type | Description |
| ----------------- | ------- | -------------------------------------------------- |
| `feature_type` | string | Always `"estimated_partial_leg"` |
| `ports_sequence` | integer | Sequence number of the departure port for this leg |
| `current_port_id` | string | ID of the port the vessel departed from |
| `next_port_id` | string | ID of the next port the vessel is heading to |
| `point_count` | integer | Number of coordinate points in the LineString |
This feature is only present when there is a `current_vessel` feature. The LineString starts from the vessel's current position (which matches the `current_vessel` feature coordinates) and extends to the next port.
## Building your map
To visualize a container's journey using the GeoJSON response on your own map (similar to [the embeddable map](/docs/api-docs/in-depth-guides/terminal49-map)):
1. **Load the GeoJSON data** into your mapping library (Leaflet, Mapbox GL, Google Maps, etc.)
2. **Filter features by type** to style them differently:
* **Ports**: Display as markers with labels (POL, POD, TS1, etc.)
* **Current vessel**: Display as a special marker (e.g., a ship icon) with vessel information
* **Past vessel locations**: Display as solid lines (representing completed journeys)
* **Estimated partial leg** and **Estimated full legs**: Display as dashed lines (representing future predictions)
3. **Use the properties** to add interactivity:
* Show port details (name, country, timestamps) on click/hover
* Display vessel information (name, IMO, speed, heading) for the current vessel
* Show leg information (departure/arrival times, vessel ID) for path segments
```javascript theme={null}
// Fetch the GeoJSON data
fetch('https://api.terminal49.com/v2/containers/{id}/map_geojson', {
headers: {
'Authorization': 'Token YOUR_API_KEY'
}
})
.then(response => response.json())
.then(geojson => {
// Create a map
const map = L.map('map').setView([20, 70], 3);
// Add base layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
// Process each feature
geojson.features.forEach(feature => {
const props = feature.properties;
if (props.feature_type === 'port') {
// Add port marker
const marker = L.marker([feature.geometry.coordinates[1], feature.geometry.coordinates[0]])
.addTo(map)
.bindPopup(`${props.label} ${props.name}`);
} else if (props.feature_type === 'current_vessel') {
// Add current vessel marker
const vesselIcon = L.icon({
iconUrl: 'vessel-icon.png',
iconSize: [32, 32]
});
L.marker([feature.geometry.coordinates[1], feature.geometry.coordinates[0]], {icon: vesselIcon})
.addTo(map)
.bindPopup(`${props.vessel_name} Speed: ${props.vessel_location_speed} knots`);
} else if (props.feature_type === 'past_vessel_locations') {
// Add past path as solid line
const coordinates = feature.geometry.coordinates.map(coord => [coord[1], coord[0]]);
L.polyline(coordinates, {color: 'green', weight: 3})
.addTo(map);
} else if (props.feature_type === 'estimated_full_legs' || props.feature_type === 'estimated_partial_leg') {
// Add estimated path as dashed line
const coordinates = feature.geometry.coordinates.map(coord => [coord[1], coord[0]]);
L.polyline(coordinates, {color: 'blue', weight: 2, dashArray: '10, 10'})
.addTo(map);
}
});
});
```
## Use cases
Integrating Terminal49's Vessel and Container Route APIs enables a variety of advanced capabilities:
* **Track Complete Shipment Journeys Visually:** Monitor shipments across multiple legs on a map, from the port of lading to the port of discharge, including all transshipment points.
* **Identify Transshipment Details Geographically:** Clearly see where transshipments occur and the routes taken between them.
* **Correlate Timestamps with Locations:** Visually connect ETDs, ETAs, ATDs, and ATAs for every leg with their geographical points on the map for precise planning and exception management.
* **Improve Internal Logistics Dashboards:** Offer your operations team a clear visual overview of all ongoing shipments and their current locations.
## Recommendations and best practices
* **Polling Intervals**: For active containers (currently at sea), refresh the map data up to once per hour to get updated vessel positions. For containers that have completed their journey, you can cache the data as it won't change.
* **Error Handling**: Implement proper error handling for API requests. If a container doesn't have route data yet, the endpoint will return an empty FeatureCollection (`{"type": "FeatureCollection", "features": []}`).
If you decide to create your own map:
* **Data Layering:** Consider layering information on your map. Start with basic port markers and paths, then add details like vessel names, ETAs, or status on hover or click.
* **Map Library Integration:** Use a robust mapping library (e.g., Leaflet, Mapbox GL, Google Maps, OpenLayers) to handle the rendering of markers, lines, and map interactivity.
* **Styling Guidelines**:
* Use distinct colors/styles for different feature types (ports, current vessel, past paths, estimated paths)
* Consider using dashed lines for estimated paths and solid lines for completed paths
* Add labels to port markers showing POL, POD, TS1, etc.
* Display vessel information in popups or info panels
* **Data Interpretation**:
* The `ports_sequence` property helps you understand the order of ports in the journey
* Use `inbound_ata_at` and `outbound_atd_at` to determine which legs are completed
* The presence of a `current_vessel` feature indicates the container is currently at sea
* **Handling Antimeridian Crossings**: When container routes cross the International Date Line (antimeridian at ±180° longitude), standard map projections can display routes incorrectly, showing lines that wrap around the entire globe. For mapping libraries that don't natively handle antimeridian crossings, the recommended approach is to: (1) detect and split crossing LineStrings into separate segments, and (2) render map features across multiple world views (standard, East, and West) as needed. Below are more details:
* **Detection**: Identify LineString features (past vessel locations or estimated paths) that cross the antimeridian by checking if consecutive coordinates have a longitude difference greater than 180°.
* **Single Crossing Solution**: When one antimeridian crossing is detected:
* Split the route into two segments: features before the crossing (based on `ports_sequence`) are drawn in the standard world view
* Features after the crossing are drawn in an extended world view (East or West, depending on crossing direction)
* Split the crossing LineString into two separate lines: one ending at the antimeridian in the standard view, and one starting from the antimeridian in the extended view
* **Multiple Crossings**: For routes with more than one antimeridian crossing (rare but possible), render all features across three world views (standard, East, and West) with duplicated features. Split all crossing lines to prevent lines from wrapping across the globe.
* **No Crossings**: If no antimeridian crossings are detected, render all features in the standard world view without any special handling.
## Frequently asked questions
Vessel location data is updated every 15 minutes, although that does not guarantee there will be a new position every 15 minutes due to factors like whether the vessel is transmitting or within range of a satellite or base station.
Predicted future positions are based on algorithms and historical data. Their accuracy can vary based on many factors such as temporary deviations, weather conditions, seasonality, or how frequently the shipping lane is used.
Predicted paths are estimates. The historical path (once available as a `past_vessel_locations` feature) will show the actual route taken. Regularly refreshing data for active shipments is key to getting the most accurate information.
The `current_vessel` feature is only present when:
* The container is currently on a vessel at sea
* The vessel has departed from a port (`outbound_atd_at` is present)
* The next port hasn't been reached yet (`inbound_ata_at` is not present)
* A valid vessel location can be retrieved
Currently, the endpoint returns data for a single container. You'll need to make separate API calls for each container you want to display on your map.
All coordinates follow the GeoJSON standard: `[longitude, latitude]` in WGS84 (EPSG:4326) format.
The endpoint applies simplification to reduce the number of points in LineStrings for better performance. The simplification tolerance can vary, but the paths remain accurate for visualization purposes.
# Terminal49 Map Embed Guide
Source: https://terminal49.com/docs/api-docs/in-depth-guides/terminal49-map
Embed the Terminal49 container map in your website with a publishable API key to display live vessel positions and shipment location data.
## Prerequisites
* A Terminal49 account.
* A publishable map API key. See [Entitlements and Paid Features](/docs/api-docs/useful-info/entitlements) for access requirements.
* Familiarity with the [Shipments API](/docs/api-docs/api-reference/shipments/list-shipments) and [Containers API](/docs/api-docs/api-reference/containers/list-containers).
The following examples pass `containerId` and `shipmentId` variables to the embedded map.
They relate to `id` attributes of the container and shipment objects that are returned by the API.
The map embed works from a local development environment. Open your HTML file directly in a browser or serve it from `http://localhost`. There is no domain allowlisting on the embed itself. Your page just needs internet access to load `bundle.js` and `bundle.css` from `https://map.terminal49.com`, and a valid publishable API key.
## Embed the map on your website
Once you have the API key, you can embed the map on your website.
Copy and paste the code below and insert it on your website.
Once loaded, this will make the map code available through the global `window` object.
Just before the closing `` tag, add the following link tag to load the map styles.
```html theme={null}
Document
```
Just before the closing `