Paddle and Lemon Squeezy skill for Codex

Sell worldwide and let the MoR handle the VAT. Codex integrates a merchant of record so the provider, not your company, sells to the customer and collects tax. It passes your user ID through checkout custom data, verifies Paddle-Signature and X-Signature webhooks on the raw body, mirrors subscriptions locally, and ships a stdlib script that checks a captured webhook offline to tell a wrong secret from a mangled body.

  • Integrations
  • $merchant-of-record-payments
  • 200 lines
  • 3 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- merchant-of-record-payments

Installs to ~/.agents/skills/merchant-of-record-payments/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $merchant-of-record-payments, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Integrates Paddle Billing or Lemon Squeezy as merchant of record for software and digital products - overlay or hosted checkout, passing your user ID through custom data, HMAC webhook verification, subscription state sync, customer portal links and sandbox testing. Use when selling SaaS or downloads internationally without handling sales tax or VAT yourself, choosing between Paddle, Lemon Squeezy and Stripe, wiring Paddle.js or Lemon.js checkout, or debugging Paddle-Signature or X-Signature webhook failures.

Use it when

  • Selling SaaS, licences or downloads internationally without a tax team
  • Wiring Paddle.js or Lemon.js checkout to your user accounts
  • Paddle or Lemon Squeezy webhooks fail verification or arrive out of order
  • Comparing a merchant of record with running Stripe yourself

Use something else when

How to check the result

A sandbox purchase creates one local subscription row tied to the right user, a resent webhook changes nothing, and the bundled script validates a captured payload with the deployed secret.

The mistake it prevents

Leaving out custom data at checkout, then matching webhooks to users by email, which breaks as soon as a buyer pays with a different address.

~/.agents/skills/merchant-of-record-payments/SKILL.md

--- name: merchant-of-record-payments description: Integrates Paddle Billing or Lemon Squeezy as merchant of record for software and digital products - overlay or hosted checkout, passing your user ID… license: MIT ---

Done well means: the buyer pays the merchant of record (MoR), which handles tax and invoicing; your app learns about every purchase and subscription change only from verified webhooks; each event maps back to your user through custom data you set at checkout; and the flow has been run end to end in the provider’s sandbox or test mode.

Whether an MoR removes the user’s own tax obligations depends on their jurisdiction and contracts. Say that an accountant should confirm it; do not promise “no tax work”.

Decide first

SituationChoice
Selling software or digital goods worldwide, small team, no tax opsMoR (Paddle or Lemon Squeezy)
Physical goods, marketplaces, or custom payment flowsNot an MoR; use $stripe-payments or $stripe-connect
Need the lowest fees and already handle tax (or use Stripe Tax)Stripe directly, $stripe-billing
Mature B2B SaaS, invoicing, many currencies and localised pricesPaddle Billing
Indie product, downloads, licence keys, quick storefrontLemon Squeezy

Paddle Classic and Paddle Billing are different APIs. Check which one the account uses (Billing uses IDs like pri_..., sub_..., txn_...); this skill targets Billing.

Before writing code

  1. Read AGENTS.md; find existing payment code, the user model and any plan fields.
  2. Confirm which environment the keys belong to: Paddle sandbox (sandbox-api.paddle.com, client tokens starting test_) versus live (api.paddle.com); Lemon Squeezy test mode versus live store.
  3. Store API keys and webhook secrets in env vars. Client-side tokens (Paddle client token) are public by design; API keys never reach the browser.
  4. Live refunds, cancellations and price changes need explicit user confirmation.

Installing SDKs and calling provider APIs need network access; in a Codex sandbox, ask for approval or stub the HTTP layer for tests.

Workflow

  • Create products and prices in the provider dashboard; put their IDs in config
  • Checkout opens with your user ID in custom data and the customer email prefilled
  • Webhook endpoint verifies the signature on the raw body, then dedupes by event ID
  • Handler upserts a local subscription or order row keyed by the provider’s ID
  • Access checks read the local row
  • Customer portal or update-payment link exposed in the app
  • Sandbox run: purchase, renewal or simulated event, cancel, refund

Paddle Billing

Checkout with Paddle.js:

<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script>
  Paddle.Environment.set("sandbox");              // remove for live
  Paddle.Initialize({ token: "test_xxx" });       // client-side token
  function buy() {
    Paddle.Checkout.open({
      items: [{ priceId: "pri_01h...", quantity: 1 }],
      customer: { email: currentUser.email },
      customData: { userId: currentUser.id },      // returned on transaction and subscription
    });
  }
</script>

Webhook verification: header Paddle-Signature: ts=1700000000;h1=<hex>. The signed payload is ts + ":" + rawBody, HMAC-SHA256 with the notification destination’s secret key.

