Ship Mail and Track the Shipment
Ship Mail Items to any address and save the tracking details in your system.
Overview
When you request a Shipment, Stable ships the Mail Items to the address you choose and sends Webhooks as the shipment progresses. This recipe explores how you can use the API to ship mail on demand and save the tracking number and cost when the shipment is completed.
In the API, a Shipment may be referred to as a "Forward." See Shipping for more information.
At a high level, the steps you'll complete are:
- Find the Mail Items to ship.
- List the available shipping methods for the Mail Items and selected destination.
- Request the Shipment.
- Build a webhook handler for completed Shipments.
- Save the tracking details to your system.
- Register and test your webhook.
Before you start
- You must have an API key. See Authentication.
- You must be able to create and host Webhooks that Stable can reach over HTTPS.
- Keep in mind that requesting a Shipment is an irreversible action that will bill your account for the shipping cost. If you'd like to test, you can use the Developer Sandbox.
Step 1: Find the Mail Items to ship
Use the List all mail items API to find Mail Items that have not been shipped or shredded. Items in these states are ineligible to be shipped, since Stable does not have physical possession of the item:
const STABLE_API_URL = 'https://api.usestable.com/v1';
const listShippableMailItems = async () => {
const response = await fetch(
`${STABLE_API_URL}/mail-items?forward.status=none&shred.status=none`,
{ headers: { 'x-api-key': process.env.STABLE_API_KEY } },
);
if (!response.ok) throw new Error(`Failed to list mail items: ${response.status}`);
return response.json();
};import os
import requests
STABLE_API_URL = "https://api.usestable.com/v1"
def list_shippable_mail_items():
response = requests.get(
f"{STABLE_API_URL}/mail-items",
params={"forward.status": "none", "shred.status": "none"},
headers={"x-api-key": os.environ["STABLE_API_KEY"]},
)
response.raise_for_status()
return response.json()Here is a minimal subset of the response (returns Mail Items):
{
"edges": [
{
"node": {
"id": "b67416b1-8b44-40d8-9842-6c457acc4bf8",
"from": "John Doe",
"clearAt": "2026-12-01T00:00:00Z",
"forwardDetails": null,
...
}
}
],
...
}Note the following:
- See Pagination and Filtering to understand how to page through results.
clearAtis the storage deadline. Mail Items past their storage deadline cannot be shipped. See Storage and Shredding.
Step 2: List the Shipping Methods
Send up to 100 Mail Item IDs and a destination address to the List available shipping methods API. The response lists the methods you can use for this combination of Mail Items and destination.
const destination = {
line1: '500 Main St',
city: 'Denver',
state: 'CO',
postalCode: '80202',
country: 'US',
};
const listShippingMethods = async (mailItemIds) => {
const response = await fetch(`${STABLE_API_URL}/shipping-methods`, {
method: 'POST',
headers: {
'x-api-key': process.env.STABLE_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ mailItemIds, address: destination }),
});
if (!response.ok) throw new Error(`Failed to list shipping methods: ${response.status}`);
return response.json();
};destination = {
"line1": "500 Main St",
"city": "Denver",
"state": "CO",
"postalCode": "80202",
"country": "US",
}
def list_shipping_methods(mail_item_ids):
response = requests.post(
f"{STABLE_API_URL}/shipping-methods",
json={"mailItemIds": mail_item_ids, "address": destination},
headers={"x-api-key": os.environ["STABLE_API_KEY"]},
)
response.raise_for_status()
return response.json()Here is a minimal subset of the response:
{
"shipments": [
{
"mailItemIds": ["b67416b1-8b44-40d8-9842-6c457acc4bf8"],
"shippingMethods": [
{
"serviceCode": "usps_first_class_mail",
"label": "USPS First Class Mail",
"carrierCode": "usps"
},
...
]
}
]
}Note the following:
- If Mail Items passed to this endpoint are not physically in the same place, the response may return multiple
shipments. You will need to select a shipping method for each group of Mail Item IDs. - If
shippingMethodsis empty, Stable cannot ship to that address. We support domestic and international shipments, but certain regional restrictions may apply. - Cost estimates are not currently available due to differing Mail Item weights and other factors.
- Note that certain services like USPS First Class Mail do not include tracking. See our recommendations for Shipping Methods.
Step 3: Request the Shipment
Choose a serviceCode from Step 2 and send it to the Request a shipment API, along with the same Mail Item IDs and address. If your Shipments are predictable, you can optionally skip Step 2 and reuse the same serviceCode.
const requestShipment = async (mailItemIds, serviceCode) => {
const response = await fetch(`${STABLE_API_URL}/shipments`, {
method: 'POST',
headers: {
'x-api-key': process.env.STABLE_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
mailItemIds,
serviceCode,
address: destination,
recipient: 'Jane Doe',
phone: '+1 303 555 0100',
}),
});
if (!response.ok) {
const { message } = await response.json();
throw new Error(`Failed to request shipment: ${response.status} ${message}`);
}
return response.json();
};def request_shipment(mail_item_ids, service_code):
response = requests.post(
f"{STABLE_API_URL}/shipments",
json={
"mailItemIds": mail_item_ids,
"serviceCode": service_code,
"address": destination,
"recipient": "Jane Doe",
"phone": "+1 303 555 0100",
},
headers={"x-api-key": os.environ["STABLE_API_KEY"]},
)
if not response.ok:
raise Exception(f"Failed to request shipment: {response.status_code} {response.json()['message']}")
return response.json()You'll receive this response:
{
"shipments": [
{
"id": "9b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"mailItemIds": ["b67416b1-8b44-40d8-9842-6c457acc4bf8"],
"serviceCode": "usps_first_class_mail",
"status": "processing",
"createdAt": "2026-10-29T14:22:00.000Z"
}
]
}Note the following:
- You can ship up to 100 Mail Items in one request.
- If any Mail Item is ineligible for shipping (for example, if a Mail Item is shredded), the API will return HTTP 409 and will not ship any items.
Step 4: Build your webhook handler
When the Shipment is completed, Stable sends mailItem.forward.completed. At that point, the tracking number and cost on the Mail Item are final.
Here is a minimal subset of the event you'll receive:
{
"eventType": "mailItem.forward.completed",
"data": {
"id": "b67416b1-8b44-40d8-9842-6c457acc4bf8",
...
"forwardDetails": {
"id": "9b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"status": "completed",
"trackingNumber": "9400100000000000000000",
"cost": 1250
}
}
}Build the webhook handler in the language or framework of your choice. Your webhook must:
- Be accessible publicly over HTTPS.
- Accept
application/jsoncontent types. - Implement signature verification. In this example, we use the Svix libraries, but you can also implement webhook signature verification manually.
For a quick primer on verifying webhook signatures, use the following:
Your handler needs to do the following:
- Receive the event as a JSON payload.
- Verify it using the raw body and its headers.
- Filter for the event you're catching (here, we use mailItem.forward.completed).
- Handle your business logic to save the tracking details.
Here is an example handler that implements these steps:
import express from 'express';
import { Webhook } from 'svix';
const app = express();
const webhook = new Webhook(process.env.STABLE_WEBHOOK_SECRET);
app.post('/webhooks/stable', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = webhook.verify(req.body, req.headers);
} catch {
return res.status(400).send();
}
if (event.eventType === 'mailItem.forward.completed') {
await saveShipment(event.data.forwardDetails);
}
res.status(200).send();
});
app.listen(3000);from flask import Flask, request
from svix.webhooks import Webhook, WebhookVerificationError
app = Flask(__name__)
webhook = Webhook(os.environ["STABLE_WEBHOOK_SECRET"])
@app.post("/webhooks/stable")
def handle_stable_webhook():
try:
event = webhook.verify(request.get_data(), request.headers)
except WebhookVerificationError:
return "", 400
if event["eventType"] == "mailItem.forward.completed":
save_shipment(event["data"]["forwardDetails"])
return "", 200Note the following:
- The raw body is passed to the verification library rather than the parsed JSON.
- Stable also sends
mailItem.forward.processingwhen a Shipment is requested. Events can arrive out of order, so do not let aprocessingevent overwrite acompletedshipment.
Step 5: Save the tracking details
Implement saveShipment to send the tracking details to your system.
const saveShipment = async ({ id, trackingNumber, cost }) => {
const response = await fetch(`https://api.example.com/shipments/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trackingNumber, cost }),
});
if (!response.ok) throw new Error(`Failed to save shipment: ${response.status}`);
};def save_shipment(forward_details):
response = requests.put(
f"https://api.example.com/shipments/{forward_details['id']}",
json={
"trackingNumber": forward_details["trackingNumber"],
"cost": forward_details["cost"],
},
)
response.raise_for_status()Note the following:
- Stable sends one event for each Mail Item in a Shipment. This example saves cost and tracking details by shipment
id, so a shipment containing three Mail Items is only saved once and itscostis not triple-counted. costis in cents.trackingNumberisnullfor shipping services without tracking.- If the request to your system fails, the handler returns an error status and Stable retries the event. Only return HTTP 200 when you have saved the Shipment.
Step 6: Register and test your webhook
Deploy your handler, then register it with Stable:
- In the Stable dashboard, go to Developer > Webhooks.
- Add your handler's URL as a new endpoint.
- Select
mailItem.forward.completed. - Copy the endpoint secret and set it as
STABLE_WEBHOOK_SECRETin your handler's environment.
To test your webhook:
- Select your endpoint in the Stable dashboard, then select the Testing tab.
- Select
mailItem.forward.completed, then send the example event. - Check that your handler verifies the event and that the example shipment appears in your system.
Recap
You've built an integration that ships mail on demand and saves the tracking details in your system.
Keep these things in mind as you complete your integration:
- List shipping methods right before you request a shipment. The
serviceCodemust be available for every shipment in the request. - Stable sends one event per Mail Item. Use
forwardDetails.idto deduplicate events and avoid double-countingcost.
See Shipping, Mail Item Lifecycle, and Webhooks to keep learning about the concepts explored in this recipe.
Updated about 2 hours ago
