Shipping Service API

Centralized shipment management, FedEx label creation, tracking with caching, print queue, and Boothwyn webhook processing.

Overview

The Shipping Service manages the full shipment lifecycle: FedEx label creation for GMP-fulfilled orders, shipment status tracking with 30-minute caching, a print queue for pharmacy staff, and Boothwyn webhook processing for 3PL shipments.

When a prescription is approved and routed to GMP, the orchestrator calls this service to create a FedEx shipment label. The label is automatically added to the print queue. For Boothwyn-fulfilled orders, the Boothwyn webhook creates or updates shipment records when tracking information becomes available.

Base URL

https://api.yourera.com/shipping

How It Works

  1. Caller submits a CreateShipment payload to POST /shipping/shipments
  2. For GMP pharmacy, the service calls the FedEx Ship API to generate a label and tracking number
  3. Shipment record is created in the database with status label_created
  4. If a label was generated, it is automatically added to the print queue as pending
  5. Pharmacy staff prints labels via the print queue endpoints
  6. Status progresses: label_createdpackedshippeddelivered
  7. Tracking info is fetched from FedEx with 30-minute caching for patient-facing display

Database Tables

TablePurpose
shipmentsCore shipment records with patient, address, tracking, and status data
print_queueFedEx label print queue with status tracking
tracking_cacheCached FedEx tracking responses (30-minute TTL)
shipping_usersDashboard authentication (pharmacy-isolated)
shipping_api_keysHMAC API key/secret pairs for service authentication

Authentication

