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
- Caller submits a
CreateShipment payload to POST /shipping/shipments
- For GMP pharmacy, the service calls the FedEx Ship API to generate a label and tracking number
- Shipment record is created in the database with status
label_created
- If a label was generated, it is automatically added to the print queue as
pending
- Pharmacy staff prints labels via the print queue endpoints
- Status progresses:
label_created → packed → shipped → delivered
- Tracking info is fetched from FedEx with 30-minute caching for patient-facing display
Database Tables
| Table | Purpose |
shipments | Core shipment records with patient, address, tracking, and status data |
print_queue | FedEx label print queue with status tracking |
tracking_cache | Cached FedEx tracking responses (30-minute TTL) |
shipping_users | Dashboard authentication (pharmacy-isolated) |
shipping_api_keys | HMAC 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
| Header | Description |
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 };
}
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
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
| Header | Value |
Content-Type | application/json |
X-API-Key | Your API key |
X-Timestamp | ISO 8601 timestamp |
X-Signature | HMAC-SHA256 hex signature |
Request Body: CreateShipment
Top-level Fields
| Field | Type | | Description |
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
| Field | Type | | Description |
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
| Status | Condition | Body |
| 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
Retrieve a shipment record by ID. Returns the full shipment object including current status,
tracking information, and label data.
Path Parameters
| Parameter | Type | Description |
id |
uuid |
The shipment ID returned from the create endpoint |
Request Headers
| Header | Value |
X-API-Key | Your API key |
X-Timestamp | ISO 8601 timestamp |
X-Signature | HMAC-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
| Status | Condition | Body |
| 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
Update a shipment's status. Only forward transitions are allowed (see
Status Flow). Attempting an invalid transition returns
409 Conflict.
Path Parameters
| Parameter | Type | Description |
id |
uuid |
The shipment ID |
Request Headers
| Header | Value |
Content-Type | application/json |
X-API-Key | Your API key |
X-Timestamp | ISO 8601 timestamp |
X-Signature | HMAC-SHA256 hex signature |
Request Body
| Field | Type | | Description |
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",
}
Error Responses
| Status | Condition | Body |
| 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. delivered → packed) |
{ "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
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
| Parameter | Type | Description |
id |
uuid |
The shipment ID to void |
Request Headers
| Header | Value |
X-API-Key | Your API key |
X-Timestamp | ISO 8601 timestamp |
X-Signature | HMAC-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",
}
Error Responses
| Status | Condition | Body |
| 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 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
| Parameter | Type | Description |
trackingNumber |
string |
FedEx tracking number (e.g. "794644790132") |
Request Headers
| Header | Value |
X-API-Key | Your API key |
X-Timestamp | ISO 8601 timestamp |
X-Signature | HMAC-SHA256 hex signature |
Response (200)
The source field indicates where the data came from.
| Field | Type | Description |
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": { }
}
Error Responses
| Status | Condition | Body |
| 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.
Print Queue: List Pending
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
| Header | Value |
X-API-Key | Your API key |
X-Timestamp | ISO 8601 timestamp |
X-Signature | HMAC-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
| Field | Type | Description |
id | uuid | Print queue item ID |
shipmentId | uuid | Parent shipment ID |
status | string | Always "pending" for this endpoint |
createdAt | string | ISO 8601 timestamp |
printedAt | string | null | When the item was printed (always null for pending items) |
shipment.id | uuid | Shipment ID |
shipment.patientName | string | Patient full name |
shipment.medication | string | Medication name |
shipment.pharmacy | string | Source pharmacy |
shipment.trackingNumber | string | null | FedEx tracking number |
shipment.labelData | string | null | Base64-encoded PDF label |
shipment.status | string | Current shipment status |
Error Responses
| Status | Condition | Body |
| 401 |
Missing or invalid HMAC authentication |
{ "error": "Invalid signature" } |
| 500 |
Database error |
{ "error": "Failed to list print queue", "detail": "..." } |
Print Queue: Mark 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
| Parameter | Type | Description |
id |
uuid |
The print queue item ID (not the shipment ID) |
Request Headers
| Header | Value |
X-API-Key | Your API key |
X-Timestamp | ISO 8601 timestamp |
X-Signature | HMAC-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
| Status | Condition | Body |
| 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
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.
| Header | Description |
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
| Field | Type | | Description |
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",
}
}
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",
}
}
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
| Status | Condition | Body |
| 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
| From | Allowed 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.
Print Queue Lifecycle
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
| Status | Description |
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
| Action | Valid From | Transitions 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
| Parameter | Value |
| 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
- Check
tracking_cache for the tracking number
- If cached and
cachedAt is within 30 minutes, return { source: "cache", data }
- If no cache or cache expired, call FedEx Track API (
POST /track/v1/trackingnumbers)
- If FedEx succeeds, upsert cache and return
{ source: "api", data }
- If FedEx fails and stale cache exists, return
{ source: "stale_cache", data }
- 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
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.
| Column | Type | Nullable | Default | Description |
id | uuid | No | Random UUID | Primary key |
patient_id | varchar(100) | No | -- | Canvas patient ID |
patient_name | varchar(255) | No | -- | Patient full name |
address_line1 | varchar(255) | No | -- | Street address line 1 |
address_line2 | varchar(255) | Yes | null | Street address line 2 |
address_city | varchar(100) | No | -- | City |
address_state | varchar(2) | No | -- | Two-letter state code |
address_zip | varchar(10) | No | -- | ZIP code |
medication | varchar(255) | No | -- | Medication name |
pharmacy | varchar(50) | No | -- | Source pharmacy (gmp, boothwyn) |
tracking_number | varchar(100) | Yes | null | Carrier tracking number |
carrier | varchar(50) | Yes | null | Shipping carrier (FedEx, USPS) |
label_data | text | Yes | null | Base64-encoded PDF label |
status | varchar(30) | No | label_created | Current shipment status |
weight | real | Yes | 1.0 | Package weight in pounds |
created_at | timestamptz | No | now() | Record creation time |
updated_at | timestamptz | No | now() | Last modification time |