Article · Developers
How does a subscription box brand receive subscription webhooks safely?
A webhook is a message our app sends to your server when something happens, such as a subscriber pausing. To receive them safely, do three things: check the signature (an HMAC SHA256 of the timestamp, a full stop and the raw body, keyed with your endpoint's secret), ignore a repeated X-SSS-Delivery-Id (delivery is at least once, so the same event can arrive twice), and answer with any 2xx quickly and do the work afterwards, because a failed delivery is retried 7 more times over about 21 hours and then marked dead.
Who this is for: For developers at subscription brands who receive subscription events on their own server and need to handle them safely.
Set up the endpoint
- Open Settings > Integrations > API and find the Event notifications card. If the page says to contact support, message us in the app's live chat and we will turn the API on.
- Enter your URL, tick the events you want and click Add endpoint.
- Copy the signing secret now. It is shown once. It starts with sss_whsec_ and you use the whole string, prefix included.
- Click Send test event and check that your server receives it. A test carries "test": true and an id starting evt_test_.
For a box brand, useful events include subscription.paused, subscription.order_skipped, subscription.billing_retry_scheduled, subscription.failed and subscription.cancelled. Fetch the live list from GET /webhook_topics. There is no topic for a single failed payment attempt: it shows up as billing_retry_scheduled and, if the retries run out, failed.
What arrives
Each delivery is a POST with a JSON body and these headers: X-SSS-Topic, X-SSS-Shop-Domain, X-SSS-Delivery-Id, X-SSS-Event-Id, X-SSS-Timestamp (Unix seconds when we signed it) and X-SSS-Hmac-SHA256 (the signature). The body holds the subscription exactly as the API returns it, under data.subscription.
Two fields are easy to confuse. occurred_at is when the event happened and state_as_of is when we read the subscription. A pause that was resumed a minute later is still delivered as a pause, with a subscription that reads active. Use the event object for what changed and data.subscription for what is true now.
Step 1: verify the signature
Anyone who knows your URL can post to it, so prove each message came from us before you act on it. Three rules: use the key exactly as shown (with sss_whsec_), use the raw body exactly as received (re-encoding parsed JSON changes the bytes and the check fails), and reject any timestamp more than five minutes from your clock, which stops someone replaying a captured message. Here is the check in Node.js, one line at a time:
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.SUPR_WEBHOOK_SECRET; // sss_whsec_...
const app = express();
app.post("/hooks/supr", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("x-sss-timestamp");
const signature = req.get("x-sss-hmac-sha256") ?? "";
const raw = req.body.toString("utf8");
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);
const expected = crypto.createHmac("sha256", SECRET).update(`${timestamp}.${raw}`).digest("base64");
const a = Buffer.from(expected); const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
res.sendStatus(200); // answer first, work after
const event = JSON.parse(raw);
});
express.raw keeps the raw bytes. If your framework parses JSON before your code runs, the signature will not match. To debug a mismatch, rebuild the signature by hand with the whole secret, the timestamp, a full stop and the raw body.
Step 2: ignore a repeated delivery id
Delivery is at least once. That means we would rather send a message twice than lose it, so the same event can arrive twice, for example when your server answered slowly and we tried again. If your code charges, emails or ships on each message, a repeat does it twice.
The fix is small: store each X-SSS-Delivery-Id you have handled, and if you see one again, answer 200 and do nothing. Keep the ids in your database, not in memory, so a restart does not forget them. Send yourself a test event twice to prove it works.
The docs do not promise the order messages arrive in, so use occurred_at to decide which change is newer.
Step 3: know the retry schedule
That is about eight attempts over roughly 21 hours. If an endpoint fails 20 times in a row, we switch it off so a retired URL is not retried forever. The endpoint then shows disabled_at and the last error. Fix your server, then click Turn on in the app or send PATCH with {"enabled": true}. Turning it back on clears the failure count.
| Attempt | Wait before it |
|---|---|
| Retry 1 | 10 seconds |
| Retry 2 | 1 minute |
| Retry 3 | 5 minutes |
| Retry 4 | 30 minutes |
| Retry 5 | 2 hours |
| Retry 6 | 6 hours |
| Retry 7 | 12 hours, then dead |
What the schedule means for your server
- Answer first, work after. Return 200 as soon as the signature checks out and the delivery id is stored, then do the slow work (helpdesk ticket, stock change) in the background. A slow answer is a failure and causes a repeat.
- A short outage is fine. A deploy that takes a few minutes is covered by the first retries. An outage of more than a day loses events marked dead.
- Check the endpoint's health. GET /webhook_endpoints/:id/deliveries lists the 50 most recent deliveries with status pending, delivering, delivered or dead, the attempts and the last status code.
- Fill gaps from the API. For anything you missed, list the subscriptions and read their current state. See Pause and resume subscriptions from your own system for the calls.
- Rotate the secret when it leaks. New secret on the same page (or POST /webhook_endpoints/:id/rotate_secret) changes only that endpoint, and the old secret stops working at once, so update your server first.
Questions
Do I need a webhook to know when a subscriber skips a box?
No, but polling the API for it costs calls against your allowance. A webhook on subscription.order_skipped tells you as it happens and costs you no calls.
Why does my signature never match?
Usually one of three things: the secret was stripped of its sss_whsec_ prefix, the body was parsed and re-encoded before the check, or the timestamp and body were joined without the full stop.
Can I test without a real subscriber?
Yes. Click Send test event, or POST /webhook_endpoints/:id/test. At most five tests can wait on an endpoint, and a switched off endpoint refuses.
Is a retry sent with the same delivery id?
The docs tell you to ignore a repeated X-SSS-Delivery-Id, which is how to treat any repeat. Test it with your own endpoint and log the ids you receive.
Every setting is covered in the help center: Using the API.
Install on ShopifyRelated
- How do I sell a monthly or weekly subscription box on Shopify?
- How do I stop subscribers cancelling on Shopify?
- How can a subscription box brand pause, resume or skip a subscriber's box from its own helpdesk or ERP?
- What should a coffee roaster charge on repeat orders of a subscribed bundle?
- How do I run a monthly art supplies club on Shopify?
- How do I stop subscription box members cancelling on Shopify?
- What should I ask a Shopify subscription app before I install it?