Integration Examples
This page shows end-to-end examples for receiving and verifying Joy Subscription outbound webhooks (Joy server → your endpoint). Each example listens for POST deliveries, verifies the X-Signature header, and acknowledges with a 2xx status before doing any real work.
The signature is computed as:
X-Signature = base64( HMAC_SHA256( apiSecret, JSON.stringify(payload) ) )payload is the full JSON body, including the delivery fields timestamp and idempotencyKey that Joy adds to every event. The HMAC covers all of it.
Note: The signature is computed over
JSON.stringify(payload)as produced by the sender. Your verification must re-serialize the parsed body the same way (key order, whitespace, unicode escaping). When possible, verify against the body exactly as received. This is why each example below captures or reconstructs the raw bytes carefully.
Delivery headers you will receive
| 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. |
The body shares one envelope across all topics — the topic-specific event fields plus timestamp and idempotencyKey. Example for subscription_contracts/cancel:
{
"subscriptionContractId": 123456789,
"status": "CANCELLED",
"reasonCode": "too_expensive",
"timestamp": "2026-06-24T10:00:00.000Z",
"idempotencyKey": "subscription_contracts/cancel-abc123-example.myshopify.com"
}Node.js (Express, raw body)
To verify the signature against the exact bytes Joy sent, capture the raw request body. The example uses express.json with a verify callback to stash the raw buffer on the request, then verifies before processing.
const express = require('express');
const crypto = require('crypto');
const app = express();
// Capture the raw body so we can verify against the exact bytes received.
app.use(
'/webhooks/joy',
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf; // Buffer of the exact request body
}
})
);
function verifyJoySignature(rawBody, signatureHeader, apiSecret) {
// rawBody: the exact JSON bytes received (Buffer or string).
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);
}
app.post('/webhooks/joy', (req, res) => {
// Prefer the captured raw bytes. If your framework strips them, fall back to
// re-serializing the parsed body with JSON.stringify (matches the sender).
const rawBody = req.rawBody || Buffer.from(JSON.stringify(req.body));
if (!verifyJoySignature(rawBody, req.get('X-Signature'), process.env.JOY_API_SECRET)) {
return res.status(401).end();
}
// Deduplicate on the idempotency key — retries reuse the same key.
const idempotencyKey = req.get('X-Idempotency-Key');
const topic = req.get('X-Shopify-Topic');
const shop = req.get('X-Shopify-Domain');
// Acknowledge fast (2xx), then process asynchronously (queue/worker).
res.status(200).end();
enqueueForProcessing({topic, shop, idempotencyKey, payload: req.body});
});
function enqueueForProcessing(event) {
// Offload to a queue/worker. Make handling idempotent on event.idempotencyKey.
}
app.listen(3000);Note: Returning
5xx,429, or408tells Joy to retry (up to 2 retries, 3 attempts total, exponential backoff ~3s then 6s). Any other 4xx is treated as non-retryable and the delivery is dropped after logging — return401only for genuine signature mismatches.
Python (Flask)
Flask exposes the raw body via request.get_data(). Verify against those exact bytes, then parse JSON.
import base64
import hashlib
import hmac
import os
from flask import Flask, request, abort, jsonify
app = Flask(__name__)
JOY_API_SECRET = os.environ["JOY_API_SECRET"]
def verify_joy_signature(raw_body: bytes, signature_header: str, api_secret: str) -> bool:
# raw_body: the exact request bytes received.
digest = hmac.new(api_secret.encode(), raw_body, hashlib.sha256).digest()
expected = base64.b64encode(digest).decode()
return hmac.compare_digest(expected, signature_header or "")
@app.post("/webhooks/joy")
def joy_webhook():
raw_body = request.get_data() # bytes, exactly as received
signature = request.headers.get("X-Signature", "")
if not verify_joy_signature(raw_body, signature, JOY_API_SECRET):
abort(401)
payload = request.get_json(silent=True) or {}
# Deduplicate on the idempotency key — retries reuse the same key.
idempotency_key = request.headers.get("X-Idempotency-Key")
topic = request.headers.get("X-Shopify-Topic")
shop = request.headers.get("X-Shopify-Domain")
# Acknowledge fast (2xx), then process asynchronously.
enqueue_for_processing(topic, shop, idempotency_key, payload)
return jsonify(success=True), 200
def enqueue_for_processing(topic, shop, idempotency_key, payload):
# Offload to a queue/worker. Make handling idempotent on idempotency_key.
pass
if __name__ == "__main__":
app.run(port=3000)Note: If you must verify from the parsed object instead of the raw bytes, serialize with
json.dumps(body_obj, separators=(",", ":"))to match the sender's serialization. Verifying against the raw bytes is more reliable.
PHP
Read the raw body from php://input, verify, then decode.
<?php
$apiSecret = getenv('JOY_API_SECRET');
// Read the exact bytes received.
$rawBody = file_get_contents('php://input');
$signatureHeader = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
function verify_joy_signature($rawBody, $signatureHeader, $apiSecret) {
$expected = base64_encode(hash_hmac('sha256', $rawBody, $apiSecret, true));
return hash_equals($expected, $signatureHeader ?? '');
}
if (!verify_joy_signature($rawBody, $signatureHeader, $apiSecret)) {
http_response_code(401);
exit;
}
$payload = json_decode($rawBody, true);
// Deduplicate on the idempotency key — retries reuse the same key.
$idempotencyKey = $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? null;
$topic = $_SERVER['HTTP_X_SHOPIFY_TOPIC'] ?? null;
$shop = $_SERVER['HTTP_X_SHOPIFY_DOMAIN'] ?? null;
// Acknowledge fast (2xx), then process asynchronously.
http_response_code(200);
// flush so the connection can close before heavy work
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request();
}
enqueue_for_processing($topic, $shop, $idempotencyKey, $payload);
function enqueue_for_processing($topic, $shop, $idempotencyKey, $payload) {
// Offload to a queue/worker. Make handling idempotent on $idempotencyKey.
}Note: If you verify from a re-encoded object instead of the raw bytes, use
json_encode($bodyObj, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)to match the sender. Verifying againstphp://input(the raw bytes) avoids serialization mismatches entirely.
Testing locally with cURL
You can simulate a delivery during development. Compute a signature over the exact body you send, then post it to your endpoint. This Node snippet prints a ready-to-run cURL command:
const crypto = require('crypto');
const apiSecret = process.env.JOY_API_SECRET;
const payload = {
subscriptionContractId: 123456789,
status: 'CANCELLED',
reasonCode: 'too_expensive',
timestamp: '2026-06-24T10:00:00.000Z',
idempotencyKey: 'subscription_contracts/cancel-abc123-example.myshopify.com'
};
const body = JSON.stringify(payload);
const signature = crypto.createHmac('sha256', apiSecret).update(body).digest('base64');
console.log(`curl -X POST http://localhost:3000/webhooks/joy \\
-H 'Content-Type: application/json' \\
-H 'X-API-Key: your-api-key' \\
-H 'X-Shopify-Topic: subscription_contracts/cancel' \\
-H 'X-Shopify-Domain: example.myshopify.com' \\
-H 'X-Idempotency-Key: ${payload.idempotencyKey}' \\
-H 'X-Timestamp: ${payload.timestamp}' \\
-H 'X-Signature: ${signature}' \\
-d '${body}'`);Verification checklist
- Verify
X-Signaturebefore trusting any payload. Reject mismatches with401. - Deduplicate on
X-Idempotency-Key— retries reuse the same key, so process each event at most once. - Acknowledge fast with
2xx, then process asynchronously to avoid timeouts and unnecessary retries. - Make handlers idempotent end-to-end — network conditions can still cause repeats.
- Return
5xx,429, or408to ask Joy to retry; return a non-retryable 4xx only for permanently bad requests. - Serve your receiver over HTTPS only.
- Filter by
X-Shopify-Topicand ignore topics you do not handle. - Do not assume field presence you do not see —
orders/*payloads may carrynullforsubscriptionContractId/cycleIndex.