Integrations No. 411Editor's pick

Merchant of Record Billing skill for Claude Code

Sell globally while the MoR owes the VAT and sales tax. Claude wires merchant-of-record billing so the provider carries VAT and sales tax while your app only tracks entitlements. Checkout carries your account ID in custom data, webhooks are verified over the raw body and deduplicated before an entitlement state machine applies them, and the full buy, renew, fail, cancel and refund lifecycle is replayed in sandbox before launch.

  • namemerchant-of-record-billing
  • size280 lines · ≈3,300 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Integrates merchant-of-record billing with Paddle Billing, Lemon Squeezy, or Polar - overlay and hosted checkout, passing your user ID through custom data, signature-verified webhooks driving entitlements, subscription upgrades and cancellations, customer portals, tax-inclusive pricing, and license keys for desktop or CLI software. Use when selling SaaS or downloadable software globally without registering for VAT/sales tax, choosing between Paddle, Lemon Squeezy and Polar, wiring their webhooks, or debugging customers who paid but never got access.

Try asking

  • Add Paddle Billing checkout for our monthly and yearly plans and grant access from webhooks
  • Should we use Lemon Squeezy or Polar to sell our CLI with license keys? Then set it up
  • Some Polar customers paid but still show as free in our app - audit the webhook handler

Use it when

  • Selling SaaS or software internationally without registering for VAT or sales tax yourself
  • Choosing between Paddle Billing, Lemon Squeezy and Polar
  • Writing or fixing a Paddle, Lemon Squeezy or Polar webhook endpoint
  • Shipping license keys for a desktop app, CLI or plugin

Skip it when

  • You want Stripe as the payment processor with your own tax registrations - use stripe-payments instead
  • You only need generic webhook receiver patterns for another provider - use webhook-handling instead

How to check the result

In sandbox, every lifecycle scenario (purchase, decline, upgrade, failed renewal, cancel, refund, replayed event, tampered payload) leaves the billing row matching the provider dashboard, and a reconciliation run reports zero drift.

The common mistake it prevents

Matching purchases to users by checkout email instead of passing your account ID through custom data, so edited or shared emails leave paying customers without access.

~/.claude/skills/merchant-of-record-billing/SKILL.md

--- name: merchant-of-record-billing description: Integrates merchant-of-record billing with Paddle Billing, Lemon Squeezy, or Polar - overlay and hosted checkout, passing your user ID… license: MIT ---

Done well means: the MoR is the seller of record and owns tax, invoices, and refunds; your app owns only entitlements. Entitlements change only from signature-verified, deduplicated webhooks keyed to your own user ID, and the whole lifecycle (buy, renew, upgrade, fail, cancel, refund) has been replayed in the provider’s sandbox before launch.

Ground rules

  • Secrets come from the environment or a secret manager: PADDLE_API_KEY, PADDLE_WEBHOOK_SECRET, LEMONSQUEEZY_API_KEY, LEMONSQUEEZY_WEBHOOK_SECRET, POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET. Only client-side tokens (Paddle live_/test_ client tokens, Lemon.js store slug) may reach the browser.
  • Sandbox and live are separate accounts with separate IDs, keys, and webhook secrets. Never mix them; key every config value by environment.
  • The success redirect is UX only. Grant access from webhooks, never from the redirect URL.
  • Pass your internal user/account ID into checkout custom data. Never match buyers to users by email: checkout email is editable and shared inboxes exist.
  • Do not compute or display tax yourself. The MoR decides tax by buyer location; show prices from the provider’s price-preview API or its localized checkout.
  • Store the provider’s customer ID and subscription ID on your account row on the first verified event, and use them for every later lookup.

Choose the provider

