# Guide to Creem Webhooks: Features and Best Practices

Creem is a merchant of record for software companies: it sells your SaaS plans, licences and usage credits, handles tax and compliance as the legal seller, and pays you out. Because Creem owns the checkout and the billing cycle, your product only learns that someone has paid, renewed, fallen behind or asked for a refund through its webhooks. They are what turns a completed checkout into an active account, a failed renewal into a payment reminder, and a dispute into an alert for your team.

This guide covers Creem's checkout, subscription and credit events, which of them should drive access to your product, how to verify `creem-signature`, and how Creem's retry window works.

## What are Creem webhooks?

You register one or more HTTPS endpoints with Creem, each optionally limited to a list of event types. When an event happens, Creem sends a JSON `POST` whose body has an event `id` (`evt_...`), the `eventType`, a `created_at` time in milliseconds and the affected `object`. Test mode and live mode are separate, with their own dashboards, endpoints and secrets.

Every delivery is signed with the endpoint's secret: `creem-signature` holds a bare hex HMAC-SHA256 of the raw body. Creem doesn't send a timestamp, so the body is all that's signed.

## Creem webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard **Developers** tab, or `POST /v1/webhooks` (SDK, CLI, MCP) |
| Subscription | `events` list per endpoint. An empty list means every type |
| Signature | hex HMAC-SHA256 |
| Signature header | `creem-signature` |
| Signed content | The raw request body |
| Secret | One per endpoint, separate for test and live mode |
| Body | JSON |
| Event routing | Body field `eventType` |
| Acknowledgement | HTTP `200`. No timeout is documented |
| Retries | 5 attempts: initial, then after 30 s, 5 min, 30 min and 6 h. None after 24 hours |
| Manual resend | From the Developers section of the dashboard |
| Source IPs | None. Creem has no static egress IPs |

## Events and payloads

| `eventType` | Triggered When | Common Use |
| --- | --- | --- |
| `checkout.completed` | A checkout finishes (carries `license_keys` when the product issues them) | Fulfil the order |
| `subscription.active` | A new subscription is created and its payment collected | Synchronisation only; Creem advises `subscription.paid` for access |
| `subscription.paid` | A subscription payment succeeds | Grant or extend access |
| `subscription.canceled` | The merchant or customer cancelled the subscription | Revoke access |
| `subscription.scheduled_cancel` | Cancellation is scheduled for the end of the billing period | Retention offer |
| `subscription.past_due` | A payment attempt failed; Creem retries it | Payment-recovery prompt |
| `subscription.unpaid` | The subscription moved to `unpaid` after failed collection | Suspend access per policy |
| `subscription.expired` | `current_end_period` was reached without a new payment | Revoke access (retries can still happen until `canceled`) |
| `subscription.update` | The subscription was changed | Sync |
| `subscription.trialing` | The subscription entered a trial | Sync |
| `subscription.paused` | The subscription was paused | Sync |
| `refund.created` | A refund is created | Bookkeeping |
| `dispute.created` | A dispute is opened | Alert your team |
| `credits.granted` | Credits were added to a customer's credit account | Balance sync |
| `credits.consumed` | Credits were debited from a customer's account | Low-balance warnings |
| `credits.auto_recharged` | An empty account was topped up automatically (also fires `credits.granted`) | Billing sync |
| `customer_credits.exhausted` | Credits ran out while settling a prepaid usage price | Upsell, throttling |

Creem draws a line between `subscription.active` and `subscription.paid`. `active` fires once, when a new subscription is created and its first payment collected, and Creem recommends it only for synchronisation. `paid` fires for every successful payment, including renewals, and is the event Creem advises for granting or extending access. A failed renewal produces `past_due`, then possibly `unpaid`, and `expired` once the period ends without payment. Creem notes that payment retries can still happen after `expired` until the subscription is `canceled`, so don't delete a customer's data on `expired`.

The credit events are for usage-based products. `credits.auto_recharged` also fires `credits.granted`, so count balance changes from one or the other, not both.

### Event Payload Structure

* `id` is the event id (`evt_...`); use it to deduplicate.
* `eventType` names the event; no header carries it.
* `created_at` is a Unix time in milliseconds.
* `object` is the resource; its own `object` key names the kind, such as `checkout` or `subscription`.
* Credit amounts (`amount_minor_units`, `balance_after_minor_units`) are strings, so parse them as integers in minor units.

## Setting up Creem webhooks

In the dashboard, open the **Developers** tab and add a webhook with your HTTPS URL. Copy the secret from **Developers > Webhook** into `WEBHOOK_SECRET` and treat it as a plain string key.

To create endpoints from code, use `POST /v1/webhooks`, which the TypeScript SDK, the CLI and the MCP server all wrap. API keys need the `webhooks:write` scope:

```bash
creem webhooks create --url https://example.com/webhooks/creem \
  --event checkout.completed --event subscription.paid --json

```

The API returns the secret when the endpoint is created, and you can fetch it again with `creem.webhooks.getSecret(id)` or `creem webhooks secret <id> --json`. Endpoints can be disabled without being deleted, which is handy while you fix a broken receiver.

Creem says web application firewalls and bot-protection products may challenge its automated requests. If deliveries are blocked, add a skip rule for the webhook route.

To receive test-mode deliveries on your machine, forward them with the [Hookdeck CLI](/docs/cli):

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