import { Paddle, Environment, EventName } from "@paddle/paddle-node-sdk";
const paddle = new Paddle(process.env.PADDLE_API_KEY!, { environment: Environment.sandbox });

app.post("/webhooks/paddle", express.raw({ type: "application/json" }), async (req, res) => {
  const signature = req.header("paddle-signature") ?? "";
  let event;
  try {
    event = await paddle.webhooks.unmarshal(req.body.toString(), process.env.PADDLE_WEBHOOK_SECRET!, signature);
  } catch {
    return res.sendStatus(400);
  }
  if (await seen(event.eventId)) return res.sendStatus(200);
  switch (event.eventType) {
    case EventName.SubscriptionCreated:
    case EventName.SubscriptionUpdated:
    case EventName.SubscriptionCanceled:
      await upsertSubscription(event.data);       // status, items, next_billed_at, custom_data
      break;
    case EventName.TransactionCompleted:
      await recordPayment(event.data);
      break;
  }
  res.sendStatus(200);
});

Customer portal: create an authenticated link server-side with POST /customers/{customer_id}/portal-sessions and redirect; do not build your own card form. Plan changes: PATCH /subscriptions/{id} with new items and an explicit proration_billing_mode (for example prorated_immediately), previewed first with PATCH /subscriptions/{id}/preview.

Lemon Squeezy

Checkout: either a store checkout URL with custom data in the query string, or an API-created checkout. Load Lemon.js for the overlay.

https://<store>.lemonsqueezy.com/buy/<variant-uuid>?checkout[email][email protected]&checkout[custom][user_id]=123&embed=1
// API-created checkout (JSON:API)
const res = await fetch("https://api.lemonsqueezy.com/v1/checkouts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LEMONSQUEEZY_API_KEY}`,
    Accept: "application/vnd.api+json",
    "Content-Type": "application/vnd.api+json",
  },
  body: JSON.stringify({
    data: {
      type: "checkouts",
      attributes: { checkout_data: { email: user.email, custom: { user_id: String(user.id) } } },
      relationships: {
        store: { data: { type: "stores", id: STORE_ID } },
        variant: { data: { type: "variants", id: VARIANT_ID } },
      },
    },
  }),
});
const url = (await res.json()).data.attributes.url;

Webhook verification: header X-Signature is the hex HMAC-SHA256 of the raw body with the webhook signing secret. The event name is in X-Event-Name and meta.event_name; your custom data comes back in meta.custom_data.

import crypto from "node:crypto";
const digest = crypto.createHmac("sha256", process.env.LS_WEBHOOK_SECRET!).update(rawBody).digest("hex");
const sig = req.header("x-signature") ?? "";
const ok = sig.length === digest.length && crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(sig));

Handle order_created, subscription_created, subscription_updated, subscription_payment_success, subscription_payment_failed, subscription_cancelled and subscription_expired. The subscription’s urls.customer_portal and urls.update_payment_method give signed links for self-service; they expire, so fetch fresh ones.

Offline signature check

When a webhook keeps failing, save the exact received body and header, then run:

WEBHOOK_SECRET=... python3 scripts/verify_webhook.py paddle --body body.json --signature 'ts=...;h1=...' --max-age 300
WEBHOOK_SECRET=... python3 scripts/verify_webhook.py lemonsqueezy --body body.json --signature '<x-signature>'
python3 scripts/verify_webhook.py --self-test

If the script says the signature is valid but your server rejects it, your framework altered the body before verification. If the script also fails, the secret is wrong (sandbox versus live, or a rotated secret).

Gotchas

  • Verifying a parsed-and-restringified body: whitespace changes, the HMAC fails.
  • Relying on the checkout success callback for access: closed tabs and ad blockers lose it.
  • Missing custom data: without userId in the event you must match by email, which breaks when buyers use a different address. Always set it.
  • Lemon Squeezy subscription status values include on_trial, active, paused, past_due, unpaid, cancelled, expired; cancelled still has access until ends_at.
  • Paddle subscription.canceled may arrive at the end of the period when cancellation was scheduled; read scheduled_change on updates to show “cancels on” dates.
  • Prices shown in your own UI can differ from checkout because the MoR adds local tax and currency; use the provider’s price preview APIs or show “plus applicable tax”.
  • Test-mode events in a live database: Lemon Squeezy marks them with meta.test_mode; reject or separate them.

Verification

  • Sandbox purchase creates exactly one local row tied to the right user via custom data.
  • Re-sending the same webhook from the dashboard leaves the row unchanged.
  • Cancelling in the portal updates the local row (access ends at the right date).
  • scripts/verify_webhook.py validates a captured payload with the deployed secret.

Report template