SituationDefaultWhy
B2B SaaS, invoices with company tax IDs, sales-assisted deals, proration controlPaddle BillingMature subscription engine, explicit proration modes, invoicing, price previews per country
Indie software, digital downloads, simple subscriptions, built-in license keysLemon SqueezyFast setup, license-key API usable from client apps, hosted storefront
Developer tools, open source monetization, usage or benefit-based access (GitHub repo, Discord, license keys, downloads)PolarBenefits model grants and revokes access automatically, first-class SDKs, Standard Webhooks
You need full control of payment methods, Connect-style marketplaces, or you already have tax registrationsNot an MoRUse stripe-payments with Stripe Tax instead

Confirm current fees, payout countries, and product restrictions on the provider’s site before committing; MoRs reject some categories (adult, gambling, marketplaces, services).

Workflow

- [ ] 1. Model the catalog: products, prices (monthly/yearly), tax mode, trial rules
- [ ] 2. Map provider objects to your schema (customer, subscription, entitlement)
- [ ] 3. Build checkout with custom data = your account ID
- [ ] 4. Build the webhook endpoint: raw body, verify, dedupe, enqueue, 2xx fast
- [ ] 5. Write the entitlement state machine from events
- [ ] 6. Add plan changes, cancel/resume, and the customer portal
- [ ] 7. Add license keys if you ship desktop/CLI software
- [ ] 8. Replay the full lifecycle in sandbox; reconcile against the provider API

1–2. Catalog and schema

Create products and prices in the provider dashboard (or via API in a seed script per environment) and reference them by ID from config, never by name.

create table billing_accounts (
  account_id        uuid primary key references accounts(id),
  provider          text not null check (provider in ('paddle','lemonsqueezy','polar')),
  provider_customer text unique,
  provider_sub      text unique,
  plan              text not null default 'free',
  status            text not null default 'none',   -- active | trialing | past_due | paused | canceled | none
  current_period_end timestamptz,
  cancel_at_period_end boolean not null default false,
  updated_from_event_at timestamptz                 -- occurred_at of the last applied event
);
create table billing_events (
  provider   text not null,
  event_id   text not null,
  received_at timestamptz not null default now(),
  payload    jsonb not null,
  processed_at timestamptz,
  primary key (provider, event_id)
);

Tax mode: decide tax-inclusive (consumer, EU-style “price is the price”) or tax-exclusive (B2B, US-style). Set it per price; changing later confuses renewals.

3. Checkout

Paddle Billing (Paddle.js overlay; client token is safe in the browser):

<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script>
  if (window.APP_ENV !== 'production') Paddle.Environment.set('sandbox');
  Paddle.Initialize({ token: window.PADDLE_CLIENT_TOKEN });
  function buy(priceId) {
    Paddle.Checkout.open({
      items: [{ priceId, quantity: 1 }],
      customer: { email: window.CURRENT_USER_EMAIL },   // prefill only
      customData: { account_id: window.CURRENT_ACCOUNT_ID },
    });
  }
</script>

Lemon Squeezy (create the checkout server-side so custom data cannot be tampered with before the session exists):

const res = await fetch('https://api.lemonsqueezy.com/v1/checkouts', {
  method: 'POST',
  headers: {
    Accept: 'application/vnd.api+json',
    'Content-Type': 'application/vnd.api+json',
    Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
  },
  body: JSON.stringify({ data: {
    type: 'checkouts',
    attributes: { checkout_data: { email: user.email, custom: { account_id: account.id } } },
    relationships: {
      store:   { data: { type: 'stores',   id: process.env.LS_STORE_ID } },
      variant: { data: { type: 'variants', id: variantIdFor(plan) } },
    },
  } }),
});
const url = (await res.json()).data.attributes.url;   // redirect or open with Lemon.js

Custom data arrives in webhooks under meta.custom_data.

Polar (server-side, official SDK):

import { Polar } from '@polar-sh/sdk';
const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN!,
  server: process.env.APP_ENV === 'production' ? 'production' : 'sandbox',
});
const checkout = await polar.checkouts.create({
  products: [productIdFor(plan)],
  externalCustomerId: account.id,       // binds the Polar customer to your ID
  customerEmail: user.email,
  successUrl: `${process.env.APP_URL}/billing/success?checkout_id={CHECKOUT_ID}`,
});
// redirect to checkout.url

