mcpField
P
paddle-sandbox-testing

paddle-sandbox-testing

github.com/paddlehqTesting & quality9116 installsUpdated 6d ago
Repository

About paddle-sandbox-testing

paddle-sandbox-testing is a skill for developers and agents validating Paddle integrations before launch. It covers sandbox setup, test payments, webhook simulations, and environment differences without charging real customers.

Key features

  1. 01Sandbox and production setup guidance
  2. 02Test card scenarios
  3. 03Single-event webhook simulations
  4. 04Subscription lifecycle scenarios
  5. 05Local testing with public tunnels

Use cases

  1. 01Verify checkout behavior before launch
  2. 02Test webhook handlers in isolation
  3. 03Exercise subscription renewal and cancellation
  4. 04Check failure and dunning handling

MCP support

When a Paddle MCP server is available, the skill describes using its search and execute tools to create simulations and run them programmatically.

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/paddlehq/paddle-agent-skills --skill paddle-sandbox-testing

After installing, type/paddle-sandbox-testingin Claude Code to confirm it loaded.

Details

From the project README

Test a Paddle integration in the sandbox

When to use this skill

Use this skill when verifying a Paddle integration before going live — running real-looking checkouts, triggering webhook events, and exercising subscription state changes without taking real money. Covers sandbox configuration, test card numbers, the webhook simulator (single events and scenarios), local development with public tunnels, and the differences between sandbox and live behavior that bite people in production.

This skill is the safety net for everything else. Apply it alongside checkout-web, webhooks, and subscription-sync whenever you ship — sandbox testing is the cheapest way to catch integration bugs.

Sandbox vs production at a glance

Paddle gives you two completely separate environments:

SandboxProduction
PurposeDevelopment and testingReal customers, real money
API basesandbox-api.paddle.comapi.paddle.com
Dashboardsandbox-vendors.paddle.comvendors.paddle.com
API keysSeparate setSeparate set
Client tokensSeparate setSeparate set
Notification destinationsSeparate setSeparate set
Checkout and account settingsSeparate setSeparate set
Products / prices / customersSeparate (none synced)—
Cards acceptedTest cards only (real cards rejected)Real cards
RefundsApproved automatically (~10 minutes)Most reviewed by Paddle
TaxCalculated but not collectedReal tax handling
Domain approvalNo approval required (any domain)Manual approval per domain

Nothing crosses between them. A sandbox pri_01h... does not exist in production. Notification destinations are separate. Customers are separate. This is intentional — it makes "test in sandbox, then promote to live" the safe default.

Step 1: Configure sandbox

Sign up for the sandbox at sandbox-login.paddle.com/signup (separate signup from production). Then:

  1. Set up at least one product with one price — see catalog-setup if you haven't done this yet.
  2. Go to Paddle > My account > Settings > Authentication and create:
    • A client-side token (on the Client-side tokens tab) for use in Paddle.js.
    • A server-side API key (on the API keys tab) for the Node SDK. Give it the permissions this skill's API and MCP calls use: notification_simulation.write and notification_simulation.read (simulator), notification.read and notification.write (notification logs and replays), client_token.write if you create client-side tokens by API, and checkout_settings.write to check and set the default payment link. To use the Paddle CLI with this key, ask the user to log it in to sandbox themselves: paddle login --environment sandbox --api-key ….
  3. Check the sandbox default payment link is set — checkout won't open without one. If a Paddle MCP server is available, call client.settings.checkoutUrl.get() (GET /settings/checkout-url) on paddle-sandbox; a null default_url means it isn't set. Use a page on the user's own domain, not localhost. Sandbox doesn't need an approved domain. If the update returns checkout_url_domain_not_approved, add the domain under Paddle > My account > Settings > Website approval, where sandbox approves it straight away, and try again. Ask the user for the URL before setting it with client.settings.checkoutUrl.update({ default_url: "https://example.com/checkout" }). With the Paddle CLI, use paddle --environment sandbox settings checkout-url get and … settings checkout-url update --json '{"default_url": "…"}'. Or have them set it under Paddle > Checkout > Checkout configuration.
  4. Note the IDs and set them in your local env:
# .env.local — sandbox values
NEXT_PUBLIC_PADDLE_ENV=sandbox
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_...
PADDLE_API_KEY=pdl_sdbx_apikey_...
PADDLE_NOTIFICATION_WEBHOOK_SECRET=pdl_ntfset_...

The PADDLE_API_KEY for sandbox is prefixed pdl_sdbx_apikey_ — production keys are prefixed pdl_live_apikey_. Glance at the prefix anytime you're not sure which environment you're hitting.