```

## Securing Creem webhooks

Compute an HMAC-SHA256 of the raw body with the endpoint secret, encode it as hex and compare it with `creem-signature` in constant time. Creem's TypeScript snippet is `createHmac('sha256', secret).update(payload).digest('hex')`. See the [verification documentation](https://docs.creem.io/code/webhooks#webhook-signatures).

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['creem-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler verifies, skips events it has already processed, and grants or removes access according to Creem's own guidance on which events to trust.

```javascript
const express = require('express');

function createCreemApp({ alreadyHandled, access, notify }) {
  const app = express();
  app.post('/webhooks/creem', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { id, eventType, object } = parsed;
    if (!id || typeof eventType !== 'string') return res.sendStatus(400);

    try {
      if (await alreadyHandled(id)) return res.sendStatus(200);
      switch (eventType) {
        case 'checkout.completed':
        case 'subscription.paid':
          await access.grant(object);
          break;
        case 'subscription.canceled':
          await access.revoke(object);
          break;
        case 'subscription.past_due':
        case 'dispute.created':
          await notify(eventType, object);
          break;
        default:
          break; // stored by alreadyHandled, nothing else to do
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

No timestamp is signed, so there's no replay window to apply. The event `id` is the protection against repeats.

Hookdeck's Creem source type verifies `creem-signature` as a hex HMAC-SHA256 of the raw body with your endpoint secret, in either letter case. Hookdeck answers Creem with `200` regardless, records a failed check as failed verification and does not forward that request. No timestamp check is involved.

> **Make Creem webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies every Creem delivery, keeps each payment and subscription event beyond Creem's 24-hour retry window, and lets you replay any of them into your billing code.

## Creem webhook limitations and pain points

### A 24-hour retry window

**The Problem:** A weekend outage leaves customers who paid without access.

**Why It Happens:** Creem makes 5 attempts, the last about 6.5 hours after the first, and doesn't retry anything after 24 hours. A longer outage loses the event unless someone resends it.

**Workarounds:**

* Use the dashboard's resend function for events that failed.
* Reconcile subscriptions against the Creem API after an incident.

**How Hookdeck Can Help:** Hookdeck accepts each event on Creem's first attempt and stores it, so the 24-hour limit no longer applies to your own downtime. You can retry or replay from Hookdeck whenever your service is back.

### Choosing the wrong event for access

**The Problem:** Customers lose access after renewing, or keep it after their payment fails.

**Why It Happens:** `subscription.active` fires only for a new subscription, while renewals arrive as `subscription.paid`. Payment failure moves through `past_due`, `unpaid` and `expired`, and retries can still succeed after `expired`.

**Workarounds:**

* Grant and extend access on `subscription.paid`, as Creem advises.
* Decide your policy for `past_due` and `unpaid` explicitly, and revoke on `canceled`.

**How Hookdeck Can Help:** Hookdeck's event log shows each subscription's events in order, which makes it easy to check your state machine against what Creem actually sent.

### No IP allowlist, and WAFs get in the way

**The Problem:** Deliveries are blocked by your own security layer, or you can't restrict the endpoint by network.

**Why It Happens:** Creem has no static egress IPs, and it notes that bot protection may challenge its requests.

**Workarounds:**

* Rely on signature verification and fail closed when the secret is missing.
* Add a WAF skip rule scoped to the webhook path.

**How Hookdeck Can Help:** With Hookdeck in front, only Hookdeck needs to reach your service, so your firewall rules can be written for Hookdeck rather than for Creem's changing addresses.

### At-least-once delivery

**The Problem:** A renewal is credited twice, or two licence keys are issued.

**Why It Happens:** Creem delivers at least once, and a slow `200` can lead to a retry of an event you already processed.

**Workarounds:**

* Store the event `id` in the same transaction as the change.
* Answer quickly and process afterwards.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated `evt_` IDs before they reach your service.

## Best practices

### Verify against the raw body

Read the body with `express.raw({ type: '*/*' })` in Express, `await request.arrayBuffer()` in Next.js or `await request.body()` in FastAPI, and reject requests when no secret is configured, because an empty key makes the HMAC trivially forgeable.

### Keep test and live apart

Test and live endpoints have different secrets. Use separate environment variables or separate Hookdeck sources so a test event never verifies against production.

### Treat credit amounts as strings

`amount_minor_units` and `balance_after_minor_units` arrive as strings. Parse them as integers in minor units, never as floating-point currency.

### Filter at the endpoint

Give each endpoint the events its consumer needs. A licensing service may only need `checkout.completed` and the subscription events, while finance wants refunds and disputes.

### Deduplicate and acknowledge fast

Store the `id`, return `200`, then do the work. Our guides to [webhook idempotency](/webhooks/guides/implement-webhook-idempotency) and [asynchronous processing](/webhooks/guides/why-implement-asynchronous-processing-webhooks) cover both halves.

## Conclusion

Creem's webhooks are straightforward to verify: a bare hex HMAC over the body, no timestamp. The care goes into the subscription states, where `paid` rather than `active` should drive access, and into the 24-hour retry limit, which makes resends and reconciliation part of running a Creem integration.

[Hookdeck Event Gateway](https://hookdeck.com) removes that time limit for your side of the connection and keeps a replayable record of every billing event.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Creem webhooks reliably in minutes.