Server-side rule for all three: resolve the price/variant/product ID from your own plan allowlist. Never accept a raw price ID from the client.

4. Webhook endpoint

Same skeleton for every provider:

  1. Read the raw body bytes before any JSON parser touches them.
  2. Verify the signature with the provider’s scheme and a constant-time compare; reject with 400/401/403 on failure. Enforce the timestamp tolerance where the scheme has one.
  3. Insert (provider, event_id) into billing_events; on conflict, return 200 and stop.
  4. Enqueue processing; return 2xx within a couple of seconds.
  5. A worker applies the event inside a transaction.
// Express: mount raw body ONLY on the webhook route, before express.json()
app.post('/webhooks/paddle', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.get('Paddle-Signature') ?? '';
  let event;
  try {
    event = await paddle.webhooks.unmarshal(req.body.toString('utf8'), process.env.PADDLE_WEBHOOK_SECRET!, sig);
  } catch { return res.sendStatus(401); }
  const fresh = await recordEvent('paddle', event.eventId, event);
  if (fresh) await queue.add('billing-event', { provider: 'paddle', id: event.eventId });
  res.sendStatus(200);
});

Signature schemes, header names, event IDs, and manual verification code for Paddle, Lemon Squeezy, and Polar are in references/provider-webhooks.md. Read it when writing or debugging a webhook handler or the license-key flow.

5. Entitlement state machine

Derive access from subscription status, not from individual payment events.

Provider status / eventYour statusAccess
active, trialingactive / trialingFull
past_due (payment failed, dunning running)past_dueKeep access during the provider’s retry window; show a banner
pausedpausedRead-only or none, per product decision
canceled with period end in the future (scheduled_change / cancel_at_period_end / ends_at)active, cancel_at_period_end = trueFull until period end
canceled / expired / revokedcanceledNone
refund on a one-time ordercanceled for that orderRevoke the purchase entitlement

Rules:

  • Events can arrive out of order and more than once. Compare the event’s occurred_at (or the subscription’s updated_at) against updated_from_event_at and skip older ones.
  • When unsure, fetch the subscription from the provider API and write that state; the API is the source of truth, the event is a trigger.
  • Map unknown events to a no-op that returns 2xx so the provider does not retry forever.

6. Plan changes, cancel, portal

  • Paddle: PATCH /subscriptions/{id} with new items and an explicit proration_billing_mode (e.g. prorated_immediately for upgrades, full_next_billing_period for downgrades). Preview first with the subscription preview endpoint and show the buyer the charge. Cancel via POST /subscriptions/{id}/cancel with effective_from: 'next_billing_period'. Portal links via the customer portal sessions endpoint.
  • Lemon Squeezy: PATCH /v1/subscriptions/{id} with a new variant_id (set invoice_immediately for upgrades); DELETE /v1/subscriptions/{id} cancels at period end. The subscription object includes signed urls.customer_portal links; fetch fresh ones rather than storing them, they expire.
  • Polar: update the subscription’s product via the subscriptions API; cancel at period end via cancelAtPeriodEnd; create a customer session and send the user to the customer portal URL it returns.

Always apply the resulting state from the webhook that follows, not from the API response of your own mutation, so one code path owns the database.

7. License keys (desktop, CLI, plugins)

  • Lemon Squeezy and Polar issue keys natively; Paddle Billing does not, so generate your own on transaction.completed (signed, e.g. Ed25519 over account_id|plan|expiry) and email it through your transactional mail.
  • Activate once per device with a label (hostname), store the returned instance or activation ID locally, and validate on startup with a cache (e.g. revalidate every 7 days, allow offline grace of 14 days).
  • Never ship an API key or org secret in the client binary. The license validate and activate endpoints are designed to be called with only the key.

8. Verify in sandbox

# Expose the local endpoint and register it as the sandbox webhook destination
ngrok http 3000      # or: cloudflared tunnel --url http://localhost:3000

