Gareth Wilson Gareth Wilson

Guide to Shippo Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationShippo API Portal (Settings > API > Webhooks) or the Webhooks API (beta)
Subscription modelOne event type per webhook, or all
ModesTest or live, per webhook
URL limitUnder 200 characters
Envelope{event, test, data}, no event ID
Version headerShippo-API-Version
Signature headerShippo-Auth-Signature: t=<timestamp>,v1=<signature>, only once HMAC is set up
SchemeHMAC-SHA256 over <timestamp>.<raw body>, hex digest
Other security optionsIP allowlist, ?token= in the URL
Timestamp toleranceNone documented
Response windowThree seconds
RetriesTwice on a timeout, 408, 429 or 5xx. Never on other 4xx
Automations eventsDelivered once, never retried
TestingThe 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.

EventFires whendata contains
track_updatedA tracking status changesThe Tracking object
transaction_createdA transaction (label purchase) is createdThe Transaction object
transaction_updatedA transaction is updatedThe Transaction object
batch_createdA batch has been createdA short message string
batch_purchasedA batch purchase completesA short message string
automations_label_resolvedAn Automations label request reaches purchased, dry_run_completed, no_match or failedThe same result as GET /automations/labels/{request_id}

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_label_resolved 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_label_resolved without the event, test and data wrapper, so status, dry_run and request_id sit at the top level.
  • On the same versions, track_updated carries the Transaction object if the label was created on Shippo.
  • Shippo's two documented transaction_created examples disagree on data.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:

  1. Go to Settings > API and find the Webhooks section.
  2. Click Add webhook.
  3. Choose the Event Type, the Mode (test or live) and enter the URL.
  4. If you are using a URL token, add it to the end of the URL as ?token=<value>.
  5. 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_label_resolved. 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-Signature once 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 4xx for a temporary problem such as a database being unavailable. Use 503.
  • 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_label_resolved 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_id} for requests that have not resolved within the time you expect.
  • 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_id for Automations, the transaction object_id and status for 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_label_resolved and change what track_updated carries, and the documented transaction_created examples disagree on data.rate.

Workarounds:

  • Log Shippo-API-Version on every delivery.
  • Handle data.rate as 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.