All /shipping/* endpoints (except /health and /shipping/webhooks/boothwyn) require HMAC-SHA256 authentication. Each API client is issued an API key (public identifier) and an API secret (used for signing).

Required Headers

HeaderDescription
X-API-Key Your public API key identifier
X-Timestamp Current timestamp in ISO 8601 format. Must be within 5 minutes of server time (replay protection).
X-Signature HMAC-SHA256 hex digest of the signing string

Signature Formula

The signing string is the timestamp, a dot, and the JSON-stringified request body:

HMAC-SHA256(apiSecret, timestamp + '.' + JSON.stringify(body))

The result is encoded as a lowercase hex string.

Replay Protection The X-Timestamp header must be within 5 minutes of the server's current time. Requests with expired timestamps are rejected with 401.

Node.js Example

import crypto from 'node:crypto';

const API_KEY = 'your-api-key';
const API_SECRET = 'your-api-secret';

function signRequest(body) {
  const timestamp = new Date().toISOString();
  const bodyStr = JSON.stringify(body);
  const signature = crypto
    .createHmac('sha256', API_SECRET)
    .update(timestamp + '.' + bodyStr)
    .digest('hex');

  return { timestamp, signature };
}

// Usage: Create a shipment
const body = {
  patientId: 'patient-uuid-here',
  patientName: 'Jane Smith',
  address: {
    line1: '123 Main St',
    city: 'Austin',
    state: 'TX',
    zip: '78701',
  },
  medication: 'Semaglutide 2.5mg/mL',
  pharmacy: 'gmp',
};

const { timestamp, signature } = signRequest(body);

const response = await fetch('https://api.yourera.com/shipping/shipments', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-API-Key': API_KEY,
    'X-Timestamp': timestamp,
    'X-Signature': signature,
  },
  body: JSON.stringify(body),
});
Important The body used for signing must be the exact same string sent as the request body. Call JSON.stringify() once, use it for both signing and the fetch body.

Create Shipment

POST /shipping/shipments

Create a shipment and optionally generate a FedEx label. For GMP pharmacy orders, a FedEx shipment is created automatically and the label is added to the print queue. For Boothwyn orders, the shipment record is created without a label (tracking arrives later via webhook).

Request Headers

HeaderValue
Content-Typeapplication/json
X-API-KeyYour API key
X-TimestampISO 8601 timestamp
X-SignatureHMAC-SHA256 hex signature

Request Body: CreateShipment

Top-level Fields

FieldTypeDescription
patientId string required Canvas patient ID. Min 1 character.
patientName string required Patient full name. Min 1 character.
address object required Shipping address. See address object.
medication string required Medication name (e.g. "Semaglutide 2.5mg/mL"). Min 1 character.
pharmacy string required Source pharmacy identifier. Use "gmp" for FedEx label generation or "boothwyn" for 3PL (no label created).
weight number optional Package weight in pounds. Must be a positive number. Defaults to 1.0.

address Object

FieldTypeDescription
line1 string required Street address line 1. Min 1 character.
line2 string optional Street address line 2 (apt, suite, etc.)
city string required City. Min 1 character.
state string required Two-letter state code (e.g. "TX"). Exactly 2 characters.
zip string required ZIP code. Min 5, max 10 characters (supports ZIP+4 format).

Example Request

{
  "patientId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "patientName": "Jane Smith",
  "address": {
    "line1": "123 Main St",
    "line2": "Apt 4B",
    "city": "Austin",
    "state": "TX",
    "zip": "78701"
  },
  "medication": "Semaglutide 2.5mg/mL",
  "pharmacy": "gmp",
  "weight": 1.5
}

Response

Success (201)

{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "patientId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "patientName": "Jane Smith",
  "addressLine1": "123 Main St",
  "addressLine2": "Apt 4B",
  "addressCity": "Austin",
  "addressState": "TX",
  "addressZip": "78701",
  "medication": "Semaglutide 2.5mg/mL",
  "pharmacy": "gmp",
  "trackingNumber": "794644790132",
  "carrier": "FedEx",
  "labelData": "JVBERi0xLjQK...",
  "status": "label_created",
  "weight": 1.5,
  "createdAt": "2026-03-20T14:30:00.000Z",
  "updatedAt": "2026-03-20T14:30:00.000Z"
}
GMP vs Boothwyn For pharmacy: "gmp", the response includes trackingNumber, carrier, and labelData (base64-encoded PDF). For pharmacy: "boothwyn", these fields are null -- tracking arrives later via the Boothwyn webhook.

Error Responses

StatusConditionBody
400 Validation failed (missing/invalid fields) { "error": "Invalid request", "details": [...] }
401 Missing or invalid HMAC authentication { "error": "Invalid signature" }
500 FedEx API failure or database error { "error": "Failed to create shipment", "detail": "..." }

Validation Error (400)

{
  "error": "Invalid request",
  "details": [
    {
      "code": "too_small",
      "minimum": 1,
      "type": "string",
      "inclusive": true,
      "message": "String must contain at least 1 character(s)",
      "path": ["patientId"]
    }
  ]
}

Get Shipment

GET /shipping/shipments/:id

Retrieve a shipment record by ID. Returns the full shipment object including current status, tracking information, and label data.

Path Parameters

ParameterTypeDescription
id uuid The shipment ID returned from the create endpoint

Request Headers

HeaderValue
X-API-KeyYour API key
X-TimestampISO 8601 timestamp
X-SignatureHMAC-SHA256 hex signature
Note For GET requests, sign against an empty object {} since there is no request body.

Response (200)

{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "patientId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "patientName": "Jane Smith",
  "addressLine1": "123 Main St",
  "addressLine2": "Apt 4B",
  "addressCity": "Austin",
  "addressState": "TX",
  "addressZip": "78701",
  "medication": "Semaglutide 2.5mg/mL",
  "pharmacy": "gmp",
  "trackingNumber": "794644790132",
  "carrier": "FedEx",
  "labelData": "JVBERi0xLjQK...",
  "status": "shipped",
  "weight": 1.5,
  "createdAt": "2026-03-20T14:30:00.000Z",
  "updatedAt": "2026-03-21T09:15:00.000Z"
}

Error Responses

StatusConditionBody
401 Missing or invalid HMAC authentication { "error": "Invalid signature" }
404 Shipment ID does not exist { "error": "Shipment not found" }
500 Database error { "error": "Failed to get shipment", "detail": "..." }

Update Status

PUT /shipping/shipments/:id/status

Update a shipment's status. Only forward transitions are allowed (see Status Flow). Attempting an invalid transition returns 409 Conflict.

Path Parameters

ParameterTypeDescription
id uuid The shipment ID

Request Headers

HeaderValue
Content-Typeapplication/json
X-API-KeyYour API key
X-TimestampISO 8601 timestamp
X-SignatureHMAC-SHA256 hex signature

Request Body

FieldTypeDescription
status string required New status. One of: "label_created", "packed", "shipped", "delivered", "voided"

Example Request

{
  "status": "packed"
}

Response (200)

Returns the updated shipment object (same shape as Get Shipment).

{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status": "packed",
  "updatedAt": "2026-03-20T15:00:00.000Z",
  // ... remaining shipment fields
}

Error Responses

StatusConditionBody
400 Invalid status value in request body { "error": "Invalid request", "details": [...] }
401 Missing or invalid HMAC authentication { "error": "Invalid signature" }
404 Shipment not found { "error": "Shipment not found" }
409 Invalid status transition (e.g. deliveredpacked) { "error": "Invalid status transition from 'delivered' to 'packed'" }
500 Database error { "error": "Failed to update status", "detail": "..." }
No Backward Transitions The service enforces strict forward-only status progression. You cannot move a shipment from shipped back to packed, or from delivered to any other state. Use the Void Shipment endpoint to cancel a shipment before it ships.

Void Shipment

PUT /shipping/shipments/:id/void

Void a shipment label. Only valid for shipments in label_created or packed status. For GMP shipments with a tracking number, this also voids the label with FedEx. Voiding an already-voided shipment is idempotent (returns the existing voided shipment).

Path Parameters

ParameterTypeDescription
id uuid The shipment ID to void

Request Headers

HeaderValue
X-API-KeyYour API key
X-TimestampISO 8601 timestamp
X-SignatureHMAC-SHA256 hex signature

Request Body

No request body required.

Response (200)

Returns the voided shipment object.

{
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status": "voided",
  "updatedAt": "2026-03-20T16:00:00.000Z",
  // ... remaining shipment fields
}

Error Responses

StatusConditionBody
401 Missing or invalid HMAC authentication { "error": "Invalid signature" }
404 Shipment not found { "error": "Shipment not found" }
409 Shipment has already shipped or been delivered { "error": "Cannot void shipment in 'shipped' status" }
500 FedEx void failure or database error { "error": "Failed to void shipment", "detail": "..." }
Idempotent Calling void on an already-voided shipment returns 200 with the existing shipment. This makes it safe to retry void operations without error handling for duplicate calls.

Tracking

GET /shipping/track/:trackingNumber

Get tracking information for a shipment. Results are cached for 30 minutes to reduce FedEx API calls. If the FedEx API is unavailable, stale cached data is returned as a fallback. See Tracking Cache for details.

Path Parameters

ParameterTypeDescription
trackingNumber string FedEx tracking number (e.g. "794644790132")

Request Headers

HeaderValue
X-API-KeyYour API key
X-TimestampISO 8601 timestamp
X-SignatureHMAC-SHA256 hex signature

Response (200)

The source field indicates where the data came from.

FieldTypeDescription
source string One of: "cache" (fresh cache hit), "stale_cache" (FedEx down, stale fallback), "api" (live FedEx response)
data object FedEx tracking response payload (events, status, estimated delivery, etc.)

Fresh Cache Hit

{
  "source": "cache",
  "data": {
    "trackingNumber": "794644790132",
    "statusDetail": "In transit",
    "estimatedDelivery": "2026-03-22T18:00:00.000Z",
    "events": [
      {
        "timestamp": "2026-03-20T10:00:00.000Z",
        "description": "Picked up",
        "city": "Dallas",
        "state": "TX"
      }
    ]
  }
}

Stale Fallback (FedEx Down)

{
  "source": "stale_cache",
  "data": { /* last cached tracking data, may be outdated */ }
}