**Provider:** Paddle Billing (sandbox) | Lemon Squeezy (test mode)
**Checkout:** Paddle.js overlay, customData.userId set; prices pri_... in config/billing.ts
**Webhooks:** POST /webhooks/paddle, raw-body HMAC verified, deduped by event_id
**State:** subscriptions table (provider_id, status, next_billed_at, cancel_at)
**Tested:** purchase, resend, plan change preview, cancel via portal
**Needs user/accountant:** tax position confirmation, live keys, payout settings

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Paddle and Lemon Squeezy"
  short_description: "Sell worldwide and let the MoR handle the VAT"
  default_prompt: "$merchant-of-record-payments Add Paddle Billing checkout to our pricing page and unlock Pro when the subscription is created"
scripts/verify_webhook.py97 lines, 4.0 KB
#!/usr/bin/env python3
"""Verify a captured Paddle Billing or Lemon Squeezy webhook signature offline.

Usage:
  python3 verify_webhook.py paddle --body payload.json --signature 'ts=1700000000;h1=abc...' [--max-age 300]
  python3 verify_webhook.py lemonsqueezy --body payload.json --signature 'abc123...'
  python3 verify_webhook.py --self-test

The signing secret is read from the WEBHOOK_SECRET environment variable so it never lands in
shell history. The body file must contain the exact bytes received (no reformatting).
Exit code 0 = valid, 1 = invalid, 2 = usage error.
"""
import argparse
import hashlib
import hmac
import os
import sys
import time


def _hex_hmac(secret: str, message: bytes) -> str:
    return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()


def verify_paddle(body: bytes, header: str, secret: str, max_age: int | None, now: float | None = None):
    parts = {}
    for item in header.split(";"):
        if "=" in item:
            key, value = item.split("=", 1)
            parts.setdefault(key.strip(), []).append(value.strip())
    ts = (parts.get("ts") or [None])[0]
    candidates = parts.get("h1") or []
    if not ts or not candidates:
        return False, "header must contain ts=... and at least one h1=..."
    if max_age is not None:
        age = (now or time.time()) - int(ts)
        if abs(age) > max_age:
            return False, f"timestamp is {int(age)}s old (limit {max_age}s): possible replay"
    expected = _hex_hmac(secret, ts.encode() + b":" + body)
    if any(hmac.compare_digest(expected, c) for c in candidates):
        return True, "valid Paddle signature"
    return False, "no h1 value matches HMAC-SHA256(secret, ts + ':' + raw_body)"


def verify_lemonsqueezy(body: bytes, header: str, secret: str):
    expected = _hex_hmac(secret, body)
    if hmac.compare_digest(expected, header.strip()):
        return True, "valid Lemon Squeezy signature"
    return False, "X-Signature does not match HMAC-SHA256(secret, raw_body)"


def self_test() -> int:
    secret = "test_secret"
    body = b'{"event_type":"transaction.completed","data":{"id":"txn_01"}}'
    ts = "1700000000"
    good = f"ts={ts};h1={_hex_hmac(secret, ts.encode() + b':' + body)}"
    checks = [
        verify_paddle(body, good, secret, 300, now=1700000010)[0] is True,
        verify_paddle(body + b" ", good, secret, 300, now=1700000010)[0] is False,
        verify_paddle(body, good, secret, 300, now=1700009999)[0] is False,
        verify_lemonsqueezy(body, _hex_hmac(secret, body), secret)[0] is True,
        verify_lemonsqueezy(body, _hex_hmac("other", body), secret)[0] is False,
    ]
    print(f"self-test: {sum(checks)}/{len(checks)} checks passed")
    return 0 if all(checks) else 1


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("provider", nargs="?", choices=["paddle", "lemonsqueezy"])
    parser.add_argument("--body", help="file with the exact raw request body")
    parser.add_argument("--signature", help="Paddle-Signature or X-Signature header value")
    parser.add_argument("--max-age", type=int, default=None, help="Paddle only: reject older timestamps (seconds)")
    parser.add_argument("--self-test", action="store_true")
    args = parser.parse_args()

    if args.self_test:
        return self_test()
    if not (args.provider and args.body and args.signature):
        parser.print_usage(sys.stderr)
        return 2
    secret = os.environ.get("WEBHOOK_SECRET")
    if not secret:
        print("error: set WEBHOOK_SECRET to the endpoint signing secret", file=sys.stderr)
        return 2
    with open(args.body, "rb") as fh:
        body = fh.read()
    if args.provider == "paddle":
        ok, msg = verify_paddle(body, args.signature, secret, args.max_age)
    else:
        ok, msg = verify_lemonsqueezy(body, args.signature, secret)
    print(("OK: " if ok else "FAIL: ") + msg)
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc