ShipStation Webhooks: A Developer's Guide (With Production Gotchas)
How ShipStation webhooks actually work — subscribing over the API, the resource_url fetch-back pattern, ORDER_NOTIFY, rate limits, and the reconciliation patterns you need in production.
Jacob
Founder
I spent four years running ShipStation at real volume, and for most of it my orders flowed through webhooks I subscribed to over the API. This is the guide I wish I'd had on day one: how ShipStation's webhooks actually work, the exact calls to subscribe and unsubscribe, what the payload really contains (less than you'd hope), and the handful of production gotchas that will bite you the first time a customer imports a thousand orders at once. Everything here is checked against ShipStation's public API docs, and the gotchas are the ones we still design around in production today.
One thing to get straight up front: this covers the classic ShipStation API — the one hosted at ssapi.shipstation.com with the ORDER_NOTIFY events and the resource_url payload. If you're on a newer ShipStation surface, some details differ, and I'll flag where that matters.
The mental model: a webhook is a ping, not the data
The single most important thing to understand is that a ShipStation webhook does not hand you the order. It hands you a nudge that says "something changed, here's a URL where you can go read it." When an event fires, ShipStation POSTs a tiny JSON body to your endpoint containing two fields: a resource_url and a resource_type. That's essentially it.
So the flow is always two steps:
- Receive the POST. Acknowledge it fast (return 200).
- Make an authenticated GET to the
resource_urlto pull the actual orders that changed.
Design everything around that two-step shape and the rest falls into place.
The event types you can subscribe to
ShipStation's classic API exposes six webhook event types. These are the exact string values you pass when subscribing:
ORDER_NOTIFY— a new order (or a batch of newly imported orders) is available.ITEM_ORDER_NOTIFY— new order items are available.SHIP_NOTIFY— orders have shipped.ITEM_SHIP_NOTIFY— order items have shipped.FULFILLMENT_SHIPPED— an external fulfillment was marked shipped.FULFILLMENT_REJECTED— an external fulfillment was rejected.
For most integrations ORDER_NOTIFY and SHIP_NOTIFY are the two that matter. ORDER_NOTIFY is your "new work has arrived" trigger — it's what we hang order processing off of.
Subscribing over the API
You subscribe by POSTing to /webhooks/subscribe on ssapi.shipstation.com. The classic API uses HTTP Basic auth, so your API key is the username and your secret is the password. Here's the whole thing as a curl call:
curl -X POST https://ssapi.shipstation.com/webhooks/subscribe \
-u "$SHIPSTATION_API_KEY:$SHIPSTATION_API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://yourapp.com/hooks/shipstation/order",
"event": "ORDER_NOTIFY",
"store_id": null,
"friendly_name": "New order → yourapp"
}'
The four body parameters:
target_url(required) — where ShipStation POSTs the ping.event(required) — one of the six event strings above.store_id(optional) — scope the webhook to a single store. Passnulland it fires for every store on the account.friendly_name(optional) — a label you'll be glad you set when you're staring at a list of webhooks six months from now.
The payload, and the fetch-back pattern
When ORDER_NOTIFY fires, the body ShipStation POSTs to your target_url looks like this:
{
"resource_url": "https://ssapi.shipstation.com/orders?importBatch=abc-123-def",
"resource_type": "ORDER_NOTIFY"
}
resource_url has a 200-character limit and is the URL you GET to retrieve what changed. resource_type echoes back which event fired. Note the shape of that URL: it's a query, not a single-order link. One ORDER_NOTIFY can represent a whole batch of imported orders, and the resource_url is pre-scoped to exactly that batch.
When you GET the resource_url (with the same Basic auth), the response is structured identically to the List Orders endpoint — a paged orders array. For ship events it mirrors List Shipments. So your fetch-back code is just your normal list-orders parsing:
require "net/http"
require "json"
def fetch_changed_orders(resource_url)
uri = URI(resource_url)
req = Net::HTTP::Get.new(uri)
req.basic_auth(ENV["SHIPSTATION_API_KEY"], ENV["SHIPSTATION_API_SECRET"])
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req)
end
body = JSON.parse(res.body)
body.fetch("orders", []) # same shape as GET /orders
end
Because the payload is deliberately thin, you never trust the ping to carry business data — you always go back to the API for the source of truth. That's not a limitation to work around; it's the contract, and it's what keeps you from acting on a stale or spoofed body.
A minimal Rails receiver
Here's a receiver stripped to the essentials. Two rules drive its shape: acknowledge instantly, and do the real work off the request thread.
# config/routes.rb
post "/hooks/shipstation/:token/order", to: "shipstation_webhooks#order"
# app/controllers/shipstation_webhooks_controller.rb
class ShipstationWebhooksController < ApplicationController
skip_before_action :verify_authenticity_token
def order
account = Account.find_by!(webhook_token: params[:token])
resource_url = params[:resource_url]
# Hand off to a background job and return immediately — never
# fetch orders inline. Slow 200s look like failures to ShipStation.
ShipstationOrderSyncJob.perform_later(account.id, resource_url)
head :ok
end
end
Two things worth calling out. First, skip_before_action :verify_authenticity_token — this is an API endpoint, not a form, so Rails' CSRF check has to be off or every POST 422s. Second, the :token in the path. That's not decoration; it's your security model, and it's the subject of the next gotcha.
Production gotcha #1: there's no signature — so make the URL unguessable
Here's the honest security picture for the classic webhooks. The documented payload carries exactly two fields — resource_url and resource_type — and the subscribe call never asks you for a signing secret. In practice, these webhooks arrive with no signature header we've ever seen to verify them by. (ShipStation's newer platform does more here; if you're seeing a signature header, you're on a different surface than this post covers.)
So how do you keep a random person from POSTing fake pings at your endpoint? You make the URL itself the secret. Mint a long, random, per-account token and bake it into the target_url you register — that's the :token segment above. An attacker who doesn't know the token can't hit the right path, and even a valid-looking ping only ever causes you to re-fetch real data from ShipStation with your own credentials, so a spoofed body can't inject anything. Unguessable URL plus fetch-back is the whole defense, and it's genuinely enough for this API.
Production gotcha #2: 429s and pacing
The classic ShipStation API allows 40 requests per minute per API key/secret combination. Blow past it and you get HTTP 429 Too Many Requests. Every response carries three headers you should be reading:
X-Rate-Limit-Limit— the ceiling.X-Rate-Limit-Remaining— requests left in the current window.X-Rate-Limit-Reset— seconds until the window resets.
The naive integration ignores these until it starts getting 429s in production. The right move is to pace proactively: watch X-Rate-Limit-Remaining, and when a 429 does land, sleep for X-Rate-Limit-Reset seconds before retrying rather than hammering.
if res.code == "429"
wait = res["X-Rate-Limit-Reset"].to_i
sleep(wait.clamp(1, 60))
retry
end
Forty a minute sounds like plenty until you remember the fetch-back pattern means every webhook costs you at least one GET — and a big import fires a burst of them at once.
Production gotcha #3: batch imports fire bursts
ORDER_NOTIFY is not one-event-per-order. When a store imports a thousand orders, you can get a cluster of notifications in a very short window, each pointing at a resource_url you now need to page through. That burst is exactly where you hit the 40/min wall if you fetch naively. Queue the work, pull batches sequentially with your pacing in place, and let the background worker drain the queue at a rate the API tolerates. Never try to service an import burst inline on the webhook thread.
Production gotcha #4: webhooks get missed — reconcile
This is the one that separates toy integrations from production ones. Do not assume you will receive every webhook. Endpoints have downtime, deploys drop connections, networks hiccup — and the classic API doesn't publish a redelivery guarantee you can lean on, so build as though any given ping can simply never arrive.
The fix is a reconciliation sweep: on a schedule, query ShipStation for orders modified since your last successful sync (the List Orders endpoint supports a modifyDateStart filter) and process anything you didn't already see. Webhooks make you fast; the sweep makes you correct. We run both in production, and the sweep quietly catches the handful of orders the webhooks miss every week.
Production gotcha #5: idempotency via marker state
Between retries, overlapping bursts, and a reconciliation sweep re-touching orders the webhook already handled, the same order will pass through your processing more than once. If your processing isn't idempotent, you'll double-apply changes — duplicate tags, re-run actions, corrupt state.
The pattern that's held up for us is marker state: before you act on an order, check for a marker that says "already handled" (a tag, a stored flag, a processed-at timestamp), and set that marker as the last step. Re-processing then becomes a cheap no-op instead of a double-write. Make the check and the write part of the same unit of work so two concurrent runs can't both slip past the check.
Or skip the infrastructure entirely
Everything above — subscribing, the fetch-back, pacing against 40/min, burst queuing, a reconciliation sweep, idempotent marker state — is real infrastructure you have to build and keep running. I built it because four years ago there was no alternative. There is now.
ShipExtension's API automation runs this whole layer for you. It subscribes to the ShipStation events, handles the fetch-back and the rate-limit pacing, reconciles the orders webhooks miss, and processes each order exactly once — then applies your automation rules on top. If what you actually want is "when an order lands, do this to it," and not a webhooks-plumbing project, that's the shortcut. If you enjoy owning the plumbing, everything you need to build it yourself is above.
Frequently asked questions
How do I subscribe to a ShipStation webhook?
Make an authenticated POST to /webhooks/subscribe on ssapi.shipstation.com using HTTP Basic auth (API key as the username, secret as the password). The JSON body needs a target_url (where ShipStation sends the ping) and an event (one of ORDER_NOTIFY, ITEM_ORDER_NOTIFY, SHIP_NOTIFY, ITEM_SHIP_NOTIFY, FULFILLMENT_SHIPPED, or FULFILLMENT_REJECTED). You can optionally scope it to one store with store_id and label it with friendly_name. To see what you've registered, GET /webhooks; to remove one, DELETE /webhooks/{webhookId}.
Do ShipStation webhooks retry if my endpoint is down?
Don't design as if they do. The classic API doesn't publish a redelivery guarantee you can rely on, so treat any single webhook as potentially lost. The robust pattern is to acknowledge fast to reduce failures, and then run a scheduled reconciliation sweep that queries orders modified since your last sync and processes anything the webhooks didn't deliver. Webhooks give you speed; the sweep gives you correctness.
Are ShipStation webhooks signed?
For the classic API described here, the payload carries only a resource_url and a resource_type, and the subscription call never gives you a signing secret, so there's no signature header we've seen to verify against. The practical defense is to register an unguessable, per-account target URL so nobody can forge pings at you, and to always fetch the real data back from ShipStation with your own credentials rather than trusting anything in the POST body. A spoofed body then can't inject data — worst case it triggers a harmless re-fetch.
Should I use webhooks or polling with ShipStation?
Both, and for different reasons. Webhooks are how you react quickly, within seconds of an order landing, without pounding the API on a timer. Polling — a periodic query for orders modified since your last check — is how you stay correct when a webhook is missed. In production the two work together: webhooks for latency, a reconciliation sweep for completeness. If you can only build one first, build the poll, because a slow-but-complete integration beats a fast-but-lossy one.
The bottom line
ShipStation's classic webhooks are simple by design: a resource_url ping, an authenticated fetch-back, six event types. The complexity is all in production — pacing under 40 requests a minute, absorbing import bursts, reconciling missed events, and staying idempotent across retries. Build those four things and you have a solid integration. Or reach for ShipExtension's API automation and let it own the plumbing while you own the rules.
Share this article