← Back to Blog
Developers

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:

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

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.

← Back to Blog