Error Responses

StatusConditionBody
401 Missing or invalid HMAC authentication { "error": "Invalid signature" }
502 FedEx API unavailable and no cached data exists { "error": "Tracking service unavailable" }
500 Database error { "error": "Failed to get tracking info", "detail": "..." }
502 Only When No Cache Exists The 502 error only occurs when the FedEx API is down and there is no cached data for this tracking number. If stale cache exists, the service returns it with source: "stale_cache" instead of an error.
GET /shipping/print-queue

List all pending items in the print queue. Each item is joined with its parent shipment data, including patient name, medication, tracking number, and base64-encoded label PDF. Only items with status pending are returned.

Request Headers

HeaderValue
X-API-KeyYour API key
X-TimestampISO 8601 timestamp
X-SignatureHMAC-SHA256 hex signature

Response (200)

Returns an array of print queue items with embedded shipment data.

[
  {
    "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
    "shipmentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "status": "pending",
    "createdAt": "2026-03-20T14:30:00.000Z",
    "printedAt": null,
    "shipment": {
      "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "patientName": "Jane Smith",
      "medication": "Semaglutide 2.5mg/mL",
      "pharmacy": "gmp",
      "trackingNumber": "794644790132",
      "labelData": "JVBERi0xLjQK...",
      "status": "label_created"
    }
  }
]

Response Fields

