Security
Joy Subscription delivers outbound webhooks (Joy server → your endpoint). Because anyone who learns your receiver URL could attempt to POST forged events to it, every delivery is signed. Your handler must verify the signature before trusting the payload.
This page covers how the X-Signature header is built, how to verify it in Node.js, Python, and PHP, and the operational rules (timing-safe comparison, HTTPS, secret storage) that keep the channel secure.
How signing works
When Joy delivers a webhook, it signs the JSON body with your external app's apiSecret (the signing secret configured for your integration) and sends the result in the X-Signature header.
The signature is a base64-encoded HMAC-SHA256 of the serialized JSON body:
X-Signature = base64( HMAC_SHA256( apiSecret, JSON.stringify(payload) ) )Where:
apiSecretis your shop's configured signing secret. Keep it server-side only.payloadis the entire JSON request body — including the Joy-added delivery fieldstimestampandidempotencyKey, not just the event fields.
To verify a delivery, re-serialize the parsed body (or use the exact received bytes), recompute the HMAC, and compare it against X-Signature using a timing-safe comparison.
The relevant headers on every delivery:
| 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 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. |
Verifying the signature
The examples below recompute the HMAC over the JSON body and compare it to X-Signature with a constant-time (timing-safe) equality check. A naive == / === comparison can leak the secret through timing side channels, so always use the platform's timing-safe primitive.
Node.js
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
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
<?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 ?? '');
}Serialization caveat
The signature is computed over JSON.stringify(payload) as produced by the sender. If your platform re-serializes JSON differently — key order, whitespace, or unicode escaping — your recomputed HMAC will not match.
- Python: use
json.dumps(..., separators=(",", ":"))to drop the default spaces. - PHP: use
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODEso slashes and unicode are not escaped. - When possible, verify against the body exactly as the sender produced it (the raw received bytes) instead of re-serializing.
Operational rules
- Always verify
X-Signaturebefore trusting a payload. Reject mismatches with401. - Use timing-safe comparison —
crypto.timingSafeEqual(Node),hmac.compare_digest(Python),hash_equals(PHP). Never compare signatures with==/===. - HTTPS only. Expose your receiver over TLS so the body and headers cannot be read or tampered with in transit.
- Store the signing secret securely. Keep
apiSecretin an environment variable or secret manager, server-side only. Never ship it to a browser, mobile client, or public repository. - Rotate on exposure. If the secret may have leaked, rotate it from the integration configuration for the shop and update your handler.
- Deduplicate after verifying. Retries reuse the same
X-Idempotency-Key; process each event at most once. - Acknowledge fast, process async. Return
2xximmediately and offload the work; slow handlers risk timeouts and unnecessary retries.