# Joy Subscription — Outbound Webhook Guide

Joy Subscription delivers **outbound** webhooks (Joy server → your endpoint) so
your integration can react to subscription lifecycle, billing, and trial
events in near real time.

This guide covers the topic list, delivery model, request headers, payload
envelope, HMAC signature verification, and best practices.

> Webhook delivery configuration (your receiver URL + credentials) is set up on
> the integration / external-app configuration for the shop. Each external app
> is configured with an `address` (your receiver URL), an `apiKey`, and an
> `apiSecret` used to sign payloads.

---

## 1. Topics

The 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. |
| `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_ending` / `trial_ended` are **custom** topics produced by Joy's trial
  cron — Shopify has no native equivalent.
- The public topic name is sent in the `X-Shopify-Topic` header (see below). Joy
  internally maps Shopify-native topic names to these public names before
  delivery; topics with no mapping are forwarded unchanged.

---

## 2. Delivery model

- **Transport:** HTTP `POST` with a JSON body to your configured `address`.
- **Content type:** `application/json`.
- **Acknowledgement:** respond with a `2xx` status as fast as possible. Do the
  real work asynchronously.
- **Retries:** on a **retryable** failure Joy retries up to **3 times** with
  exponential backoff. The base retry delay is `3000 ms`, doubling each attempt
  (`retryDelay * 2^(attempt-1)` — ~3s, 6s, 12s).
- **Retryable conditions:** HTTP status `>= 500`, `429`, or `408`. Other 4xx
  responses are treated as non-retryable (the delivery is dropped after logging).
- **Idempotency:** every delivery carries an idempotency key (header
  `X-Idempotency-Key` and `idempotencyKey` in the body). Retries reuse the **same**
  key. Deduplicate on it.

---

## 3. Request headers

Each delivery includes:

| 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`. See §5. |
| `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. |

---

## 4. Payload envelope

All topics share **one** consistent payload shape. The body is the event payload
for the topic, augmented by Joy with two delivery fields:

| Field | Type | Description |
|-------|------|-------------|
| `timestamp` | string (ISO 8601) | When the payload was built (mirrors `X-Timestamp`). |
| `idempotencyKey` | string | Mirrors `X-Idempotency-Key`. Default form: `<topic>-<uniqueId>-<shopDomain>`. |
| *(event fields)* | object | Topic-specific fields (see examples). |

> The HMAC signature is computed over the **entire JSON body** (including
> `timestamp` and `idempotencyKey`). Verify against the exact received bytes /
> re-serialized JSON — see §5.

### Example — `subscription_contracts/cancel`

```jsonc
{
  "subscriptionContractId": 123456789,
  "status": "cancelled",
  "reasonCode": "too_expensive",
  "timestamp": "2026-06-24T10:00:00.000Z",
  "idempotencyKey": "subscription_contracts/cancel-abc123-example.myshopify.com"
}
```

### Example — `billing_attempt.failure`

```jsonc
{
  "subscriptionContractId": 123456789,
  "billingAttemptId": "…",
  "errorCode": "…",
  "timestamp": "2026-06-24T10:05:00.000Z",
  "idempotencyKey": "billing_attempt.failure-def456-example.myshopify.com"
}
```

---

## 5. HMAC signature verification

`X-Signature` is computed as:

```
X-Signature = base64( HMAC_SHA256( apiSecret, JSON.stringify(payload) ) )
```

where `payload` is the JSON request body (the full object, including `timestamp`
and `idempotencyKey`). To verify, re-serialize the parsed JSON body and recompute.

### Node.js

```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

```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
<?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 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. When possible, verify against the body as
> the sender produced it.

---

## 6. Best practices

- **Always verify `X-Signature`** before trusting a payload. Reject mismatches
  with `401`.
- **Deduplicate on `X-Idempotency-Key`.** Retries reuse the same key; process
  each event at most once.
- **Acknowledge fast (2xx), process async.** Return `2xx` immediately and offload
  work to a queue/worker. Slow handlers risk timeouts and unnecessary retries.
- **Make handlers idempotent** end-to-end, even beyond the idempotency key —
  network conditions can still cause repeats.
- **Return the right status for retries.** Use `5xx`/`429`/`408` to ask Joy to
  retry; return a non-retryable 4xx only for permanently bad requests.
- **HTTPS only.** Expose your receiver over TLS.
- **Filter by `X-Shopify-Topic`** (or the topic implied by the payload) and
  ignore topics you don't handle.
