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:

  1. Find the Mail Items to ship.
  2. List the available shipping methods for the Mail Items and selected destination.
  3. Request the Shipment.
  4. Build a webhook handler for completed Shipments.
  5. Save the tracking details to your system.
  6. 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:

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 shippingMethods is 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/json content 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:

  1. Receive the event as a JSON payload.
  2. Verify it using the raw body and its headers.
  3. Filter for the event you're catching (here, we use mailItem.forward.completed).
  4. 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 "", 200

Note the following:

  • The raw body is passed to the verification library rather than the parsed JSON.
  • Stable also sends mailItem.forward.processing when a Shipment is requested. Events can arrive out of order, so do not let a processing event overwrite a completed shipment.

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 its cost is not triple-counted.
  • cost is in cents.
  • trackingNumber is null for 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:

  1. In the Stable dashboard, go to Developer > Webhooks.
  2. Add your handler's URL as a new endpoint.
  3. Select mailItem.forward.completed.
  4. Copy the endpoint secret and set it as STABLE_WEBHOOK_SECRET in your handler's environment.

To test your webhook:

  1. Select your endpoint in the Stable dashboard, then select the Testing tab.
  2. Select mailItem.forward.completed, then send the example event.
  3. 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 serviceCode must be available for every shipment in the request.
  • Stable sends one event per Mail Item. Use forwardDetails.id to deduplicate events and avoid double-counting cost.

See Shipping, Mail Item Lifecycle, and Webhooks to keep learning about the concepts explored in this recipe.



Did this page help you?