Overview
Joy Subscription delivers outbound webhooks (Joy server → your endpoint) so your integration can react to subscription lifecycle, billing, order, and trial events in near real time.
Webhooks are configured per shop and delivered over HTTP POST with a JSON body. Every delivery is signed with HMAC-SHA256 (the X-Signature header), carries an idempotency key for safe deduplication, and is retried on retryable failures.
Configuration
Webhook delivery is configured per shop on the integration / external-app settings, available under the Developers tab in the Shopify admin of the app. Each external app is configured with three values:
| Field | Description |
|---|---|
address | Your receiver URL. Joy sends every delivery to this endpoint. Must be HTTPS. |
apiKey | Identifies the integration. Sent on every delivery in the X-API-Key header. |
apiSecret | Signing secret used to compute the HMAC X-Signature. Keep it server-side only. |
You also select which topics to subscribe to. Only events for subscribed topics are delivered.
Topics
Topic names match Shopify's native webhook topic strings (trial topics are custom).
| Topic | Trigger |
|---|---|
subscription_contracts/create | A subscription contract is created. |
subscription_contracts/update | A subscription contract is updated. |
subscription_contracts/activate | A contract is activated. |
subscription_contracts/pause | A contract is paused. |
subscription_contracts/resume | A paused contract is resumed. |
subscription_contracts/cancel | A contract is cancelled. |
subscription_contracts/expire | A contract reaches its end / expires. |
subscription_billing_attempts/success | A billing attempt succeeds. |
subscription_billing_attempts/failure | A billing attempt fails. |
orders/create | A subscription-related order is created. |
orders/update | A subscription-related order is updated. |
orders/cancelled | A subscription-related order is cancelled. |
trial/ending | A trial is approaching its end (emitted by the trial cron). |
trial/ended | A trial has ended (emitted by the trial cron). |
Notes:
trial/endingandtrial/endedare custom topics produced by Joy's trial cron — Shopify has no native equivalent.orders/*topics only fire for subscription-related orders, identified by a Joy subscription note likeSubscription #123 cycle 4, or by a selling-plan line item. Non-subscription orders are not forwarded.- The public topic name is sent in the
X-Shopify-Topicheader.
Delivery model
- Transport: HTTP
POSTwith a JSON body to your configuredaddress. - Content type:
application/json. - Acknowledgement: respond with a
2xxstatus as fast as possible, then do the real work asynchronously. - Retries: on a retryable failure Joy makes up to 2 retries (3 attempts total). The base retry delay is
3000 ms, doubling each attempt (retryDelay * 2^(attempt-1)→ roughly 3s then 6s). - Retryable conditions: HTTP status
>= 500,429, or408. Other4xxresponses are treated as non-retryable and the delivery is dropped after logging. - Idempotency: every delivery carries an idempotency key (header
X-Idempotency-KeyandidempotencyKeyin the body). Retries reuse the same key, so deduplicate on it.
Request headers
Each delivery includes the following headers:
| Header | Description |
|---|---|
Content-Type | application/json. |
X-API-Key | Your configured apiKey. |
X-Signature | Base64 HMAC-SHA256 of the JSON body, keyed with your apiSecret. |
X-Shopify-Topic | The Shopify-native 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. |
Payload envelope
All topics share one consistent payload shape: the topic-specific event fields, augmented by Joy with two delivery fields — timestamp (ISO 8601, mirrors X-Timestamp) and idempotencyKey (mirrors X-Idempotency-Key, default form <topic>-<uniqueId>-<shopDomain>).
Example payload 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"
}The HMAC signature is computed over the entire JSON body, including timestamp and idempotencyKey.
HMAC signature
Verify every delivery before trusting it. The X-Signature header is computed as:
X-Signature = base64( HMAC_SHA256( apiSecret, JSON.stringify(payload) ) )where payload is the full JSON request body. To verify, re-serialize the parsed JSON body, recompute the HMAC, and compare with a timing-safe comparison. Reject mismatches with 401.
const crypto = require('crypto');
function verifyJoySignature(rawBody, signatureHeader, apiSecret) {
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);
}Note: the signature is over
JSON.stringify(payload)as produced by the sender. If your platform re-serializes JSON differently (key order, whitespace, unicode escaping), recompute from the parsed object using a serialization that matches the sender.