How to Receive Third-Party Webhooks in a ShopsWired Plugin
To receive a webhook in a ShopsWired plugin, declare a route script with "type": "route", "method": "POST" and "public": true, export a fetch(ctx) handler, and give the provider your store's address plus that route_path. The platform does not authenticate a public route for you — so the first thing your handler does is verify the provider's signature with crypto.createHmac and crypto.timingSafeEqual, using a secret the script never sees in plaintext.
The rest is what decides whether it holds up: signing over the right bytes, finding the record, surviving retries, and answering in time.
The running example
A store ships through a third-party warehouse. When the warehouse dispatches a parcel it POSTs a JSON event — { "type": "shipment.created", "order_ref": "…", "carrier": "UPS", "tracking_number": "1Z…" } — signed with HMAC-SHA256 over the raw body, hex-encoded, in an X-Signature header. We want the matching order marked shipped with its tracking number attached. Most senders follow some variation of this shape.
Why it has to be a public route
An ordinary plugin route lives on the storefront origin, and every state-changing request there — POST, PUT, PATCH, DELETE — is rejected with a 403 unless it carries the storefront's CSRF token. A warehouse server has no token, so it would bounce. "public": true lifts that requirement for one route:
{
"scripts": [
{
"path": "webhook.js",
"type": "route",
"method": "POST",
"route_path": "/webhooks/warehouse",
"public": true
}
],
"secrets": [
{ "key": "WAREHOUSE_WEBHOOK_SECRET", "help": "Signing secret from your warehouse dashboard." }
]
}Two consequences come with it. A public route is unauthenticated — anyone who knows the URL can call it, so authenticating the caller is your job. And it never sees a signed-in shopper: ctx.customer_id is always absent, even if the request happens to carry a storefront session. Shopper actions belong on normal routes; external callers on public ones, identifying the account from the payload.
The secrets array tells the store's Secrets panel which credential to ask for. public appears in the capability list a merchant reviews before installing, so declare it only where needed.
The handler
// webhook.js
module.exports.fetch = function (ctx) {
const req = ctx.request;
const raw = req.text(); // the exact bytes the provider signed
const sig = req.headers["X-Signature"] || "";
const expected = crypto
.createHmac("sha256", "{secret.WAREHOUSE_WEBHOOK_SECRET}")
.update(raw)
.digest("hex");
if (!sig || !crypto.timingSafeEqual(expected, sig)) {
return { status: 401, body: { error: "invalid signature" } };
}
let evt;
try { evt = JSON.parse(raw); } catch (e) {
return { status: 400, body: { error: "body is not JSON" } };
}
if (evt.type !== "shipment.created") {
return { status: 200, body: { ignored: evt.type } };
}
const found = sw.orders.list({ filters: { index: "meta#wh_ref#" + evt.order_ref }, limit: 1 });
const order = found.items[0];
if (!order) {
console.warn("warehouse event for unknown order_ref " + evt.order_ref);
return { status: 200, body: { ignored: "unknown order" } };
}
const trackings = order.trackings || [];
if (trackings.some((t) => t.number === evt.tracking_number)) {
return { status: 200, body: { duplicate: true } };
}
sw.orders.save({
id: order.id,
status: "shipped",
trackings: trackings.concat([{ carrier: evt.carrier, number: evt.tracking_number }])
});
return { status: 200, body: { ok: true } };
};Line by line, the decisions that matter:
- Sign over
req.text(), not a re-serialised object. The provider signed the bytes it sent; re-serialise and key order or whitespace drifts, and every check fails. - The secret stays write-only. A
{secret.KEY}placeholder in the key argument ofcreateHmacis expanded at the crypto boundary and never materialises in your script, so a signing secret does not need"readable": true. The flip side: only the key expands. A placeholder passed to.update()is hashed literally, with no error — if a provider puts a stored value in the signed message, that value has to be a readable secret you fetch withsw.secrets.getand concatenate yourself. timingSafeEqual, never===. It compares in constant time, so the check doesn't leak the expected signature through response timing, and it returnsfalsewhen the lengths differ.cryptois a top-level global, likefetch—sw.cryptois undefined.- A route answers with
body. A string inbodyis served as HTML, anything else as JSON. Dashboard widgets usejson/htmlinstead, and if you return one of those keys from a route the status still goes out with an empty body.
If your provider signs a timestamp along with the body (the t=…,v1=… style), sign the concatenation it specifies and reject events whose timestamp is too old. 'hex', 'base64' and 'base64url' digests are all supported.
Finding the record the event is about
The lookup above relies on a marker the plugin stamped when it first sent the order to the warehouse:
sw.orders.save({ id: order.id, meta: { _wh_ref: shipmentRef } });Plain .meta can't be filtered, but a key starting with _ derives an entry on the record's read-only index array, namespaced as meta#wh_ref#<value> — filterable by exact equality, on its own, with no sort. Keep the marker short: a value over 200 characters is kept in meta but left out of index, so the lookup would silently miss.
A theme can read order.meta in Liquid, so ids and flags only — never credentials.
Retries, and why the handler is idempotent
Webhook senders retry, so the same event can arrive after you already applied it. The handler therefore checks the state it's about to create: if the tracking number is already on the order, it acknowledges and stops. That's sturdier than remembering event ids, because it stays correct even if the merchant added the tracking by hand first.
It also matters here specifically: setting an order's status to "shipped" fires order.before_save / order.after_save and sends the customer the shipping email. Apply the event twice without a guard and the shopper hears about one parcel twice.
The status codes are a message to the provider about whether to try again. Answer 2xx for anything a retry can't fix — an event type you don't handle, an order that doesn't exist — and log it. Reserve 4xx for a request you reject (a bad signature) and 5xx for a failure where a retry might succeed.
Answer fast; push slow work to the background
A fetch route gets a short inline budget of about 30 seconds — and many providers time out well before that. If the event triggers real work — a call back to the provider's API, a catalog resync — verify inline, then hand the payload to a durable background task and answer immediately:
sw.task.bg((ctx) => {
const evt = ctx.args.evt;
// slow work here: up to 10 minutes, with every sw.* bridge available
}, { args: { evt } });
return { status: 202, body: { queued: true } };The closure captures no outer scope; pass data through opts.args and read it on ctx.args. The task is persisted before it runs. Know the trade you're making, though: once you've answered 2xx, a provider won't normally resend. A task that dies on its memory or time budget is picked back up by the platform, up to five attempts, but an error your closure throws is logged and finished with. Make it idempotent and watch the log viewer.
When this is the wrong tool
- Payment provider events. If you're building a payment gateway, don't hand-roll a public route for its callbacks. Gateway callbacks belong in the payment gateway hooks —
payment.webhook— which tie each event to the order its payment belongs to. - Providers that don't retry. A public route only runs when it's called. If a sender fires once and forgets, a single lost delivery is lost. Pair the route with a scheduled reconciliation script that asks the provider what changed — on Pro the minimum interval is 15 minutes.
- Starter stores. Starter runs a full store on ready-made themes and plugins; writing your own plugin starts on Pro.
The developer docs cover public routes, CORS for browser callers, streaming request bodies and the full sw.* surface used above. Pro is $99/mo — currently $1/month for the first 3 months on Starter or Pro — and every plan carries 0% platform transaction fees; you still pay your payment processor's standard rate. Full details on pricing.