Authentication
Joy Subscription delivers outbound webhooks (Joy server → your endpoint). Every delivery is signed so your integration can prove it came from Joy and was not tampered with in transit.
Authentication relies on a shared signing secret (apiSecret) configured for your integration, plus a set of request headers that carry the signature and delivery metadata.
Note: The signing secret (
apiSecret), yourapiKey, and your receiver URL (address) are configured on the external-app / integration settings for the shop. KeepapiSecretserver-side only.
How signing works
Joy computes an HMAC-SHA256 over the entire JSON request body using your apiSecret as the key, then encodes the result as Base64. The result is sent in the X-Signature header.
X-Signature = base64( HMAC_SHA256( apiSecret, JSON.stringify(payload) ) )Here payload is the full JSON body, including the delivery fields timestamp and idempotencyKey that Joy adds to every event. The signature covers all of it.
Note: Because the signature is computed over
JSON.stringify(payload)as produced by the sender, your verification must serialize the parsed body the same way (key order, whitespace, unicode escaping). When possible, verify against the body exactly as received.
Request headers
Each webhook delivery includes the following headers:
| Header | Description |
|---|---|
Content-Type | Always 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 Shopify-native topic name (e.g. subscription_contracts/cancel). |
X-Shopify-Domain | The shop domain (e.g. example.myshopify.com). |
X-Idempotency-Key | Stable key for this event; Joy sends the same key on the original delivery and every retry of it. Deduplicate on X-Idempotency-Key. See Deduplication below. |
X-Timestamp | ISO 8601 timestamp of when the payload was built. Mirrors the body field timestamp. |
Verifying the signature
Recompute the HMAC over the received body with your apiSecret, Base64-encode it, and compare it to X-Signature using a timing-safe comparison. Reject mismatches with 401.
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();
}
// Deduplicate on X-Idempotency-Key — Joy reuses the same key across retries.
// 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 ?? '');
}Best practices
- Always verify
X-Signaturebefore trusting a payload. Reject mismatches with401. - Use a timing-safe compare (
crypto.timingSafeEqual,hmac.compare_digest,hash_equals) — never a plain==string comparison. - Deduplicate on
X-Idempotency-Key. Joy sends the same key for the original delivery and every retry of that event. Treat deliveries as at-least-once and dedupe onX-Idempotency-Keyto prevent duplicate processing. See Deduplication. - Keep
apiSecretserver-side only. Never expose it in client code or logs. - HTTPS only. Expose your receiver over TLS.
- Match the sender's serialization when recomputing the HMAC; differences in key order, whitespace, or unicode escaping will break verification.
Deduplication
Webhook delivery is at-least-once: a single logical event may be delivered more than once (for example, when your endpoint times out or returns a 5xx/429 and Joy retries).
Joy computes the X-Idempotency-Key (mirrored in the body as idempotencyKey) once per logical event and reuses that same key for the original delivery and every retry of it. You can therefore deduplicate directly on X-Idempotency-Key.
To safely process each event at most once:
- On receipt, look up
X-Idempotency-Keyin a store (database row, RedisSET NX, etc.). - If the key already exists, treat the event as already handled and return
2xxwithout reprocessing. - If the key is new, record it (ideally atomically with the processing transaction) and process the event.
- Make your handler idempotent: re-processing the same event should have no additional effect.