Webhook Configuration
Joy Subscription delivers outbound webhooks (Joy server → your endpoint). Before any event can be delivered, you must configure a receiver on the integration / external-app settings for your shop. Configuration lives under the Developers tab in the Shopify admin of the app.
Each external app is configured with three values: a receiver URL (address), an apiKey that identifies the integration, and an apiSecret used to sign every payload. Joy will not deliver events until a signing secret has been generated, because every delivery is signed with HMAC-SHA256.
Configuration fields
Webhook delivery is configured per shop. The external app is configured with the following fields:
| Field | Maps to header | Required | Description |
|---|---|---|---|
address | — | Yes | Your receiver URL. Joy sends every delivery to this endpoint as an HTTP POST with a JSON body. Must be HTTPS. |
apiKey | X-API-Key | Yes | Identifies the integration. Sent on every delivery so your endpoint can recognize the caller. |
apiSecret | X-Signature (used to sign) | Yes | The signing secret. Joy computes base64(HMAC_SHA256(apiSecret, JSON.stringify(payload))) and sends it in the X-Signature header. A secret is required for Joy to send deliveries. |
Note: The
apiSecretis server-side only. Never embed it in browser JavaScript, mobile apps, or any client the customer can inspect. Verify signatures only on your backend.
Steps
- Open your app's Developers tab in the Shopify admin.
- Set the endpoint URL (
address) — the HTTPS receiver where Joy willPOSTdeliveries. - Generate a signing secret (
apiSecret). This is required for Joy to send deliveries. Store it in your server-side secret manager. - Set the
apiKeythat identifies your integration. It is sent on every delivery in theX-API-Keyheader. - Choose the topics you want delivered to this endpoint (see the topic list below).
- Save the configuration.
After saving, Joy starts delivering the selected topics to your address. Each delivery is signed with the apiSecret, carries an idempotency key for deduplication, and is retried on retryable failures.
Endpoint URL requirements
- The
addressmust be reachable over HTTPS (TLS). Plain HTTP is not supported. - Your endpoint must respond with a
2xxstatus as fast as possible, then process the event asynchronously. - Joy sends an HTTP
POSTwithContent-Type: application/json.
A configured receiver receives every delivery with these headers:
| Header | Description |
|---|---|
Content-Type | application/json. |
X-API-Key | Your configured apiKey (identifies the integration). |
X-Signature | Base64 HMAC-SHA256 of the JSON body, keyed with your apiSecret. |
X-Shopify-Topic | The Shopify-native topic name (for example subscription_contracts/cancel). |
X-Shopify-Domain | The shop domain (for example 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. |
Signing secret
A signing secret (apiSecret) is mandatory — Joy will not deliver events until one is generated. Joy uses it to sign every payload:
X-Signature = base64( HMAC_SHA256( apiSecret, JSON.stringify(payload) ) )payload is the full JSON request body, including the delivery fields timestamp and idempotencyKey. To verify, re-serialize the parsed JSON body and recompute the HMAC, then compare with a timing-safe comparison.
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);
}Note: If your signing secret is ever exposed, rotate it from the Developers tab and update your receiver. The new secret takes effect for subsequent deliveries.
Topics
Select the topics you want delivered to your endpoint. Topic names match Shopify's native webhook topic strings (trial topics are custom to Joy):
| 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.- The
orders/*topics only fire for subscription-related orders, identified by a Joy subscription note (such asSubscription #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 behavior
Once a topic is configured, deliveries follow this model:
- Transport: HTTP
POSTwith a JSON body to your configuredaddress. - 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. Other 4xx responses 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 — deduplicate on it.