Step 2: Use test cards

In sandbox, only test cards work. Real cards are rejected. The standard test card:

Card number: 4242 4242 4242 4242
Expiry:      any future month/year
CVC:         any 3 digits
ZIP/Postal:  any value
Name:        any value

Other test cards trigger specific scenarios:

Card numberResult
4242 4242 4242 4242Valid card without 3DS
4000 0038 0000 0446Valid card with 3DS
4000 0566 5566 5556Valid Visa debit card
4000 0000 0000 0002Declined card
4000 0027 6000 3184Initial success, subsequent renewals decline (dunning)

Full list at developer.paddle.com/sdks/sandbox#test-cards. Use the failure cards to confirm your checkout.payment.failed handling (it fires on each failed payment attempt), dunning flow, and subscription.past_due handling work end-to-end. checkout.payment.error is different: it means no payment methods are available.

Step 3: The webhook simulator

The simulator sends fake-but-correctly-signed webhook events to one of your notification destinations without going through a real flow. Simulated events don't change any real data in your account. Two modes:

Single event. Pick one event type (e.g. subscription.canceled) — the simulation type is the event name. Edit the JSON payload or let Paddle fill in a demo example, then run it. Paddle signs it with the destination's secret and POSTs it to the destination's URL. Use this to test specific handlers in isolation — e.g. "what does my UI do when subscription.paused arrives?"

Scenario. A pre-defined sequence of events that mirrors a real flow:

Scenario typeEvents (default options)
subscription_creationtransaction.created, customer.created, address.created, transaction.ready, transaction.paid, subscription.created, subscription.activated (or subscription.trialing), transaction.completed, payment_method.saved — with transaction.updated between steps
subscription_renewalsubscription.updated, transaction.created, transaction.billed, transaction.updated, transaction.paid, transaction.updated, transaction.completed
subscription_pausesubscription.updated, subscription.paused
subscription_resumesubscription.updated, subscription.resumed, transaction.created, transaction.billed, then the same payment events as a renewal
subscription_cancellationsubscription.updated, subscription.canceled

Options change the sequence. Pause and cancellation default to effective_from: "immediately"; "next_billing_period" adds a subscription.updated with scheduled_change first. Renewal and resume take a payment_outcome — "failed" swaps the payment events for transaction.payment_failed, transaction.past_due and subscription.past_due. Add real sandbox entities (e.g. a subscription_id) to fill payloads with their details; the entities themselves aren't changed, but the API key needs read permission for them (e.g. subscription.read).

Find the simulator at Paddle > Events > Simulations. If a Paddle MCP server is available to you, you can drive the simulator programmatically inside one execute call instead of using the dashboard — create the simulation, then create a run against it:

async (client) => {
  const sim = await client.simulations.create({
    name: "Subscription created flow",
    type: "subscription_creation",
    // Required: the destination that receives the simulated events.
    notification_setting_id: "ntfset_...",
  });
  const run = await client.simulations.runs.create(sim.id);
  return { simulation_id: sim.id, run_id: run.id };
}

Note client.simulations.runs.create is nested under simulations (not a top-level client.simulationRuns), and the simulation ID is a positional path param. Scenario options go in config, keyed by the scenario type — e.g. config: { subscription_cancellation: { entities: { subscription_id: "sub_..." }, options: { effective_from: "next_billing_period" } } }.

The Paddle MCP exposes three tools per server (search, execute, report_missing_tool). Workflow: call search to confirm the exact method name and parameter shapes, then call execute with an async function that calls client.<resource>.<operation>(...). Method paths are camelCase (client.clientTokens.create, client.pricingPreview.preview). Body params and response fields are snake_case (tax_category, product_id, unit_price, currency_code). List methods return { pagination: { hasMore, estimatedTotal }, <resource>: [...] }, not data. The array's key is the resource's API path segment, so it's kebab-case where the method path is camelCase: (await client.products.list()).products, (await client.clientTokens.list())["client-tokens"]. Page with { after: "<last_id>" } while pagination.hasMore is true, not .next(). Lists that aren't paginated, like client.currencies.list(), return a plain array. Single-entity methods return the entity. Chain multi-step workflows inside one execute; variables don't persist between calls. Hard caps: 50 API calls per execute, 30s timeout, and 32,000 characters of code.

