Developer Docs
Webhook API
Best Practices

Best Practices

This page covers operational best practices for receiving Joy Subscription outbound webhooks (Joy server → your endpoint) reliably. It is built strictly from the documented delivery model: HTTPS-only receivers, fast 2xx acknowledgement, idempotency, asynchronous processing, monitoring, and Joy's retry behavior.

Joy delivers events such as subscription contract lifecycle changes, billing attempts, subscription-related orders, and trial cron events. Each delivery is signed and carries an idempotency key so you can process each event exactly once.


Use HTTPS only

Expose your webhook receiver over TLS. Joy delivers payloads via HTTP POST to the address configured for your external app on the shop. Plain HTTP endpoints leak signed payloads (which include customer and subscription identifiers) in transit.

  • Serve the receiver behind a valid TLS certificate.
  • Reject or do not register non-https:// receiver URLs.
  • Always verify the X-Signature header before trusting the body (HMAC-SHA256 over the JSON payload, keyed with your apiSecret).

Acknowledge fast — return 2xx in under 5 seconds

Respond with a 2xx status as quickly as possible, then do the real work asynchronously. Slow handlers risk timeouts and trigger unnecessary retries.

A safe handler does only three things synchronously:

  1. Verify the signature.
  2. Deduplicate on the idempotency key.
  3. Enqueue the event for background processing and return 2xx.
// Express — acknowledge fast, process async
const crypto = require('crypto');
 
app.post('/webhooks/joy', express.json(), (req, res) => {
  const rawBody = JSON.stringify(req.body); // Joy signs JSON.stringify(payload)
  const expected = crypto
    .createHmac('sha256', process.env.JOY_API_SECRET)
    .update(rawBody)
    .digest('base64');
 
  const sig = Buffer.from(req.get('X-Signature') || '');
  const exp = Buffer.from(expected);
  if (exp.length !== sig.length || !crypto.timingSafeEqual(exp, sig)) {
    return res.status(401).end(); // non-retryable: bad signature
  }
 
  const key = req.get('X-Idempotency-Key');
  // 1) dedupe on key, 2) enqueue, 3) ack — all without blocking
  enqueueForProcessing(key, req.body);
 
  return res.status(200).end(); // ack within a few seconds
});

Guideline: keep the synchronous path well under 5 seconds. Offload anything slower (DB writes, downstream API calls, emails) to a queue or worker.


Acknowledge with the right status code

Joy decides whether to retry based on the HTTP status you return. Return a 2xx to acknowledge successful receipt. Return a retryable status only when you genuinely want Joy to deliver again.

Response from your endpointJoy's behavior
2xxDelivery acknowledged. No retry.
>= 500Retryable. Joy retries (up to 2 retries, 3 attempts total).
429Retryable. Joy retries (up to 2 retries, 3 attempts total).
408Retryable. Joy retries (up to 2 retries, 3 attempts total).
Other 4xxNon-retryable. Delivery is dropped after logging.

Use 5xx / 429 / 408 to ask Joy to retry on transient failures. Return a non-retryable 4xx (for example 401 on signature mismatch) only for permanently bad requests, so you do not waste retry attempts on something that will never succeed.


Deduplicate with X-Idempotency-Key

Every delivery carries an idempotency key, available both as the X-Idempotency-Key header and as the idempotencyKey field in the body. Retries reuse the same key, so you can detect and drop duplicates.

The default key form is:

<topic>-<uniqueId>-<shopDomain>

For example:

subscription_contracts/cancel-abc123-example.myshopify.com

Recommended pattern:

  • On receipt, look up the key in a store (database row, Redis SET NX, etc.).
  • If it already exists, treat the event as already handled and return 2xx without reprocessing.
  • If it is new, record it (ideally atomically with the processing transaction) and process the event.
// Pseudocode — idempotent intake
async function handle(key, payload) {
  const fresh = await store.setIfAbsent(key, { receivedAt: Date.now() });
  if (!fresh) return; // duplicate retry — already handled
  await processEvent(payload);
}

The HMAC signature is computed over the entire JSON body, including timestamp and idempotencyKey. Verify against the bytes the sender produced (or a serialization that matches JSON.stringify(payload)) before reading the key.


Process asynchronously and make handlers idempotent end-to-end

The idempotency key prevents reprocessing of redelivered events, but you should still make the actual work idempotent — network conditions and your own retries can cause repeats beyond Joy's control.

  • Acknowledge first, then push the event to a queue/worker for the heavy lifting.
  • Design downstream operations to be safe to run more than once (upserts instead of blind inserts, conditional state transitions, etc.).
  • Do not assume every field is always present. For orders/* topics, subscriptionContractId and cycleIndex may be null when the order is subscription-related via a selling-plan line item but carries no Joy subscription note to parse. Guard against missing values.

Filter by topic

The Shopify-native topic name is delivered in the X-Shopify-Topic header (for example subscription_contracts/cancel). Branch on it and ignore topics you do not handle, rather than attempting to process every payload shape.

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.

Understand the retry behavior

On a retryable failure, Joy makes up to 2 retries (3 attempts total) with exponential backoff. The base retry delay is 3000 ms, doubling each attempt:

retryDelay * 2^(attempt-1)
attempt 1 → ~3s
attempt 2 → ~6s

Retryable conditions are HTTP status >= 500, 429, or 408. Any other 4xx response is treated as non-retryable and the delivery is dropped after logging.

Implications for your integration:

  • A persistently failing endpoint will stop receiving an event after the retries are exhausted — you cannot rely on indefinite redelivery. Monitor failures (see below) so you can backfill or reconcile.
  • Because retries reuse the same idempotency key, dedupe logic keeps repeated attempts from producing duplicate side effects.
  • If you need Joy to retry, make sure your error path returns >= 500, 429, or 408 — not a generic 400.

Monitor your receiver

Treat the webhook endpoint as a production service and instrument it.

  • Acknowledgement latency — track time-to-2xx; alert if it approaches the few-second budget that keeps you clear of timeouts and retries.
  • Status code mix — watch for spikes in 5xx / 429 / 408 (which cause retries) and 4xx (which drop deliveries).
  • Signature failures — a rise in 401 mismatches can indicate a rotated/incorrect apiSecret or a serialization mismatch in your verification.
  • Duplicate rate — count how often the idempotency key was already seen; a high rate suggests your handler is too slow or returning the wrong status.
  • Dead-letter / backfill — log dropped non-retryable deliveries and exhausted retries so you can reconcile missed events.

Checklist

  • Receiver served over HTTPS/TLS only.
  • X-Signature verified with timing-safe comparison before trusting the body.
  • Returns 2xx within a few seconds; heavy work offloaded to a worker.
  • Deduplicates on X-Idempotency-Key; handlers idempotent end-to-end.
  • Returns >= 500 / 429 / 408 only when a retry is wanted; non-retryable 4xx for permanently bad requests.
  • Branches on X-Shopify-Topic and ignores unhandled topics.
  • Tolerates null subscriptionContractId / cycleIndex on orders/* payloads.
  • Monitors latency, status mix, signature failures, and dropped deliveries.
Product
Install on ShopifyWebsitePricing
Resources
DocumentationGetting StartedFAQsIntegrations
Company
Avada GroupContact Support
© 2026 Avada Group. All rights reserved.