Best Practices
This page covers operational best practices for receiving Joy Subscription outbound webhooks (Joy server → your endpoint) reliably. It is built strictly from the documented delivery model: HTTPS-only receivers, fast 2xx acknowledgement, idempotency, asynchronous processing, monitoring, and Joy's retry behavior.
Joy delivers events such as subscription contract lifecycle changes, billing attempts, subscription-related orders, and trial cron events. Each delivery is signed and carries an idempotency key so you can process each event exactly once.
Use HTTPS only
Expose your webhook receiver over TLS. Joy delivers payloads via HTTP POST to the address configured for your external app on the shop. Plain HTTP endpoints leak signed payloads (which include customer and subscription identifiers) in transit.
- Serve the receiver behind a valid TLS certificate.
- Reject or do not register non-
https://receiver URLs. - Always verify the
X-Signatureheader before trusting the body (HMAC-SHA256 over the JSON payload, keyed with yourapiSecret).
Acknowledge fast — return 2xx in under 5 seconds
Respond with a 2xx status as quickly as possible, then do the real work asynchronously. Slow handlers risk timeouts and trigger unnecessary retries.
A safe handler does only three things synchronously:
- Verify the signature.
- Deduplicate on the idempotency key.
- Enqueue the event for background processing and return
2xx.
// Express — acknowledge fast, process async
const crypto = require('crypto');
app.post('/webhooks/joy', express.json(), (req, res) => {
const rawBody = JSON.stringify(req.body); // Joy signs JSON.stringify(payload)
const expected = crypto
.createHmac('sha256', process.env.JOY_API_SECRET)
.update(rawBody)
.digest('base64');
const sig = Buffer.from(req.get('X-Signature') || '');
const exp = Buffer.from(expected);
if (exp.length !== sig.length || !crypto.timingSafeEqual(exp, sig)) {
return res.status(401).end(); // non-retryable: bad signature
}
const key = req.get('X-Idempotency-Key');
// 1) dedupe on key, 2) enqueue, 3) ack — all without blocking
enqueueForProcessing(key, req.body);
return res.status(200).end(); // ack within a few seconds
});Guideline: keep the synchronous path well under 5 seconds. Offload anything slower (DB writes, downstream API calls, emails) to a queue or worker.
Acknowledge with the right status code
Joy decides whether to retry based on the HTTP status you return. Return a 2xx to acknowledge successful receipt. Return a retryable status only when you genuinely want Joy to deliver again.
| Response from your endpoint | Joy's behavior |
|---|---|
2xx | Delivery acknowledged. No retry. |
>= 500 | Retryable. Joy retries (up to 2 retries, 3 attempts total). |
429 | Retryable. Joy retries (up to 2 retries, 3 attempts total). |
408 | Retryable. Joy retries (up to 2 retries, 3 attempts total). |
Other 4xx | Non-retryable. Delivery is dropped after logging. |
Use 5xx / 429 / 408 to ask Joy to retry on transient failures. Return a non-retryable 4xx (for example 401 on signature mismatch) only for permanently bad requests, so you do not waste retry attempts on something that will never succeed.
Deduplicate with X-Idempotency-Key
Every delivery carries an idempotency key, available both as the X-Idempotency-Key header and as the idempotencyKey field in the body. Retries reuse the same key, so you can detect and drop duplicates.
The default key form is:
<topic>-<uniqueId>-<shopDomain>For example:
subscription_contracts/cancel-abc123-example.myshopify.comRecommended pattern:
- On receipt, look up the key in a store (database row, Redis
SET NX, etc.). - If it already exists, treat the event as already handled and return
2xxwithout reprocessing. - If it is new, record it (ideally atomically with the processing transaction) and process the event.
// Pseudocode — idempotent intake
async function handle(key, payload) {
const fresh = await store.setIfAbsent(key, { receivedAt: Date.now() });
if (!fresh) return; // duplicate retry — already handled
await processEvent(payload);
}The HMAC signature is computed over the entire JSON body, including timestamp and idempotencyKey. Verify against the bytes the sender produced (or a serialization that matches JSON.stringify(payload)) before reading the key.
Process asynchronously and make handlers idempotent end-to-end
The idempotency key prevents reprocessing of redelivered events, but you should still make the actual work idempotent — network conditions and your own retries can cause repeats beyond Joy's control.
- Acknowledge first, then push the event to a queue/worker for the heavy lifting.
- Design downstream operations to be safe to run more than once (upserts instead of blind inserts, conditional state transitions, etc.).
- Do not assume every field is always present. For
orders/*topics,subscriptionContractIdandcycleIndexmay benullwhen the order is subscription-related via a selling-plan line item but carries no Joy subscription note to parse. Guard against missing values.
Filter by topic
The Shopify-native topic name is delivered in the X-Shopify-Topic header (for example subscription_contracts/cancel). Branch on it and ignore topics you do not handle, rather than attempting to process every payload shape.
| Header | Description |
|---|---|
Content-Type | application/json. |
X-API-Key | Your configured apiKey (identifies the integration). |
X-Signature | Base64 HMAC-SHA256 of the JSON body, keyed with your apiSecret. |
X-Shopify-Topic | The topic name, e.g. subscription_contracts/cancel. |
X-Shopify-Domain | The shop domain, e.g. example.myshopify.com. |
X-Idempotency-Key | Unique key for this event; identical across retries. |
X-Timestamp | ISO 8601 timestamp of when the payload was built. |
Understand the retry behavior
On a retryable failure, Joy makes up to 2 retries (3 attempts total) with exponential backoff. The base retry delay is 3000 ms, doubling each attempt:
retryDelay * 2^(attempt-1)
attempt 1 → ~3s
attempt 2 → ~6sRetryable conditions are HTTP status >= 500, 429, or 408. Any other 4xx response is treated as non-retryable and the delivery is dropped after logging.
Implications for your integration:
- A persistently failing endpoint will stop receiving an event after the retries are exhausted — you cannot rely on indefinite redelivery. Monitor failures (see below) so you can backfill or reconcile.
- Because retries reuse the same idempotency key, dedupe logic keeps repeated attempts from producing duplicate side effects.
- If you need Joy to retry, make sure your error path returns
>= 500,429, or408— not a generic400.
Monitor your receiver
Treat the webhook endpoint as a production service and instrument it.
- Acknowledgement latency — track time-to-
2xx; alert if it approaches the few-second budget that keeps you clear of timeouts and retries. - Status code mix — watch for spikes in
5xx/429/408(which cause retries) and4xx(which drop deliveries). - Signature failures — a rise in
401mismatches can indicate a rotated/incorrectapiSecretor a serialization mismatch in your verification. - Duplicate rate — count how often the idempotency key was already seen; a high rate suggests your handler is too slow or returning the wrong status.
- Dead-letter / backfill — log dropped non-retryable deliveries and exhausted retries so you can reconcile missed events.
Checklist
- Receiver served over HTTPS/TLS only.
-
X-Signatureverified with timing-safe comparison before trusting the body. - Returns
2xxwithin a few seconds; heavy work offloaded to a worker. - Deduplicates on
X-Idempotency-Key; handlers idempotent end-to-end. - Returns
>= 500/429/408only when a retry is wanted; non-retryable4xxfor permanently bad requests. - Branches on
X-Shopify-Topicand ignores unhandled topics. - Tolerates
nullsubscriptionContractId/cycleIndexonorders/*payloads. - Monitors latency, status mix, signature failures, and dropped deliveries.