The Paddle CLI (paddle, v0.2.0 or later) runs Paddle API operations from the terminal with the user's saved login. Resource commands mirror the API reference: paddle products create is "create a product". Run paddle <resource> --help to find a command, add --schema to a create or update command to see its request body, and use paddle api <path> for an endpoint without a command. It starts in the live account, so pass --environment sandbox unless the user is working in live. When an agent runs it, it returns the API's JSON (data, meta), and meta.environment names the account it used. Cancel and delete commands need --confirm <id>. Add it only after the user has agreed to that specific cancel or delete, and never use it to work around a refusal. paddle api writes aren't checked at all, so treat them the same way. Exit code 5 is a 403: a missing permission, or a login for the other account. Exit code 7 is a validation error. If paddle whoami shows no login, ask the user to run paddle login themselves (sandbox takes an API key: paddle login --environment sandbox --api-key …), and never ask for a key in the chat. A live login with OAuth can change anything the user's Paddle role allows, so name each live change and wait for a yes before you run it. For CI/CD, set a live API key with only the permissions it needs as PADDLE_API_KEY. To install it, see Paddle CLI.

With the Paddle CLI, run the same steps as separate commands. The body is the same snake_case JSON:

paddle --environment sandbox simulations create --jq '.data.id' --json '{
  "name": "Subscription created flow",
  "type": "subscription_creation",
  "notification_setting_id": "ntfset_..."
}'
paddle --environment sandbox simulation-runs create ntfsim_...