Run each scenario and check the DB row after every step:

  • New subscription with a test card: account becomes active with provider IDs stored
  • Declined card: no entitlement, no orphan rows
  • Upgrade and downgrade: plan and period end match the provider dashboard
  • Failed renewal (use the provider’s simulated failure or dashboard action): past_due, then canceled after dunning
  • Cancel at period end, then resume
  • Full refund on a one-time order revokes the entitlement
  • Replay the same webhook (provider’s resend button): processed once
  • Tamper one byte of a captured payload: endpoint returns 4xx
  • Nightly reconciliation job: list active subscriptions via API and diff against DB

Gotchas

  • Body parsers mounted globally (app.use(express.json()), Next.js route handlers calling req.json() first) destroy the raw body; read await req.text() in Next.js route handlers and verify before parsing.
  • Paddle can send more than one h1 in Paddle-Signature during secret rotation; accept if any matches.
  • Polar secrets created before its Standard Webhooks switch need base64 encoding when verifying manually; the official SDK helper handles both. See the reference.
  • Lemon Squeezy uses snake_case event names (subscription_updated) in the X-Event-Name header and meta.event_name; Paddle and Polar use dotted names.
  • Prices shown on your own pricing page drift from checkout once tax applies. Label them “excl. tax” or use the provider’s localized pricing preview.
  • Refunds and chargebacks are handled by the MoR; your job is to react to the resulting event. Do not build a parallel refund ledger.
  • A customer can buy twice (two tabs). Detect an existing active subscription before opening checkout and send them to the portal instead.
  • Switching MoR later means customers re-enter payment details; card data cannot be exported freely. Choose deliberately.

Output contract

When reporting an integration or audit, give:

## Billing integration: <provider> (<sandbox|live>)
- Catalog: <plan -> price/variant/product ID per env>
- Checkout: <where it is opened, custom data fields>
- Webhook: <route>, verification <method>, dedupe key <field>, events handled <list>
- Entitlement mapping: <status table or link>
- Lifecycle tests: <checklist with pass/fail>
- Open risks: <e.g. no reconciliation job, license offline grace undecided>

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/provider-webhooks.md 114 lines · 5.9 KB
# Provider webhook and license reference

Verify against the provider's docs when an SDK major version changes. All three schemes
sign the exact raw request bytes; any re-serialization breaks them.

## Paddle Billing

- Header: `Paddle-Signature: ts=<unix seconds>;h1=<hex>` (several `h1` values may be
  present while a secret is being rotated).
- Signed payload: `<ts>:<raw body>`. HMAC-SHA256 keyed with the notification
  destination's secret, hex digest.
- Tolerance: official SDKs default to a few seconds; manual implementations should reject
  stale timestamps too (allow small clock skew, keep it under a minute).
- Event ID for dedupe: `event_id` (SDK: `eventId`). Ordering: `occurred_at`.
- SDK helpers: Node `paddle.webhooks.unmarshal(rawBody, secret, signatureHeader)`;
  Python `paddle_billing.Notifications.Verifier().verify(request, Secret(...))`.
- Key events:
  - `transaction.completed` - payment captured; fulfil one-time purchases here.
  - `subscription.created`, `subscription.activated`, `subscription.updated` - write
    status, items, `current_billing_period.ends_at`, `scheduled_change`.
  - `subscription.past_due`, `subscription.paused`, `subscription.resumed`,
    `subscription.canceled`.
  - `adjustment.created` / `adjustment.updated` - refunds and credits.
- `custom_data` set at checkout appears on the transaction and the subscription.
- License keys: not provided by Paddle Billing; issue your own.

Manual verification (Node, stdlib):

```js
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verifyPaddle(rawBody, header, secret, toleranceSec = 30) {
  const pairs = header.split(';').map((p) => p.trim().split('='));
  const ts = pairs.find(([k]) => k === 'ts')?.[1];
  const sigs = pairs.filter(([k]) => k === 'h1').map(([, v]) => v);
  if (!ts || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;
  const expected = createHmac('sha256', secret).update(`${ts}:${rawBody}`).digest();
  return sigs.some((h) => {
    const got = Buffer.from(h, 'hex');
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}
```

