Developer Docs
Webhook API
Integration Examples

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

HeaderDescription
Content-Typeapplication/json.
X-API-KeyYour configured apiKey (identifies the integration).
X-SignatureBase64 HMAC-SHA256 of the JSON body, keyed with your apiSecret.
X-Shopify-TopicThe topic name, e.g. subscription_contracts/cancel.
X-Shopify-DomainThe shop domain, e.g. example.myshopify.com.
X-Idempotency-KeyUnique key for this event; identical across retries.
X-TimestampISO 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, or 408 tells 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 — return 401 only 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 against php://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-Signature before trusting any payload. Reject mismatches with 401.
  • 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, or 408 to ask Joy to retry; return a non-retryable 4xx only for permanently bad requests.
  • Serve your receiver over HTTPS only.
  • Filter by X-Shopify-Topic and ignore topics you do not handle.
  • Do not assume field presence you do not see — orders/* payloads may carry null for subscriptionContractId / cycleIndex.
Product
Install on ShopifyWebsitePricing
Resources
DocumentationGetting StartedFAQsIntegrations
Company
Avada GroupContact Support
© 2026 Avada Group. All rights reserved.