Guide to Shippo Webhooks: Features and Best Practices
Shippo webhooks tell your application when shipping state changes: a label is bought, a tracking status moves from transit to delivered, a batch finishes purchasing, or a Shippo Automations label request is resolved. If you send delivery notifications, update order status, or trigger returns handling from carrier events, webhooks save you polling Shippo's tracking API for every shipment.
This guide covers how Shippo webhooks are set up, the six event types and their envelope, the three security options Shippo offers, the retry rules, and the practices that keep a Shippo receiver reliable in production.
What are Shippo webhooks?
A Shippo webhook is a URL registered for one event type (or for all of them) in either test or live mode. When an event of that type happens, Shippo sends a JSON POST to the URL with the event name, a test flag and the related object.
By default, a Shippo delivery carries no signature. Shippo offers three ways to secure webhooks, which you can combine: an IP allowlist, a secret token in the URL, and HMAC signing. HMAC has to be requested from Shippo and set up by its solutions team, which can take up to 10 business days.
Shippo waits three seconds for a 2xx and retries at most twice.
Shippo webhook features
| Feature | Details |
|---|---|
| Configuration | Shippo API Portal (Settings > API > Webhooks) or the Webhooks API (beta) |
| Subscription model | One event type per webhook, or all |
| Modes | Test or live, per webhook |
| URL limit | Under 200 characters |
| Envelope | {event, test, data}, no event ID |
| Version header | Shippo-API-Version |
| Signature header | Shippo-Auth-Signature: t=<timestamp>,v1=<signature>, only once HMAC is set up |
| Scheme | HMAC-SHA256 over <timestamp>.<raw body>, hex digest |
| Other security options | IP allowlist, ?token= in the URL |
| Timestamp tolerance | None documented |
| Response window | Three seconds |
| Retries | Twice on a timeout, 408, 429 or 5xx. Never on other 4xx |
| Automations events | Delivered once, never retried |
| Testing | The portal sends a sample payload |
Common events
Shippo documents six event types, plus the all wildcard. The event field in the body names the event.
| Event | Fires when | data contains |
|---|---|---|
track_updated | A tracking status changes | The Tracking object |
transaction_created | A transaction (label purchase) is created | The Transaction object |
transaction_updated | A transaction is updated | The Transaction object |
batch_created | A batch has been created | A short message string |
batch_purchased | A batch purchase completes | A short message string |
automations_ | An Automations label request reaches purchased, dry_run_completed, no_match or failed | The same result as GET /automations/labels/{request_ |
Shippo checks carriers for tracking updates and posts track_updated whenever the status changes. It says updates arrive within two hours of the carrier event on average, with variation by carrier. Tracking status values include PRE_TRANSIT, TRANSIT, DELIVERED, RETURNED, FAILURE and UNKNOWN.
Here is Shippo's example automations_ delivery:
{
"event": "automations_label_resolved",
"test": false,
"data": {
"request_id": "b3b8f6b0-1a2b-4c3d-9e4f-5a6b7c8d9e0f",
"status": "purchased",
"dry_run": false,
"test": false,
"shipment_id": "5e40ead7cffe4cc1ad45108696162e42",
"transaction_id": "9b2d4f6a8c0e4a1b3d5f7a9c1e3b5d7f",
"tracking_number": "9400111899223197428490",
"label_url": "https://shippo-delivery.s3.amazonaws.com/2f8b6d1e4a9c4f3b8e7d5a1c6b9e0f24.pdf",
"selected_rate": {
"rate_id": "c4e8a2f6d0b94c17a3e5f7b9d1c3e5a7",
"carrier": "USPS",
"service_level": "usps_priority",
"amount": "7.35"
},
"created_at": "2026-09-03T18:04:10Z"
}
}
The test field at the top is true for test-mode objects and test webhooks.
The body depends on your account's API version, which Shippo sends in Shippo-API-Version:
- Accounts on API version 2017-03-29 or older receive
automations_without thelabel_ resolved event,testanddatawrapper, sostatus,dry_runandrequest_idsit at the top level. - On the same versions,
track_updatedcarries the Transaction object if the label was created on Shippo. - Shippo's two documented
transaction_createdexamples disagree ondata.rate: one shows a rate object, the other a rate ID string.
Shippo's release notes for API version 2017-08-01 call the body field event_type and list a batch_updated value. Every current page uses event and batch_purchased. Build against the current names, and log unknown values rather than rejecting them.
Setting up Shippo webhooks
In the Shippo web app:
- Go to Settings > API and find the Webhooks section.
- Click Add webhook.
- Choose the Event Type, the Mode (test or live) and enter the URL.
- If you are using a URL token, add it to the end of the URL as
?token=<value>. - Save, then use the portal's test option to send a sample payload.
The Webhooks API (POST /webhooks) can do the same, but Shippo marks it as beta and its event enum does not include automations_. Subscribe to that one with the literal string or with all.
Buying a label through Shippo automatically creates a tracking webhook for it. For tracking numbers bought elsewhere, register them with POST /tracks.
Securing Shippo webhooks
Shippo gives you three options, and you can combine them.
IP allowlist. Shippo publishes five US-region addresses (52.4.41.98, 52.23.121.194, 52.44.110.80, 54.81.253.187, 54.81.255.221) and four EU-region addresses (34.248.247.69, 34.253.119.130, 52.214.174.64, 54.72.179.250). An allowlist stops working as soon as anything relays the request, because the source address changes.
URL token. Add ?token=<long random value> to the webhook URL. Shippo sends the same query parameter on every POST, and you compare it with your stored value in constant time.
HMAC. Email your account manager with the subject line HMAC Webhook Setup for <your name>, or use the Additional Details: field of Shippo's sales form. Shippo's solutions team sets up the secret with you. After that, each delivery carries Shippo-Auth-Signature: t=<unix seconds>,v1=<hex>, where v1 is an HMAC-SHA256 of the timestamp, a dot and the raw body.
The HMAC check:
const crypto = require('crypto');
const express = require('express');
function verifyShippoSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const parts = Object.fromEntries(
signatureHeader.split(',').map((part) => {
const index = part.indexOf('=');
return [part.slice(0, index).trim(), part.slice(index + 1).trim()];
})
);
if (!parts.t || !parts.v1) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.`)
.update(rawBody)
.digest('hex');
// Optional: Shippo documents no tolerance, but you can also reject old values of parts.t here.
const received = Buffer.from(parts.v1.toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/shippo', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyShippoSignature(req.body, req.get('shippo-auth-signature'), process.env.SHIPPO_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.event. Persist, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import os
def verify_shippo_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
parts = dict(
part.strip().split("=", 1) for part in signature_header.split(",") if "=" in part
)
timestamp, signature = parts.get("t"), parts.get("v1")
if not timestamp or not signature:
return False
signed = timestamp.encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_shippo_signature(raw_body, request.headers.get("shippo-auth-signature"),
# os.environ["SHIPPO_WEBHOOK_SECRET"])
Shippo's own verifier is a bash script that reads the body with payload=$(cat), which drops trailing newlines. Verify against the exact bytes you received instead.
To test your code, use the secret shippo-test-hmac-secret, the timestamp 1688493073 and this exact body:
{"event":"track_updated","test":true,"data":{"tracking_number":"9205590164917312751089","tracking_status":{"status":"DELIVERED"}}}
The header should be:
t=1688493073,v1=7c2fcfba50123f6624f91c84e6da7d75b62493754fea74f8ebefbc6f6f1a932f
In Hookdeck, a Shippo source verifies Shippo-Auth-Signature with the secret Shippo gave you, and does not enforce a timestamp tolerance. If you didn't set up HMAC with Shippo, leave verification off, because unsigned deliveries have no header to check. Hookdeck's Shippo verification does not check a ?token= parameter.
Make Shippo webhooks production-ready. Hookdeck Event Gateway answers Shippo inside its three-second window, verifies
Shippo-Auth-Signatureonce HMAC is set up, and queues every tracking and label event until your service has processed it.
Shippo webhook limitations and pain points
Unsigned by default
The Problem: Without extra setup, anyone who learns your webhook URL can post fake tracking updates.
Why It Happens: HMAC signing is opt-in and not self-serve. You have to ask for it, and Shippo says setup can take up to 10 business days. Until then, the only built-in protection is the IP allowlist or a token in the URL.
Workarounds:
- Add a long random
?token=to the URL from day one, and compare it in constant time. - Request HMAC early in your integration so it is ready before launch.
- Treat unsigned events as hints and confirm important changes through the API.
How Hookdeck Can Help: Once HMAC is in place, Hookdeck verifies every signature before forwarding. Before that, Hookdeck has no Shippo signature to check, but it can authenticate itself to your destination, so your service's own URL stays private and only accepts traffic from Hookdeck.
Two retries and three seconds
The Problem: A short outage or a slow handler loses tracking updates.
Why It Happens: Shippo waits three seconds for a 2xx. On a timeout, 408, 429 or 5xx it retries twice, at intervals it does not publish, and never retries other 4xx responses.
Workarounds:
- Acknowledge immediately and process in the background.
- Never return a
4xxfor a temporary problem such as a database being unavailable. Use503. - Reconcile tracking and transactions from the API after any incident.
How Hookdeck Can Help: Hookdeck accepts each delivery as it arrives and stores it, then retries against your service on a schedule you control, so Shippo's two retries are not your only chance.
Automations results are sent once
The Problem: An automations_ event that fails is gone.
Why It Happens: Shippo says it attempts delivery once and does not retry, and the event can arrive out of order or more than once. It tells you to poll the request for the authoritative outcome.
Workarounds:
- Poll
GET /automations/labels/{request_for requests that have not resolved within the time you expect.id} - Deduplicate on
data.request_id.
How Hookdeck Can Help: Hookdeck takes the one delivery Shippo makes and keeps it until your service has processed it, with retries and replay on your side.
No event ID
The Problem: Duplicate deliveries are hard to spot.
Why It Happens: The envelope is event, test and data, with no event ID or delivery ID header. Shippo also warns that registering the same tracking number twice produces duplicate notifications.
Workarounds:
- Build a key from the object:
data.request_idfor Automations, the transactionobject_idandstatusfor transactions, the tracking number and latest status for tracking. - Register each tracking number once.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries before they reach your service, based on the fields you choose.
Body shape depends on the account
The Problem: Code that works on one Shippo account breaks on another.
Why It Happens: The account's API version controls the webhook body. Old versions unwrap automations_ and change what track_updated carries, and the documented transaction_created examples disagree on data.rate.
Workarounds:
- Log
Shippo-API-Versionon every delivery. - Handle
data.rateas either an object or an ID string. - Upgrade old accounts to a current API version, remembering that upgrades only go forwards.
How Hookdeck Can Help: Hookdeck's transformations can normalise older body shapes into the current one before they reach your service.
Best practices
Verify against the raw body
The signature covers the exact bytes. Read the raw body with express.raw(), Buffer.from(await request.arrayBuffer()) in a Next.js route handler, or await request.body() in FastAPI.
Separate test and live
Register test-mode and live-mode webhooks to different URLs, and ignore events with "test": true in production.
Keep the URL short
The URL must be under 200 characters, including any ?token=. Use a short path so a long token still fits.
Respond with the right status code
Return 2xx once you have stored the event. Use 5xx for temporary failures, since Shippo does not retry other 4xx codes.
Acknowledge fast and process asynchronously
See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Shippo's HMAC signature, t=<timestamp>,v1=<hex> over <timestamp>.<raw body>, is only there after you ask for it. Plan for unsigned deliveries until then, and for a delivery policy that gives up quickly: three seconds, two retries, and a single attempt for Automations results.
Hookdeck Event Gateway sits in front of your receiver, verifies the signature once Shippo has set it up, and holds every tracking and label event until your service has processed it, so a slow deploy does not cost you a delivery update.
Get started with Hookdeck for free and handle Shippo webhooks reliably in minutes.