## Lemon Squeezy

- Header: `X-Signature` = hex HMAC-SHA256 of the raw body, keyed with the signing secret
  you set on the webhook. No timestamp in the scheme; replay protection comes from
  deduplication.
- Event name: `X-Event-Name` header and `meta.event_name`. Custom checkout data:
  `meta.custom_data`.
- Dedupe key: there is no top-level event ID in every payload; use a hash of
  `event_name + data.id + data.attributes.updated_at`, or the webhook delivery record if
  your framework exposes one.
- Events: `order_created`, `order_refunded`, `subscription_created`,
  `subscription_updated`, `subscription_cancelled`, `subscription_resumed`,
  `subscription_expired`, `subscription_paused`, `subscription_unpaused`,
  `subscription_payment_success`, `subscription_payment_failed`,
  `subscription_payment_recovered`, `subscription_payment_refunded`,
  `license_key_created`, `license_key_updated`, `customer_updated`.
- Treat `subscription_updated` as the catch-all state sync; it fires for most changes.

```js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyLemon(rawBody, header, secret) {
  const expected = createHmac('sha256', secret).update(rawBody).digest();
  const got = Buffer.from(header ?? '', 'hex');
  return got.length === expected.length && timingSafeEqual(got, expected);
}
```

License API (base `https://api.lemonsqueezy.com`, form-encoded POST,
`Accept: application/json`, no API key required, about 60 requests per minute):

| Call | Path | Fields |
|---|---|---|
| Activate | `/v1/licenses/activate` | `license_key`, `instance_name` -> returns `instance.id` |
| Validate | `/v1/licenses/validate` | `license_key`, optional `instance_id` |
| Deactivate | `/v1/licenses/deactivate` | `license_key`, `instance_id` |

Check that the response's `meta.store_id` and `meta.product_id` match your product, or
keys from any other Lemon Squeezy store would validate.

## Polar

- Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature` (Standard Webhooks
  layout). Dedupe on `webhook-id`.
- Secrets created after Polar's switch to the Standard Webhooks scheme are passed to a
  Standard Webhooks library as-is. Older secrets were plain HMAC keys over the UTF-8 bytes
  of the `whsec_...` string, so base64-encode the whole string before handing it to a
  Standard Webhooks verifier. Recent official SDKs try both.
- SDK helper: `validateEvent(body, headers, secret)` from the SDK's webhooks module (TS
  and Python), raising a verification error; the import path is versioned in newer SDK
  releases, so copy it from the installed SDK's docs. Return 403 on verification failure;
  return 202 for event types your SDK does not know so Polar stops retrying.
- Delivery: about 10 s timeout, respond within 2 s, retries with backoff, redirects count
  as failures, and the endpoint is disabled after repeated consecutive failures.
- Key events: `order.paid` (fulfil), `subscription.active`, `subscription.updated`,
  `subscription.canceled` (cancellation scheduled), `subscription.revoked` (access ends),
  `benefit_grant.created`, `benefit_grant.revoked`, `customer.state_changed`.
- Prefer benefits for access control: grant on `benefit_grant.created`, revoke on
  `benefit_grant.revoked`, so trials, grace periods, and refunds follow Polar's logic.

License keys (benefit type "License Keys", base `https://api.polar.sh`, JSON):

| Call | Path | Fields |
|---|---|---|
| Activate | `POST /v1/customer-portal/license-keys/activate` | `key`, `organization_id`, `label`, optional `conditions`, `meta` |
| Validate | `POST /v1/customer-portal/license-keys/validate` | `key`, `organization_id`, `activation_id` when activation limits are on |

Activation is only needed when the benefit sets an activation limit. Deactivation is
offered through the customer portal; check the API reference for a programmatic path
before promising one.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc