Developer Docs
Webhook API
Security

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:

  • apiSecret is your shop's configured signing secret. Keep it server-side only.
  • payload is the entire JSON request body — including the Joy-added delivery fields timestamp and idempotencyKey, 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:

HeaderDescription
Content-Typeapplication/json.
X-API-KeyYour configured apiKey (identifies the integration).
X-SignatureBase64 HMAC-SHA256 of the JSON body, keyed with 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.

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_UNICODE so 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-Signature before trusting a payload. Reject mismatches with 401.
  • Use timing-safe comparisoncrypto.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 apiSecret in 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 2xx immediately and offload the work; slow handlers risk timeouts and unnecessary retries.
Product
Install on ShopifyWebsitePricing
Resources
DocumentationGetting StartedFAQsIntegrations
Company
Avada GroupContact Support
© 2026 Avada Group. All rights reserved.