Developer Docs
Webhook API
Authentication

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), your apiKey, and your receiver URL (address) are configured on the external-app / integration settings for the shop. Keep apiSecret server-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:

HeaderDescription
Content-TypeAlways application/json.
X-API-KeyYour configured apiKey. Identifies the integration.
X-SignatureBase64 HMAC-SHA256 of the JSON body, keyed with your apiSecret.
X-Shopify-TopicThe Shopify-native topic name (e.g. subscription_contracts/cancel).
X-Shopify-DomainThe shop domain (e.g. example.myshopify.com).
X-Idempotency-KeyStable 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-TimestampISO 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-Signature before trusting a payload. Reject mismatches with 401.
  • 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 on X-Idempotency-Key to prevent duplicate processing. See Deduplication.
  • Keep apiSecret server-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-Key in a store (database row, Redis SET NX, etc.).
  • If the key already exists, treat the event as already handled and return 2xx without 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.
Product
Install on ShopifyWebsitePricing
Resources
DocumentationGetting StartedFAQsIntegrations
Company
Avada GroupContact Support
© 2026 Avada Group. All rights reserved.