To use the simulator:

  1. In the dashboard, go to Paddle > Events > Simulations.
  2. Pick a scenario or single event.
  3. Choose the notification destination to send to. You pick an existing destination rather than typing a URL, so point that destination at your tunnelled local URL or a deployed preview URL first.
  4. (Optional) For a single event, edit the payload JSON to use specific IDs (e.g. a customer_id you've already created in your DB). For a scenario, add real sandbox entities.
  5. (Optional) Configure your simulation (e.g. set the payment outcome to failed to simulate a failed payment).
  6. Run it. Watch your local server logs.

The destination must have Usage type "Simulation" or "Both" (traffic_source: "simulation" or "all") — "Platform" destinations can't be used. It must also be active and subscribed to each event type in the simulation; any event it can't receive is marked aborted and not sent. A "Simulation" destination gets no real events, so use "Both" for a local destination you also use for real checkouts.

Each simulated event gets one delivery attempt, with no retries. Check results on the run in Paddle > Events > Simulations, or with client.simulations.runsEvents.list(simulationId, runId) — each event has a status (success, failed, aborted, pending) plus the request and response. Resend one with client.simulations.runsEvents.replay(simulationId, runId, eventId). With the CLI, use paddle simulation-run-events list <simulation_id> <run_id> and paddle simulation-run-events replay <simulation_id> <run_id> <event_id>.

The signature on a simulated event uses the destination's secret, so your normal verification code works without changes. If verification fails, the secret you've set in PADDLE_NOTIFICATION_WEBHOOK_SECRET doesn't match the destination you're simulating against.

Run simulations in CI

To check a deployed preview on every change, run a simulation from CI with the Paddle CLI. Create the simulation once, pointed at a destination for the preview URL, then start a new run in each build and fail if any event wasn't delivered. Store a sandbox API key with the simulator permissions as a CI secret, and never commit it:

# CI secrets and variables: PADDLE_API_KEY (sandbox key), PADDLE_SIMULATION_ID (ntfsim_...)
export PADDLE_ENVIRONMENT=sandbox
RUN_ID=$(paddle simulation-runs create "$PADDLE_SIMULATION_ID" --jq '.data.id')
while [ "$(paddle simulation-runs get "$PADDLE_SIMULATION_ID" "$RUN_ID" --jq '.data.status')" = pending ]; do
  sleep 5
done
FAILED=$(paddle simulation-run-events list "$PADDLE_SIMULATION_ID" "$RUN_ID" \
  --jq '[.data[] | select(.status != "success")] | length')
[ "$FAILED" = 0 ] || { echo "$FAILED simulated events weren't delivered"; exit 1; }

Step 4: Tunnel localhost for real events

The simulator covers most testing. For real flows (e.g. completing an actual sandbox checkout to verify the full chain), Paddle needs to reach your local machine. Use a tunnel:

Hookdeck (recommended):

# Install: brew install hookdeck
hookdeck listen 3000 paddle --path /api/webhook
# Copy the source URL it prints ("Requests to → https://...")

paddle is the Hookdeck source name and --path is your webhook route. Point a sandbox notification destination at the source URL, with Usage type "Both" so it gets real and simulated events. The source URL stays the same when you restart the CLI, so you only set it once. Hookdeck keeps your event history between sessions, so if events arrived while the CLI was stopped, replay them from Hookdeck or Paddle.

Debug delivery. For real events, open the notification in Paddle > Events > Notifications, or use client.notifications.logs.list("ntf_...") to see each delivery attempt and client.notifications.replay("ntf_...") to send it again. For simulated events, check the simulation run's events (see Step 3).

Step 5: A typical end-to-end test

Here's the loop for verifying a fresh integration:

  1. Tunnel localhost: hookdeck listen 3000 paddle --path /api/webhook.
  2. Point the sandbox destination at the tunnel's source URL (once — it doesn't change between restarts).
  3. Open your pricing page → click Subscribe.
  4. Pay with 4242 4242 4242 4242.
  5. Confirm:
    • Browser lands on your successUrl.
    • Server logs show all events
    • Your DB has the new customer and subscription rows (if you've wired subscription-sync).
    • The dashboard shows the transaction under Transactions.
  6. Cancel the subscription, one of two ways:
    • Real: cancel the sandbox subscription you just created, using the API or the customer portal (see subscription-cancel).
    • Simulated: run a subscription_cancellation simulation with that subscription's ID in entities.subscription_id and effective_from: "next_billing_period". This only sends events whose payloads use the subscription's details — the real subscription stays active, so your DB and Paddle will disagree afterwards.
  7. Confirm:
    • Real, effective_from: "next_billing_period": a subscription.updated event arrives with scheduled_change.action: "cancel" and status still active. subscription.canceled only arrives when the billing period ends.
    • Real, effective_from: "immediately": a subscription.canceled event arrives with status canceled; your row should flip to canceled.
    • Simulated: a subscription.updated with scheduled_change, then subscription.updated and subscription.canceled with status canceled, all in the same run.

Sandbox vs live: differences that may catch you out

These behave differently in sandbox and live.

BehaviorSandboxLiveWhy it matters
Adjustments (refunds/credits)Refunds are created pending_approval and approved automatically within about 10 minutes. Credits are approved straight away.Most refunds stay pending_approval until Paddle reviews them. Credits are approved straight away.Don't treat a refund as done on adjustment.created — handle adjustment.updated moving status from pending_approval to approved (or rejected).
Webhook retries3 attempts in total, all within about two minutesUp to 60 attempts in total over about two daysA failing sandbox handler gets no more attempts after about two minutes — fix it, then replay the event. Don't write tests that rely on exact timing.
PayoutsNo payoutsReal money to your bankTest payout webhook handling against simulated payouts only.
Domain approvalCheckout opens on any page, including localhost, and domains are approved as soon as you add them. The default payment link doesn't need an approved domain, but use a page on your own domain, not localhostApprove each domain in Paddle > My account > Settings > Website approval. Your default payment link (Paddle > Checkout > Checkout configuration) must be on an approved domain.Forgetting domain approval means production checkout fails to load. Check each domain's status with GET /checkout-domains.
Checkout "test mode" flagShown on checkoutsNot shownA quick visual check of which environment a checkout is using.
Dunning (Retain)Not availableAvailableRetain handles canceling or pausing past due subscriptions.
Email deliveryOnly sent to addresses on your account email's domain. Emails to other addresses go to your account email instead.Sent to the email on the customer record (real)You receive the emails for every test customer, so check your own inbox.

Common pitfalls

  • Mixing sandbox and live keys. Always confirm PADDLE_API_KEY and NEXT_PUBLIC_PADDLE_ENV are for the correct environment.
  • Sandbox environment param in production. Always confirm the environment parameter is set to production when initializing Paddle.js in production.
  • Tunnel URL changed after a restart. Tunnels that hand out a new URL each time (e.g. a free ngrok URL) need the destination URL updated after every restart. Symptoms: events fire on Paddle's side but never reach your code. A Hookdeck source URL stays the same.
  • Simulated events marked aborted. The destination's Usage type isn't "Simulation" or "Both", it's inactive, or it isn't subscribed to that event type.
  • Testing with a real card "just to be sure". Sandbox rejects them. Production accepts them and charges them.
  • Not testing the failure path. Use 4000 0000 0000 0002 to confirm checkout.payment.failed handling, then use the simulator to test a failed subscription renewal (payment_outcome: "failed").
  • Using the same notification destination secret in code for both environments. They are not the same. Each environment, each destination, has its own secret.
  • Testing only the happy path. Real users may hit weird states — declined cards, expired trials, immediate cancellations. Use the simulator to fire each edge case and watch your code respond.

Related docs

Comments

Markdown supported