# Purchasely Webhooks

Purchasely (in-app subscription and paywall management) sends server events to your HTTPS endpoint
for the whole subscription lifecycle: lifecycle, offer, transactional and entitlement events. Each
delivery is a `POST` with a JSON body whose `event_name` field names the event, signed with
HMAC-SHA256 over the **timestamp header value followed by the raw body**.

## When to Use This Skill

* How do I receive Purchasely webhooks?
* How do I verify the `X-PURCHASELY-REQUEST-SIGNATURE` header?
* Why does my Purchasely signature verification fail?
* How do I handle `SUBSCRIPTION_STARTED`, `SUBSCRIPTION_RENEWED` or `SUBSCRIPTION_TERMINATED` events?
* Why does Purchasely keep retrying, and why are webhooks for one user paused?

## Verification (core)

Headers: `X-PURCHASELY-REQUEST-SIGNATURE` (bare lowercase hex digest, no prefix) and
`X-PURCHASELY-TIMESTAMP` (Unix seconds). Signed message = timestamp header value + raw body, no
separator. Key = the webhook shared secret as UTF-8 (not decoded). Ignore the deprecated
`X-PURCHASELY-SIGNATURE` header.

```javascript
const crypto = require('crypto');
const HEX_SHA256 = /^[0-9a-fA-F]{64}$/;

function verifyPurchasely(headers, rawBody, secret = process.env.PURCHASELY_WEBHOOK_SECRET) {
  const sig = headers['x-purchasely-request-signature'];
  const ts = headers['x-purchasely-timestamp'];
  if (!secret || typeof sig !== 'string' || typeof ts !== 'string' || !ts) return false;
  if (!HEX_SHA256.test(sig)) return false;                    // validate before decoding
  const message = Buffer.concat([Buffer.from(ts, 'latin1'), rawBody]); // rawBody is a Buffer
  const expected = crypto.createHmac('sha256', secret).update(message).digest();
  return crypto.timingSafeEqual(expected, Buffer.from(sig.toLowerCase(), 'hex'));
}

```

Return `401` when it fails. Read the body raw (`express.raw({ type: '*/*' })` in Express,
`await request.arrayBuffer()` in Next.js, `await request.body()` in FastAPI) and parse JSON only
after the check.

**Timestamp window is off by default.** Hookdeck retries and replays resend the original headers and
timestamp, so a fixed window would reject legitimate deliveries. The examples offer an opt-in:
set `PURCHASELY_WEBHOOK_TOLERANCE_SECONDS` (unset or `0` = off) to reject timestamps further than that
many seconds from now.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/purchasely-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/purchasely-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/purchasely-webhooks/examples/fastapi/).

## Response and Retries

Answer **exactly HTTP `200`**. Any other code, other 2xx included, counts as a failure: Purchasely
retries up to 25 times over about 20 days, and **pauses later webhooks for that user** until the
first succeeds (to keep events in order). Dedupe on `event_id`, and return `200` for events you
choose to ignore.

## Event Types

Dispatch on the body's `event_name`. Common events (full list in [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/purchasely-webhooks/references/overview.md)):

| Event | Sent When |
| --- | --- |
| `SUBSCRIPTION_STARTED` | A subscription is purchased |
| `SUBSCRIPTION_RENEWED` | The subscription renews |
| `RENEWAL_DISABLED` | The customer turns off auto-renewal |
| `GRACE_PERIOD_STARTED` / `ENTERED_BILLING_RETRY` | A billing issue occurs at renewal |
| `SUBSCRIPTION_TERMINATED` | The subscription ends; no active subscription remains |
| `TRANSACTION_PROCESSED` | A transaction is processed (amount and currency) |
| `ACTIVATE` | An entitlement is activated (see Purchasely's entitlements guide) |

## Environment Variables

```bash
PURCHASELY_WEBHOOK_SECRET=your-webhook-shared-secret   # Console: [YOUR APP] > Settings > Webhooks
# PURCHASELY_WEBHOOK_TOLERANCE_SECONDS=300             # optional replay window, off by default

```

## Local Development

Purchasely needs a public `https` URL. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 purchasely --path /webhooks/purchasely

```

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/purchasely-webhooks/references/overview.md) - How Purchasely webhooks work, events, payload fields
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/purchasely-webhooks/references/setup.md) - Configure the webhook URL and events in the Console
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/purchasely-webhooks/references/verification.md) - Signature details, test vector, gotchas
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/purchasely-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests