Troubleshooting
This page covers the most common problems when receiving Joy Subscription outbound webhooks (Joy server → your endpoint): deliveries that never arrive, signature (HMAC) mismatches, and retry/timeout behavior. Each section lists what to check and how to debug it.
Webhook not arriving
If your endpoint is not receiving any deliveries, work through the checklist below in order.
1. Signing secret / credentials not configured
Webhook delivery is configured per shop on the integration / external-app configuration. Each external app is configured with three values:
| Field | Purpose |
|---|---|
address | Your receiver URL (where Joy POSTs the payload). |
apiKey | Sent back to you in the X-API-Key header to identify the integration. |
apiSecret | Used to sign each payload (HMAC-SHA256). See HMAC mismatch. |
If the signing secret (apiSecret) has not been generated, or the credentials
are incomplete, deliveries for that integration are skipped. Confirm all three
values are set before debugging anything downstream.
2. Endpoint is not HTTPS or does not return 2xx
Joy expects your receiver to:
- Be reachable over HTTPS (TLS). Plain HTTP endpoints are not supported.
- Respond with a
2xxstatus to acknowledge receipt.
Any non-2xx response is treated as a failed delivery. Depending on the status
code, the delivery is either retried or dropped — see
Retries and timeouts.
A common mistake is doing heavy work before responding. Acknowledge with
2xxfirst, then process asynchronously, so the connection does not time out.
3. Topic not selected / not subscription-related
Confirm the event you expect actually maps to a forwarded topic:
- The topic must be one of the supported topics (
subscription_contracts/*,subscription_billing_attempts/*,orders/*,trial/ending,trial/ended). orders/*topics only fire for subscription-related orders. An order is considered subscription-related when it carries a Joy subscription note likeSubscription #123 cycle 4, or has a selling-plan line item. Non-subscription orders are never forwarded.trial/endingandtrial/endedare custom topics emitted by Joy's trial cron — they do not have a Shopify-native equivalent and only fire when the cron runs.
Inspect the X-Shopify-Topic header on any deliveries you do receive to confirm
which topics are reaching you, and filter on it in your handler.
HMAC signature mismatch
If deliveries arrive but your signature check fails, the cause is almost always a serialization mismatch when recomputing the HMAC.
The X-Signature header is computed as:
X-Signature = base64( HMAC_SHA256( apiSecret, JSON.stringify(payload) ) )where payload is the entire JSON request body, including the Joy-added
timestamp and idempotencyKey fields.
Re-serialize the JSON to match the sender
Joy signs JSON.stringify(payload) exactly as produced by the sender. If your
platform parses the body and then re-serializes it with different key order,
whitespace, or unicode escaping, the recomputed digest will not match.
To avoid mismatches:
- When possible, verify against the raw body bytes as the sender produced them, before any framework re-parses them.
- If you only have the parsed object, re-serialize using a serialization that
matches the sender:
- Node.js —
JSON.stringify(req.body). - Python —
json.dumps(body_obj, separators=(",", ":")). - PHP —
json_encode($bodyObj, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE).
- Node.js —
- Always use a timing-safe comparison (
crypto.timingSafeEqual,hmac.compare_digest,hash_equals).
Node.js verification example
const crypto = require('crypto');
function verifyJoySignature(rawBody, signatureHeader, apiSecret) {
// rawBody: the exact JSON string received (or JSON.stringify of the parsed body).
const expected = crypto
.createHmac('sha256', apiSecret)
.update(rawBody)
.digest('base64');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express example
app.post('/webhooks/joy', express.json(), (req, res) => {
const body = JSON.stringify(req.body); // Joy signs JSON.stringify(payload)
if (!verifyJoySignature(body, req.get('X-Signature'), process.env.JOY_API_SECRET)) {
return res.status(401).end();
}
// dedupe on req.get('X-Idempotency-Key'), then ack fast and process async
res.status(200).end();
});Python verification example
import base64, hashlib, hmac, json
def verify_joy_signature(body_obj, signature_header, api_secret):
payload = json.dumps(body_obj, separators=(",", ":")) # match the sender's serialization
digest = hmac.new(api_secret.encode(), payload.encode(), hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(expected, signature_header or "")PHP verification example
<?php
function verify_joy_signature($bodyObj, $signatureHeader, $apiSecret) {
$payload = json_encode($bodyObj, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$expected = base64_encode(hash_hmac('sha256', $payload, $apiSecret, true));
return hash_equals($expected, $signatureHeader ?? '');
}If verification still fails after matching serialization, confirm you are using
the correct apiSecret for the shop sending the delivery (check X-API-Key and
X-Shopify-Domain to identify the integration).
Retries and timeouts
Joy retries deliveries on retryable failures only:
| Condition | Behavior |
|---|---|
HTTP status >= 500, 429, or 408 | Retryable — Joy retries. |
| Other 4xx responses | Non-retryable — delivery is dropped after logging. |
Retry policy:
- Up to 2 retries after the initial attempt (3 attempts total).
- Exponential backoff with a base delay of
3000 ms, doubling each attempt (retryDelay * 2^(attempt-1)), i.e. approximately 3s and then 6s.
Avoiding timeouts
Slow handlers risk timing out, which produces unnecessary retries. Acknowledge
with 2xx as fast as possible and offload the real work to a queue or worker.
Controlling retries with your status code
Return the status code that matches what you want Joy to do:
- Return
5xx,429, or408to ask Joy to retry (e.g. your downstream is temporarily unavailable). - Return a non-retryable 4xx (such as
401on a signature mismatch) only for permanently bad requests you never want retried.
Deduplicate retries with the idempotency key
Every delivery carries an idempotency key in both the X-Idempotency-Key header
and the idempotencyKey body field. Retries reuse the same key (default form:
<topic>-<uniqueId>-<shopDomain>). Deduplicate on it so a retried delivery is
processed at most once, and keep your handler idempotent end-to-end since network
conditions can still cause repeats.
Debugging checklist
Use the request headers on any received delivery to narrow down issues:
| Header | Use during debugging |
|---|---|
X-Shopify-Topic | Confirm which topic was delivered (e.g. subscription_contracts/cancel). |
X-Shopify-Domain | Identify which shop sent the delivery. |
X-API-Key | Match the delivery to the correct integration / apiSecret. |
X-Signature | The value to verify against your recomputed HMAC. |
X-Idempotency-Key | Deduplicate retries; correlate retried attempts. |
X-Timestamp | ISO 8601 time the payload was built (mirrors the body timestamp). |
Suggested debugging steps:
- Receiving anything? Log every inbound request (before verification) and check whether deliveries reach your endpoint at all. If not, revisit Webhook not arriving.
- Right topic? Inspect
X-Shopify-Topicand the payload fields against the topic you expect. Rememberorders/*only fires for subscription-related orders. - Signature passing? Log your recomputed signature alongside
X-Signature, and log the exact bytes you signed. A difference points to a serialization mismatch — see HMAC signature mismatch. - Returning 2xx fast? Measure handler latency. If you see repeated
deliveries with the same
X-Idempotency-Key, you are likely timing out or returning a retryable status; acknowledge first, then process async. - Tolerating nulls? For
orders/*payloads,subscriptionContractIdandcycleIndexmay benull. Do not assume their presence.