FieldTypeDescription
iduuidPrint queue item ID
shipmentIduuidParent shipment ID
statusstringAlways "pending" for this endpoint
createdAtstringISO 8601 timestamp
printedAtstring | nullWhen the item was printed (always null for pending items)
shipment.iduuidShipment ID
shipment.patientNamestringPatient full name
shipment.medicationstringMedication name
shipment.pharmacystringSource pharmacy
shipment.trackingNumberstring | nullFedEx tracking number
shipment.labelDatastring | nullBase64-encoded PDF label
shipment.statusstringCurrent shipment status

Error Responses

StatusConditionBody
401 Missing or invalid HMAC authentication { "error": "Invalid signature" }
500 Database error { "error": "Failed to list print queue", "detail": "..." }
PUT /shipping/print-queue/:id/printed

Mark a print queue item as printed. Only items in pending or printing status can be marked as printed. Sets the printedAt timestamp.

Path Parameters

ParameterTypeDescription
id uuid The print queue item ID (not the shipment ID)

Request Headers

HeaderValue
X-API-KeyYour API key
X-TimestampISO 8601 timestamp
X-SignatureHMAC-SHA256 hex signature

Request Body

No request body required.

Response (200)

{
  "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
  "shipmentId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status": "printed",
  "createdAt": "2026-03-20T14:30:00.000Z",
  "printedAt": "2026-03-20T15:10:00.000Z"
}

Error Responses

StatusConditionBody
401 Missing or invalid HMAC authentication { "error": "Invalid signature" }
404 Print queue item not found { "error": "Print queue item not found" }
409 Item is already printed, skipped, or in error state { "error": "Cannot mark item as printed -- current status is 'printed'" }
500 Database error { "error": "Failed to mark as printed", "detail": "..." }

Boothwyn Webhook

POST /shipping/webhooks/boothwyn

Receives shipping updates from Boothwyn pharmacy. When a tracking number is present, the service either updates an existing shipment or creates a new shipment record. Payloads without a tracking number are skipped.

Authentication

The Boothwyn webhook uses a shared secret instead of HMAC. The secret is validated against the BOOTHWYN_WEBHOOK_SECRET environment variable.

HeaderDescription
x-webhook-secret Shared secret configured with Boothwyn. Must match the BOOTHWYN_WEBHOOK_SECRET env var.
No HMAC This endpoint does NOT use the standard HMAC authentication. It uses a simple shared secret header. The X-API-Key, X-Timestamp, and X-Signature headers are not required.

Request Body

FieldTypeDescription
orderId string optional Boothwyn order/case ID. Used as patientId if creating a new shipment record.
trackingNumber string | null optional Shipping tracking number. If empty or null, the webhook is skipped (no action taken).
carrier string optional Shipping carrier name. Defaults to "USPS" if not provided.
status string optional Shipment status. Defaults to "shipped" if not provided.

Example Request

{
  "orderId": "BTW-ORD-12345",
  "trackingNumber": "9400111899223456789012",
  "carrier": "USPS",
  "status": "shipped"
}

Responses

Skipped -- No Tracking Number (200)

{
  "received": true,
  "action": "skipped",
  "reason": "Empty tracking number"
}

Updated Existing Shipment (200)

{
  "received": true,
  "action": "updated",
  "shipment": {
    "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "status": "shipped",
    "carrier": "USPS",
    // ... remaining shipment fields
  }
}

Created New Shipment (200)

{
  "received": true,
  "action": "created",
  "shipment": {
    "id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
    "patientId": "BTW-ORD-12345",
    "patientName": "Boothwyn Order",
    "pharmacy": "boothwyn",
    "trackingNumber": "9400111899223456789012",
    "carrier": "USPS",
    "status": "shipped",
    // ... remaining fields
  }
}
Lookup by Tracking Number The webhook matches existing shipments by trackingNumber, not by orderId. If a shipment with the same tracking number already exists, it is updated. Otherwise, a new shipment record is created with placeholder address data ("N/A").

Error Responses

StatusConditionBody
401 Missing x-webhook-secret header { "error": "Missing webhook secret" }
401 Invalid webhook secret { "error": "Invalid webhook secret" }
500 BOOTHWYN_WEBHOOK_SECRET env var not configured { "error": "Webhook configuration error" }
500 Database error { "error": "Failed to process webhook", "detail": "..." }

Shipment Status Flow

Shipments follow a strict forward-only status progression. Terminal states (delivered and voided) cannot transition to any other state.

