API Documentation
Last updated: October 2026
Learn how to integrate with the DwellZero Container Tracking API
Quick Start
- Create an account and an API key under Settings → API in your dashboard
- Add containers to tracking with their terminal code
- Read their status from the API, or get it pushed to you by email alerts and webhooks
Base URL
https://api.dwellzero.comAuthentication
Every request except the terminal list and health check needs an API key. Create keys under Settings → API in your dashboard (up to 10 per account). A key is shown once when you create it, so store it safely.
Sending the key
API keys look like 550e8400-e29b-41d4-a716-446655440000. Send one in the Authorization header, or in an X-API-Key header:
Authorization: Bearer your-api-key
# or
X-API-Key: your-api-keyExample Request
curl -X GET \
-H "Authorization: Bearer your-api-key" \
"https://api.dwellzero.com/tracking"Container States
Each tracked container has a lifecycle (where the box is) and a status (what DwellZero is doing with it).
lifecycle
| NOT_FOUND | The terminal does not list the container yet (usually still on the water). |
| IN_TRANSIT | The terminal knows the container, but it is not off the vessel yet. |
| DISCHARGED | Off the vessel, not available for pickup yet (holds, releases or yard moves). |
| AVAILABLE | Available for pickup. |
| GATE_OUT | Left the terminal. Tracking ends. |
status
| syncing | Just added; the first check is running. |
| active | Found at the terminal and checked on a schedule. |
| pending | Not found at the terminal yet; checked every few hours. After 7 days it expires and the credit is returned. |
| error | The terminal could not be reached. DwellZero keeps retrying; after 3 days of errors it expires. When the whole terminal service is down, it waits in error until the service is back and does not expire. |
| completed | The container left the terminal. |
| expired | Tracking stopped without a gate-out. A container that never returned data gets its credit back. |
How often containers are checked
The schedule follows the container: several times a day, more often while it is arriving and as the Last Free Day gets close, and less often before the terminal lists it. Checks pause at night in the terminal's time zone. nextSyncAt on a container says when it is checked next.
Dates
lastFreeDay is a calendar day (YYYY-MM-DD) in the terminal's local calendar; it is the earliest of the terminal's and the shipping line's free days. Moments such as dischargedAt and gateOutAt are UTC timestamps. terminalTimeZone on a container is the terminal's time zone.
Container Tracking
Add containers to tracking. DwellZero checks each one with its terminal on a schedule (see Container States) until it leaves the terminal, and can tell you about changes by email or webhook. Adding a container uses one credit or one of your 10 free containers a month; checks are never charged.
POST/trackingAdd container to tracking
Request Body
{
"terminalCode": "NWK-MAH",
"containerNumber": "BEAU6666666",
"notifyEmails": ["user@example.com", "team@example.com"], // optional, up to 10
"billOfLading": "MEDU1234567" // optional; CSX ramps return the Last Free Day only with it
}Code Examples
curl -X POST \
-H "Authorization: Bearer your-api-key-uuid" \
-H "Content-Type: application/json" \
-d '{
"terminalCode": "NWK-MAH",
"containerNumber": "BEAU6666666",
"notifyEmails": ["user@example.com"]
}' \
"https://api.dwellzero.com/tracking"const response = await fetch('https://api.dwellzero.com/tracking', {
method: 'POST',
headers: {
'Authorization': 'Bearer your-api-key-uuid',
'Content-Type': 'application/json'
},
body: JSON.stringify({
terminalCode: 'NWK-MAH',
containerNumber: 'BEAU6666666',
notifyEmails: ['user@example.com']
})
});
const data = await response.json();
console.log(data);import requests
response = requests.post(
'https://api.dwellzero.com/tracking',
headers={
'Authorization': 'Bearer your-api-key-uuid',
'Content-Type': 'application/json'
},
json={
'terminalCode': 'NWK-MAH',
'containerNumber': 'BEAU6666666',
'notifyEmails': ['user@example.com']
}
)
data = response.json()
print(data)package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"io/ioutil"
)
func main() {
payload := map[string]interface{}{
"terminalCode": "NWK-MAH",
"containerNumber": "BEAU6666666",
"notifyEmails": []string{"user@example.com"},
}
jsonPayload, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST",
"https://api.dwellzero.com/tracking",
bytes.NewBuffer(jsonPayload))
req.Header.Set("Authorization", "Bearer your-api-key-uuid")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}Response
// 201 Created (200 when the container is already tracked, with "isNew": false and no charge)
{
"success": true,
"trackingId": "550e8400-e29b-41d4-a716-446655440000",
"status": "syncing",
"isNew": true,
"message": "Container added to tracking. Initial sync in progress.",
"terminal": {
"code": "NWK-MAH",
"name": "Maher Terminal",
"location": "New Jersey"
},
"notifyEmails": ["user@example.com", "team@example.com"],
"billOfLading": "MEDU1234567",
"charge": {
"credits": 0,
"freeQuota": true,
"alreadyCharged": false,
"balance": 42,
"freeRemaining": 6
},
"usage": { "containersThisMonth": 4, "freeRemaining": 6, "balance": 42 },
"timestamp": "2026-10-26T10:30:00.000Z"
}GET/trackingList all tracked containers
Code Examples
curl -X GET \
-H "Authorization: Bearer your-api-key-uuid" \
"https://api.dwellzero.com/tracking"const response = await fetch('https://api.dwellzero.com/tracking', {
headers: {
'Authorization': 'Bearer your-api-key-uuid'
}
});
const data = await response.json();
console.log(data.data); // Array of tracked containers
console.log(data.stats); // your containers by status: { total, active, pending, completed, ... }import requests
response = requests.get(
'https://api.dwellzero.com/tracking',
headers={'Authorization': 'Bearer your-api-key-uuid'}
)
data = response.json()
for container in data['data']:
print(f"{container['containerNumber']} - {container['status']}")package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func main() {
req, _ := http.NewRequest("GET",
"https://api.dwellzero.com/tracking", nil)
req.Header.Set("Authorization", "Bearer your-api-key-uuid")
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}Response
{
"success": true,
"count": 1,
"stats": {
"total": 1, "syncing": 0, "active": 1, "pending": 0, "completed": 0, "expired": 0, "error": 0,
"isSyncing": false,
"lastSyncTime": "2026-10-26T10:15:00.000Z",
"nextSyncTime": "2026-10-26T11:15:00.000Z"
},
"data": [
{
"trackingId": "550e8400-e29b-41d4-a716-446655440000",
"containerNumber": "BEAU6666666",
"terminalCode": "NWK-MAH",
"terminal": "Maher Terminal",
"location": "New Jersey",
"terminalTimeZone": "America/New_York",
"status": "active",
"lifecycle": "AVAILABLE",
"createdAt": "2026-10-25T08:00:00.000Z",
"updatedAt": "2026-10-26T10:15:00.000Z",
"notifyEmails": ["user@example.com"],
"billOfLading": null,
"shareUrl": "https://app.dwellzero.com/c/0123456789abcdef0123456789abcdef",
"lfdRequiresBillOfLading": false,
"billOfLadingMismatch": false,
"lastSyncAt": "2026-10-26T10:15:00.000Z",
"lastChangeAt": "2026-10-26T10:15:00.000Z",
"available": true,
"lastFreeDay": "2026-10-30",
"transitState": "IN YARD",
"freightReleased": true,
"customsReleased": true,
"hasHolds": false,
"holds": [],
"dischargedAt": "2026-10-25T14:20:00.000Z",
"gateOutAt": null,
"vessel": { "name": "OCEAN ANNA", "voyage": "123W" },
"errorMessage": null
}
],
"timestamp": "2026-10-26T10:30:00.000Z"
}GET/tracking/:trackingIdGet container details
The container with everything the terminal reported. data is the latest answer, previousData the one before it. shareUrl is a read-only page you can send to anyone, no login needed.
Code Examples
curl -X GET \
-H "Authorization: Bearer your-api-key-uuid" \
"https://api.dwellzero.com/tracking/550e8400-e29b-41d4-a716-446655440000"const trackingId = '550e8400-e29b-41d4-a716-446655440000';
const response = await fetch(`https://api.dwellzero.com/tracking/${trackingId}`, {
headers: {
'Authorization': 'Bearer your-api-key-uuid'
}
});
const data = await response.json();
console.log(data.data); // { available, lastFreeDay, transitState, ... }import requests
tracking_id = '550e8400-e29b-41d4-a716-446655440000'
response = requests.get(
f'https://api.dwellzero.com/tracking/{tracking_id}',
headers={'Authorization': 'Bearer your-api-key-uuid'}
)
data = response.json()
print(f"Available: {data['data']['available']}")
print(f"Last Free Day: {data['data']['lastFreeDay']}")package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func main() {
trackingId := "550e8400-e29b-41d4-a716-446655440000"
req, _ := http.NewRequest("GET",
fmt.Sprintf("https://api.dwellzero.com/tracking/%s", trackingId), nil)
req.Header.Set("Authorization", "Bearer your-api-key-uuid")
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}Response
{
"success": true,
"trackingId": "550e8400-e29b-41d4-a716-446655440000",
"containerNumber": "BEAU6666666",
"terminalCode": "NWK-MAH",
"terminal": "Maher Terminal",
"location": "New Jersey",
"terminalTimeZone": "America/New_York",
"status": "active",
"lifecycle": "AVAILABLE",
"createdAt": "2026-10-25T08:00:00.000Z",
"updatedAt": "2026-10-26T10:15:00.000Z",
"lastSyncAt": "2026-10-26T10:15:00.000Z",
"nextSyncAt": "2026-10-26T11:15:00.000Z",
"notifyEmails": ["user@example.com"],
"billOfLading": null,
"shareUrl": "https://app.dwellzero.com/c/0123456789abcdef0123456789abcdef",
"lfdRequiresBillOfLading": false,
"billOfLadingMismatch": false,
"data": {
"containerNumber": "BEAU6666666",
"found": true,
"lifecycle": "AVAILABLE",
"available": true,
"transitState": "IN YARD",
"lastFreeDay": "2026-10-30",
"portLfd": "2026-10-30",
"lineLfd": null,
"freightReleased": true,
"customsReleased": true,
"hasHolds": false,
"holds": [],
"dischargedAt": "2026-10-25T14:20:00.000Z",
"gateOutAt": null,
"vessel": { "name": "OCEAN ANNA", "voyage": "123W" },
"line": { "name": "Ocean Line" },
"containerType": { "iso": "45G1", "description": "40' High Cube" },
"terminal": "Maher Terminal",
"location": "New Jersey"
},
"previousData": { "...": "the answer before this one, same fields" },
"lastChangeAt": "2026-10-26T10:15:00.000Z",
"timestamp": "2026-10-26T10:30:00.000Z"
}PATCH/tracking/:trackingId/notifyUpdate notification emails
Code Examples
curl -X PATCH \
-H "Authorization: Bearer your-api-key-uuid" \
-H "Content-Type: application/json" \
-d '{
"notifyEmails": ["new@example.com", "another@example.com"]
}' \
"https://api.dwellzero.com/tracking/550e8400-e29b-41d4-a716-446655440000/notify"const trackingId = '550e8400-e29b-41d4-a716-446655440000';
const response = await fetch(`https://api.dwellzero.com/tracking/${trackingId}/notify`, {
method: 'PATCH',
headers: {
'Authorization': 'Bearer your-api-key-uuid',
'Content-Type': 'application/json'
},
body: JSON.stringify({
notifyEmails: ['new@example.com', 'another@example.com']
})
});
const data = await response.json();
console.log(data.notifyEmails); // Updated email listimport requests
tracking_id = '550e8400-e29b-41d4-a716-446655440000'
response = requests.patch(
f'https://api.dwellzero.com/tracking/{tracking_id}/notify',
headers={
'Authorization': 'Bearer your-api-key-uuid',
'Content-Type': 'application/json'
},
json={
'notifyEmails': ['new@example.com', 'another@example.com']
}
)
data = response.json()
print(data['message'])package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"io/ioutil"
)
func main() {
trackingId := "550e8400-e29b-41d4-a716-446655440000"
payload := map[string]interface{}{
"notifyEmails": []string{"new@example.com", "another@example.com"},
}
jsonPayload, _ := json.Marshal(payload)
req, _ := http.NewRequest("PATCH",
fmt.Sprintf("https://api.dwellzero.com/tracking/%s/notify", trackingId),
bytes.NewBuffer(jsonPayload))
req.Header.Set("Authorization", "Bearer your-api-key-uuid")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}Request Body
{
"notifyEmails": ["new@example.com", "another@example.com"]
}Response
{
"success": true,
"trackingId": "550e8400-e29b-41d4-a716-446655440000",
"notifyEmails": ["new@example.com", "another@example.com"],
"message": "Notification emails updated. 2 email(s) configured.",
"timestamp": "2026-01-16T10:30:00Z"
}PATCH/tracking/:trackingId/bill-of-ladingSet or clear the bill of lading
CSX rail ramps return the Last Free Day only when the search includes the bill of lading. Tracking responses include lfdRequiresBillOfLading: true when that is why the LFD is missing. Send null to clear it.
Request Body
{
"billOfLading": "MEDU1234567"
}Response
{
"success": true,
"trackingId": "550e8400-e29b-41d4-a716-446655440000",
"billOfLading": "MEDU1234567",
"timestamp": "2026-10-23T10:30:00Z"
}DELETE/tracking/:trackingIdRemove from tracking
Remove a container from tracking. This will stop all syncing and notifications.
Code Examples
curl -X DELETE \
-H "Authorization: Bearer your-api-key-uuid" \
"https://api.dwellzero.com/tracking/550e8400-e29b-41d4-a716-446655440000"const trackingId = '550e8400-e29b-41d4-a716-446655440000';
const response = await fetch(`https://api.dwellzero.com/tracking/${trackingId}`, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer your-api-key-uuid'
}
});
const data = await response.json();
console.log(data.message); // "Container removed from tracking"import requests
tracking_id = '550e8400-e29b-41d4-a716-446655440000'
response = requests.delete(
f'https://api.dwellzero.com/tracking/{tracking_id}',
headers={'Authorization': 'Bearer your-api-key-uuid'}
)
data = response.json()
print(data['message'])package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func main() {
trackingId := "550e8400-e29b-41d4-a716-446655440000"
req, _ := http.NewRequest("DELETE",
fmt.Sprintf("https://api.dwellzero.com/tracking/%s", trackingId), nil)
req.Header.Set("Authorization", "Bearer your-api-key-uuid")
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}Response
{
"success": true,
"message": "Container removed from tracking",
"timestamp": "2026-01-16T10:30:00Z"
}POST/tracking/bulkBulk add containers
Add up to 500 containers in one request. Each item is checked and charged on its own, so some can succeed while others fail. Items take the same fields as POST /tracking.
Request Body
{
"containers": [
{
"terminalCode": "NWK-MAH",
"containerNumber": "MSDU1234560",
"notifyEmails": ["user@example.com"] // optional, up to 10
},
{
"terminalCode": "LALB-APM",
"containerNumber": "TCNU7654328"
}
]
}Code Examples
curl -X POST \
-H "Authorization: Bearer your-api-key-uuid" \
-H "Content-Type: application/json" \
-d '{
"containers": [
{ "terminalCode": "NWK-MAH", "containerNumber": "MSDU1234560", "notifyEmails": ["user@example.com"] },
{ "terminalCode": "LALB-APM", "containerNumber": "TCNU7654328" },
{ "terminalCode": "BAL-SGT", "containerNumber": "CMAU9876540" }
]
}' \
"https://api.dwellzero.com/tracking/bulk"const response = await fetch('https://api.dwellzero.com/tracking/bulk', {
method: 'POST',
headers: {
'Authorization': 'Bearer your-api-key-uuid',
'Content-Type': 'application/json'
},
body: JSON.stringify({
containers: [
{ terminalCode: 'NWK-MAH', containerNumber: 'MSDU1234560', notifyEmails: ['user@example.com'] },
{ terminalCode: 'LALB-APM', containerNumber: 'TCNU7654328' },
{ terminalCode: 'BAL-SGT', containerNumber: 'CMAU9876540' }
]
})
});
const data = await response.json();
console.log(data.summary); // { total, created, alreadyTracked, failed, insufficientCredits }
console.log(data.results); // Per-container resultsimport requests
response = requests.post(
'https://api.dwellzero.com/tracking/bulk',
headers={
'Authorization': 'Bearer your-api-key-uuid',
'Content-Type': 'application/json'
},
json={
'containers': [
{'terminalCode': 'NWK-MAH', 'containerNumber': 'MSDU1234560', 'notifyEmails': ['user@example.com']},
{'terminalCode': 'LALB-APM', 'containerNumber': 'TCNU7654328'},
{'terminalCode': 'BAL-SGT', 'containerNumber': 'CMAU9876540'}
]
}
)
data = response.json()
print(f"Created: {data['summary']['created']}, Failed: {data['summary']['failed']}")package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"io/ioutil"
)
func main() {
payload := map[string]interface{}{
"containers": []map[string]interface{}{
{"terminalCode": "NWK-MAH", "containerNumber": "MSDU1234560", "notifyEmails": []string{"user@example.com"}},
{"terminalCode": "LALB-APM", "containerNumber": "TCNU7654328"},
{"terminalCode": "BAL-SGT", "containerNumber": "CMAU9876540"},
},
}
jsonPayload, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST",
"https://api.dwellzero.com/tracking/bulk",
bytes.NewBuffer(jsonPayload))
req.Header.Set("Authorization", "Bearer your-api-key-uuid")
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}Response
{
"success": true,
"summary": {
"total": 3,
"created": 2,
"alreadyTracked": 1,
"failed": 0,
"insufficientCredits": 0
},
"results": [
{ "containerNumber": "MSDU1234560", "status": "created", "trackingId": "550e8400-..." },
{ "containerNumber": "TCNU7654328", "status": "created", "trackingId": "660f9500-..." },
{ "containerNumber": "CMAU9876540", "status": "already_tracked", "trackingId": "770a0600-..." }
],
"timestamp": "2026-01-16T10:30:00Z"
}Per-Container Status Values
| Status | Description |
|---|---|
| created | New tracking created successfully |
| already_tracked | Container was already being tracked |
| insufficient_credits | Free containers used up and not enough credits. Later new containers in the same request are skipped. |
| failed | Validation error (see the error field) |
GET/tracking/statsGet tracking statistics
Your containers by status, and when they were last and are next checked.
Code Examples
curl -X GET \
-H "Authorization: Bearer your-api-key-uuid" \
"https://api.dwellzero.com/tracking/stats"const response = await fetch('https://api.dwellzero.com/tracking/stats', {
headers: {
'Authorization': 'Bearer your-api-key-uuid'
}
});
const data = await response.json();
console.log(`Total: ${data.data.total}, Active: ${data.data.active}`);
console.log(`Next sync: ${data.data.nextSyncTime}`);import requests
response = requests.get(
'https://api.dwellzero.com/tracking/stats',
headers={'Authorization': 'Bearer your-api-key-uuid'}
)
data = response.json()
stats = data['data']
print(f"Total: {stats['total']}, Active: {stats['active']}")
print(f"Next sync: {stats['nextSyncTime']}")package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func main() {
req, _ := http.NewRequest("GET",
"https://api.dwellzero.com/tracking/stats", nil)
req.Header.Set("Authorization", "Bearer your-api-key-uuid")
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}Response
{
"success": true,
"data": {
"total": 10,
"syncing": 0,
"active": 6,
"pending": 2,
"completed": 1,
"expired": 0,
"error": 1,
"isSyncing": false,
"lastSyncTime": "2026-10-26T10:15:00.000Z",
"nextSyncTime": "2026-10-26T10:42:00.000Z"
},
"timestamp": "2026-10-26T10:30:00.000Z"
}Arrival Notices
Upload an arrival notice PDF and DwellZero reads the containers, the terminal (or the rail ramp for boxes moving inland) and the bill of lading, then starts tracking them. Each new container is charged like POST /tracking. You can also forward notices by email to the import address on your dashboard.
Upload
POST /tracking/import-an/upload, multipart form with a file field (PDF, up to 10 MB, 20 uploads an hour). Reading happens in the background: the answer is an import id to check.
curl -X POST \
-H "Authorization: Bearer your-api-key" \
-F "file=@arrival-notice.pdf" \
"https://api.dwellzero.com/tracking/import-an/upload"
# 202 Accepted
{ "success": true, "import_id": "7d0f3c1e-2b8a-4e57-9a5e-1f2c3d4e5f60" }Check the import
GET /tracking/imports/:importId. Poll every few seconds until status is completed, partial or failed.
{
"success": true,
"data": {
"id": "7d0f3c1e-2b8a-4e57-9a5e-1f2c3d4e5f60",
"source": "upload",
"filename": "arrival-notice.pdf",
"status": "completed",
"containersFound": 2,
"containersCreated": 2,
"containersUpdated": 0,
"errorMessage": null,
"createdAt": "2026-10-26T10:30:00.000Z"
}
}Credits
Each account gets 10 free containers a month; after that, adding a container uses one credit. Re-adding the same container at the same terminal in the same month is free, and a container that never returned any data gets its credit back. When both are used up, new containers answer 402 INSUFFICIENT_CREDITS; containers already tracked keep updating. See pricing to top up.
Check your balance
GET /billing/credits. GET /billing/credits/transactions lists the history (?limit=50&before=<id>).
{
"success": true,
"balance": 42,
"freeQuota": { "limit": 10, "used": 3, "remaining": 7, "resetsAt": "2026-11-01T00:00:00.000Z" },
"containersThisMonth": 3,
"estimatedContainersRemaining": 49,
"expiring": { "credits": 15, "at": "2026-11-15T10:00:00.000Z" },
"timestamp": "2026-10-26T10:30:00.000Z"
}Email Notifications
Get notified via email when your container status changes or when the Last Free Day is approaching.
Notification Types
- Now TrackingSent when DwellZero first finds the container at the terminal, with its state and Last Free Day
- Status ChangeSent when availability, holds, the Last Free Day or the terminal status change, and at gate-out
- LFD ReminderSent 2 days and 1 day before the Last Free Day, and on the day
- LFD PassedDaily while the container is past its Last Free Day and still at the terminal
- Tracking StoppedSent when tracking expires: the container never showed up, or the terminal kept failing
Adding Notification Emails
You can add up to 10 email addresses when creating tracking or update them later:
// When creating tracking
{
"terminalCode": "NWK-MAH",
"containerNumber": "BEAU6666666",
"notifyEmails": ["dispatch@company.com", "logistics@company.com"]
}
// Update emails later
PATCH /tracking/:trackingId/notify
{
"notifyEmails": ["new@company.com"]
}Auto-Stop Notifications
Emails stop when the container leaves the terminal (lifecycle GATE_OUT); the gate-out itself is the last one. Each email has a "View container" button to the container's read-only page, so people on the list need no DwellZero account.
Webhooks
DwellZero can POST container events to your own system. Add up to 5 endpoints under Settings → API or through the API, and pick the events each one receives. Webhooks cover every container on your account, whether or not it has notification emails.
Setting up a webhook
- Build a receiver. An https URL on your server that accepts a JSON POST, checks the signature and answers 200 within 10 seconds. The example below is a complete receiver in Node.js.
- Make it reachable. DwellZero only sends to public https addresses. For a first test on your own machine, run the example and open a tunnel to it, e.g.
cloudflared tunnel --url http://localhost:4000, which prints an https URL. Add/dwellzero/webhookto it. - Add the endpoint. Under Settings → API, click Add endpoint, paste the URL and tick the events you want. Copy the signing secret it shows: it is shown only once. Put it in your receiver's configuration (
DWELLZERO_WEBHOOK_SECRETin the example). - Send a test event. Click Send test event. DwellZero sends a
pingwith a sample container and shows the status your server answered. Recent deliveries lists every event and its result. - Go live. Events now arrive as your containers change, and LFD events once a day, in the morning. Move the receiver to your own server when you are done testing, and update the URL with Edit.
Good practice: check the signature on the raw body, answer before doing slow work, and skip an id you already handled (a retry sends the same event again).
// npm install express
const express = require('express');
const crypto = require('crypto');
const SECRET = process.env.DWELLZERO_WEBHOOK_SECRET; // whsec_... from Settings > API in your dashboard
const seen = new Set(); // use your database in production
function verify(rawBody, headers) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const ts = Number(timestamp);
if (!id || !timestamp || !Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = Buffer.from(SECRET.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
return (headers['webhook-signature'] || '').split(' ').some((entry) => {
const sig = Buffer.from(entry.slice(3), 'base64');
return entry.startsWith('v1,') && sig.length === expected.length && crypto.timingSafeEqual(sig, expected);
});
}
const app = express();
// Raw body: the signature covers the exact bytes DwellZero sent
app.post('/dwellzero/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verify(rawBody, req.headers)) return res.status(401).end();
const event = JSON.parse(rawBody);
res.status(200).end(); // answer first, then do the work
if (seen.has(event.id)) return; // a retry of an event you already handled
seen.add(event.id);
const box = event.container;
switch (event.event) {
case 'ping':
console.log('DwellZero test event received');
break;
case 'container.changed':
console.log(`${box.containerNumber}: ${event.changes.map((c) => c.field).join(', ')} changed`);
break;
case 'container.lfd_reminder':
case 'container.lfd_passed':
console.log(`${box.containerNumber}: LFD ${event.lfd.day} (${event.lfd.daysUntil} days)`);
break;
default:
console.log(event.event, box.containerNumber);
}
});
app.listen(4000, () => console.log('Listening on http://localhost:4000/dwellzero/webhook'));Events
| container.tracking_started | DwellZero found the container at the terminal. Has "lfd" ({ day, daysUntil }) when the terminal already gives one. |
| container.changed | Availability, holds, Last Free Day, terminal status or gate-out changed. Has "changes". |
| container.lfd_reminder | The Last Free Day is in 2 days, tomorrow or today (sent once a day, in the morning). Has "lfd". |
| container.lfd_passed | Daily while the container is past its Last Free Day and still at the terminal. Has "lfd" (negative daysUntil). |
| container.tracking_stopped | Tracking expired. Has "reason": "not_found" or "error". |
| ping | The test event from "Send test event". Sent to that endpoint whatever it subscribes to. |
Payload
A JSON POST. container is the container as it is after the event. Use id to skip an event you already handled.
{
"id": "evt_8sKq2mXbV3rT0yPzL1nW4eHd",
"event": "container.changed",
"createdAt": "2026-10-26T18:00:05.000Z",
"trackingId": "tracking-uuid",
"container": {
"containerNumber": "MSCU1234566",
"terminalCode": "NWK-MAH",
"terminal": "Maher Terminal",
"location": "New Jersey",
"status": "active",
"lifecycle": "AVAILABLE",
"lastFreeDay": "2026-10-30",
"available": true,
"transitState": "IN YARD",
"freightReleased": true,
"customsReleased": true,
"hasHolds": false,
"holds": [],
"dischargedAt": "2026-10-25T10:00:00Z",
"gateOutAt": null,
"vessel": { "name": "OCEAN ANNA", "voyage": "123W" },
"line": { "name": "Ocean Line" },
"lastCheckedAt": "2026-10-26T18:00:00Z",
"lastChangeAt": "2026-10-26T18:00:00Z",
"billOfLading": "MEDU1234567"
},
"changes": [{ "field": "available", "oldValue": false, "newValue": true }],
"shareUrl": "https://app.dwellzero.com/c/<token>"
}Verifying the signature
Each request has webhook-id, webhook-timestamp and webhook-signature headers (the Standard Webhooks scheme). The signature is an HMAC-SHA256 of id.timestamp.body with your endpoint's signing secret. Check it against the raw body, before parsing the JSON:
const crypto = require('crypto');
// Express: app.post('/dwellzero/webhook', express.raw({ type: 'application/json' }), handler)
function verifyDwellZeroWebhook(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'] || '';
const ts = Number(timestamp);
if (!id || !timestamp || !Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; // missing, or older than 5 minutes
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = crypto.createHmac('sha256', key)
.update(`${id}.${timestamp}.${rawBody}`)
.digest();
return signatures.split(' ').some((entry) => {
const [version, sig] = entry.split(',');
const candidate = Buffer.from(sig || '', 'base64');
return version === 'v1' && candidate.length === expected.length
&& crypto.timingSafeEqual(candidate, expected);
});
}Delivery and retries
- Answer with any 2xx status within 10 seconds. The response body is ignored and redirects are not followed.
- Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours.
- After 10 events in a row fail every retry, the endpoint is turned off and the account owner gets an email.
- Endpoints must be public https URLs.
Managing endpoints through the API
GET /webhooks # your endpoints (up to 5)
POST /webhooks # { "url", "events"?, "description"? } -> returns "secret" once
PATCH /webhooks/:id # { "url"?, "events"?, "description"?, "enabled"? }
POST /webhooks/:id/secret # new signing secret, returned once
POST /webhooks/:id/test # send a test event (event "ping") right away
GET /webhooks/:id/deliveries # recent deliveries, newest first
DELETE /webhooks/:id
GET /webhooks/events # the event namesTerminal Codes
DwellZero covers 162 terminals (91 ports and 71 rail ramps) in the US and Canada. Use the code from the terminal list when adding a container; some terminals give less data than others (for example, CSX ramps need the bill of lading for the Last Free Day).
Port Terminals (90+)
Major container ports across North America.
NWK-MAH, NWK-PNCT, LALB-LBCT, POV-NIT, BAL-SGT...Rail Terminals (70+)
Intermodal rail yards across the country.
NS-ATLANTA, NS-CHICAGO_47TH, CSX-CHARLOTTE...Get Terminal List
GET /terminals, no key needed. Add ?type=port or ?type=rail to filter. GET /terminals/locations returns the same terminals grouped by location.
Code Examples
curl -X GET "https://api.dwellzero.com/terminals"
# Filter by type
curl -X GET "https://api.dwellzero.com/terminals?type=port"// List all terminals
// No API key needed
const response = await fetch('https://api.dwellzero.com/terminals');
const data = await response.json();
console.log(`${data.data.count} terminals available`);
// Filter by type (port or rail)
const portResponse = await fetch('https://api.dwellzero.com/terminals?type=port');import requests
# List all terminals
response = requests.get('https://api.dwellzero.com/terminals') # no API key needed
data = response.json()
for terminal in data['data']['terminals']:
print(f"{terminal['code']} - {terminal['name']}")
# Filter by type
rail = requests.get('https://api.dwellzero.com/terminals?type=rail')package main
import (
"fmt"
"net/http"
"io/ioutil"
)
func main() {
req, _ := http.NewRequest("GET",
"https://api.dwellzero.com/terminals", nil) // no API key needed
// Optional: filter by type
q := req.URL.Query()
q.Add("type", "port")
req.URL.RawQuery = q.Encode()
client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()
body, _ := ioutil.ReadAll(resp.Body)
fmt.Println(string(body))
}Response
{
"success": true,
"data": {
"count": 161,
"stats": {
"total": 166,
"byType": { "port": 93, "rail": 73 }
},
"terminals": [
{
"code": "NWK-MAH",
"name": "Maher Terminal",
"location": "New Jersey",
"type": "port",
"timeZone": "America/New_York"
},
...
]
},
"timestamp": "2026-10-26T10:30:00.000Z"
}Error Handling
Errors use standard HTTP status codes and the same JSON shape. Some errors add fields (the credits error below adds the balance).
Error Response Format
{
"success": false,
"error": {
"code": "INSUFFICIENT_CREDITS",
"message": "Your free containers for this month are used up and you have 0 credits. Top up to keep adding containers.",
"balance": 0,
"freeRemaining": 0,
"topupUrl": "/dashboard/billing",
"timestamp": "2026-01-16T10:30:00Z"
}
}Common Error Codes
| Code | HTTP | Description |
|---|---|---|
| MISSING_API_KEY | 401 | No API key in the Authorization or X-API-Key header |
| INVALID_API_KEY | 401 | The API key is wrong or expired |
| INVALID_API_KEY_FORMAT | 401 | The API key is malformed |
| REVOKED_API_KEY | 401 | The API key was revoked |
| ACCOUNT_SUSPENDED | 403 | The account is suspended |
| INSUFFICIENT_CREDITS | 402 | Free containers for the month used up and no credits left |
| INVALID_REQUEST | 400 | A required field is missing or has the wrong type |
| INVALID_TERMINAL_CODE | 400 | Unknown terminal code (see GET /terminals) |
| TERMINAL_UNAVAILABLE | 400 | The terminal is listed, but tracking there is paused |
| INVALID_CONTAINER_FORMAT | 400 | Not 4 letters and 7 digits |
| INVALID_CHECK_DIGIT | 400 | The last digit does not match the container number (usually a typo) |
| INVALID_EMAIL | 400 | Invalid email format in notifyEmails |
| INVALID_BILL_OF_LADING | 400 | Bill of lading must be letters, digits and dashes, up to 40 characters |
| TOO_MANY_EMAILS | 400 | Maximum 10 notification emails allowed |
| TRACKING_NOT_FOUND | 404 | No container with that tracking id on your account |
| FILE_REQUIRED | 400 | Arrival notice upload has no file field |
| INVALID_FILE_TYPE | 400 | Arrival notice upload is not a PDF |
| FILE_TOO_LARGE | 400 | Arrival notice upload is over 10 MB |
| IMPORT_NOT_FOUND | 404 | No import with that id on your account |
| INVALID_WEBHOOK_URL | 400 | Webhook URL is not public https, or has a user name and password |
| INVALID_WEBHOOK_EVENTS | 400 | Unknown webhook event, or no events picked |
| WEBHOOK_LIMIT_REACHED | 400 | The account already has 5 webhooks |
| WEBHOOK_NOT_FOUND | 404 | No webhook with that id on your account |
| RATE_LIMIT_EXCEEDED | 429 | Too many requests; wait and retry |
Rate limits
- Requests without an API key (terminal list, health): 20 a minute per IP address.
- Requests with an API key have no general limit.
- Arrival notice uploads: 20 an hour per account.
- Webhook test events: 10 a minute per account.
- Over a limit, the answer is
429 RATE_LIMIT_EXCEEDED; wait a minute and retry.
Need help? Contact us