Gareth Wilson Gareth Wilson

Guide to Creem Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationDashboard Developers tab, or POST /v1/webhooks (SDK, CLI, MCP)
Subscriptionevents list per endpoint. An empty list means every type
Signaturehex HMAC-SHA256
Signature headercreem-signature
Signed contentThe raw request body
SecretOne per endpoint, separate for test and live mode
BodyJSON
Event routingBody field eventType
AcknowledgementHTTP 200. No timeout is documented
Retries5 attempts: initial, then after 30 s, 5 min, 30 min and 6 h. None after 24 hours
Manual resendFrom the Developers section of the dashboard
Source IPsNone. Creem has no static egress IPs

Events and payloads

eventTypeTriggered WhenCommon Use
checkout.completedA checkout finishes (carries license_keys when the product issues them)Fulfil the order
subscription.activeA new subscription is created and its payment collectedSynchronisation only; Creem advises subscription.paid for access
subscription.paidA subscription payment succeedsGrant or extend access
subscription.canceledThe merchant or customer cancelled the subscriptionRevoke access
subscription.scheduled_cancelCancellation is scheduled for the end of the billing periodRetention offer
subscription.past_dueA payment attempt failed; Creem retries itPayment-recovery prompt
subscription.unpaidThe subscription moved to unpaid after failed collectionSuspend access per policy
subscription.expiredcurrent_end_period was reached without a new paymentRevoke access (retries can still happen until canceled)
subscription.updateThe subscription was changedSync
subscription.trialingThe subscription entered a trialSync
subscription.pausedThe subscription was pausedSync
refund.createdA refund is createdBookkeeping
dispute.createdA dispute is openedAlert your team
credits.grantedCredits were added to a customer's credit accountBalance sync
credits.consumedCredits were debited from a customer's accountLow-balance warnings
credits.auto_rechargedAn empty account was topped up automatically (also fires credits.granted)Billing sync
customer_credits.exhaustedCredits ran out while settling a prepaid usage priceUpsell, 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:

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:

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.

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.

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 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 and asynchronous processing 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 removes that time limit for your side of the connection and keeps a replayable record of every billing event.

Get started with Hookdeck for free and handle Creem webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.