Status Diagram

                    +------------------+
                    |  label_created   |
                    +--------+---------+
                             |
                    +--------v---------+
                    |      packed      |
                    +--------+---------+
                             |
                    +--------v---------+
                    |     shipped      |
                    +--------+---------+
                             |
                    +--------v---------+
                    |    delivered     |  (terminal)
                    +------------------+

  Void path (from label_created or packed only):

    label_created ----+
                      +----->  voided  (terminal)
    packed -----------+

Allowed Transitions

FromAllowed Transitions
label_created packed, shipped, voided
packed shipped, voided
shipped delivered
delivered (none -- terminal state)
voided (none -- terminal state)
Skip Packed The transition from label_created directly to shipped is allowed. This supports workflows where the pack step is not tracked separately, such as Boothwyn 3PL shipments.
Same-Status Transitions Rejected Setting a shipment's status to its current value is not allowed and returns 409. The isValidTransition() function rejects from === to.

The print queue tracks FedEx label printing for pharmacy staff. Items are created automatically when a GMP shipment is created with a label.

Status Diagram

  +----------+     +-----------+     +-----------+
  | pending  +---->| printing  +---->|  printed  |
  +----+-----+     +-----+-----+     +-----------+
       |                 |
       |                 v
       |           +----------+
       |           |  error   |
       |           +----+-----+
       |                |
       +<---------------+  (retry: error -> pending)

  Auto-skip for voided shipments:

  pending ---> skipped  (if parent shipment is voided)

Print Queue Statuses

StatusDescription
pending Label is ready to print. Appears in the GET /shipping/print-queue list.
printing Label is currently being printed. Entered from pending or error (retry).
printed Label has been successfully printed. Set via PUT /shipping/print-queue/:id/printed.
error Print failed. Can be retried (transitions back to printing). Only entered from printing.
skipped Auto-skipped because the parent shipment was voided. Only applies to pending items.

Transition Rules

ActionValid FromTransitions To
Start printing pending, error printing
Mark printed pending, printing printed
Mark error printing error
Auto-skip (voided shipment) pending skipped

Tracking Cache

Tracking data from FedEx is cached in the tracking_cache database table to minimize API calls and improve response times. Each tracking number has a single cache entry that is refreshed on access when expired.

Cache Behavior

ParameterValue
TTL (Time to Live) 30 minutes (1,800,000 ms)
Storage tracking_cache PostgreSQL table (JSONB data column)
Cache key trackingNumber (unique index)
Stale fallback Yes -- returns expired cache if FedEx API is down

Resolution Logic

  1. Check tracking_cache for the tracking number
  2. If cached and cachedAt is within 30 minutes, return { source: "cache", data }
  3. If no cache or cache expired, call FedEx Track API (POST /track/v1/trackingnumbers)
  4. If FedEx succeeds, upsert cache and return { source: "api", data }
  5. If FedEx fails and stale cache exists, return { source: "stale_cache", data }
  6. If FedEx fails and no cache exists, return 502 Tracking service unavailable
Graceful Degradation The stale fallback ensures patients always see tracking info, even during FedEx outages. The source field lets the frontend indicate when data may be outdated.

Health Check

GET /health

Returns service health status. No authentication required.

Response (200)

{
  "status": "ok",
  "service": "shipping-service",
  "timestamp": "2026-03-20T14:30:00.000Z"
}

Shipment Data Model

The shipments table stores all shipment records. Below is the full column schema.

ColumnTypeNullableDefaultDescription
iduuidNoRandom UUIDPrimary key
patient_idvarchar(100)No--Canvas patient ID
patient_namevarchar(255)No--Patient full name
address_line1varchar(255)No--Street address line 1
address_line2varchar(255)YesnullStreet address line 2
address_cityvarchar(100)No--City
address_statevarchar(2)No--Two-letter state code
address_zipvarchar(10)No--ZIP code
medicationvarchar(255)No--Medication name
pharmacyvarchar(50)No--Source pharmacy (gmp, boothwyn)
tracking_numbervarchar(100)YesnullCarrier tracking number
carriervarchar(50)YesnullShipping carrier (FedEx, USPS)
label_datatextYesnullBase64-encoded PDF label
statusvarchar(30)Nolabel_createdCurrent shipment status
weightrealYes1.0Package weight in pounds
created_attimestamptzNonow()Record creation time
updated_attimestamptzNonow()Last modification time