These docs are a work in progress and may not be fully up to date. Some pages may contain internal notes for our team.
Skip to Content

Backend — billing (Paddle)

Paddle is the merchant of record. The backend exposes five authenticated billing endpoints for checkout, portal access, subscription sync, and invoice retrieval, plus one unauthenticated webhook receiver. All Paddle state is reflected on the User model via a set of paddle_* and subscription_* columns.

For the customer-facing description of plans, checkout UX, and the Paddle.js overlay, see content/integrations/billing-paddle.md 🔴 [NEEDS CLARIFICATION: confirm this page exists and is published before linking here]. For the third-party integration reference (env vars, config keys, outbound HTTP), see ../../integration-points/backend-to-third-party.md (session 7).

Environment and configuration

Paddle credentials are in config/services.php under the paddle key (config/services.php:60-68):

Config keyEnv varPurpose
services.paddle.environmentPADDLE_ENVIRONMENTsandbox or production
services.paddle.api_keyPADDLE_API_KEYBearer token for outbound Paddle REST calls
services.paddle.client_tokenPADDLE_CLIENT_TOKENPassed to the frontend for Paddle.js initialisation
services.paddle.webhook_secretPADDLE_WEBHOOK_SECRETHMAC secret for webhook signature verification
services.paddle.bronze_price_idPADDLE_BRONZE_PRICE_IDPaddle Price ID for the Bronze plan
services.paddle.premium_price_idPADDLE_PREMIUM_PRICE_IDPaddle Price ID for the Premium plan
services.paddle.base_urlPADDLE_API_BASE_URLOverride for sandbox vs. production API root (https://api.paddle.com)

All outbound requests use Laravel’s HTTP client via the protected paddleRequest() helper (BillingController.php:607-617), which sets Accept: application/json, Authorization: Bearer {api_key}, and Paddle-Version: 1.

BillingController — authenticated endpoints

Defined in app/Http/Controllers/BillingController.php. All five authenticated routes sit under the auth:api, auth_session, audit middleware group (routes/api.php:139-143):

POST /api/billing/checkout

Creates a Paddle transaction (checkout session). Validates that the requested tier is one of bronze or premium (BillingController.php:19-23). Builds a transaction payload with:

  • items[0].price_id — resolved from $tier via priceIdForTier() (a match against the configured price IDs)
  • custom_data.user_id and custom_data.subscription_tier — used by the webhook handler to resolve the user when no customer_id exists yet
  • collection_mode: automatic
  • customer_id — included only if $user->paddle_customer_id is already set (BillingController.php:48-50)

Returns {"transactionId": "txn_..."}. The frontend passes this to Paddle.Checkout.open({ transactionId }) via the Paddle.js overlay.

POST /api/billing/portal

Opens the Paddle customer portal for subscription management. Requires $user->paddle_subscription_id to exist; returns 422 otherwise. Fetches the subscription from Paddle and returns the first non-null of management_urls.update_payment_method or management_urls.cancel (BillingController.php:112-118). Returns {"url": "https://..."}.

POST /api/billing/sync

Manual subscription sync endpoint. Called by the frontend immediately after checkout to ensure the user’s tier is reflected before the next page load, since webhook delivery may lag by a few seconds. Calls findSubscriptionForUser() which queries Paddle by paddle_subscription_id first, then falls back to listing subscriptions by customer_id. If a subscription is found, calls syncUserSubscription() and returns {"synced": true, "user": {...}}. Returns 202 with {"synced": false} if no subscription is found yet (BillingController.php:69-95).

GET /api/billing/invoices

Returns the 12 most recent Paddle transactions for the authenticated user’s customer. Resolves the customer ID from $user->paddle_customer_id or falls back to a Paddle /customers?email= lookup. Returns a shaped array:

{ "invoices": [ { "id": "txn_...", "status": "completed", "currency_code": "USD", "total": "29.00", "billed_at": "2026-04-01T00:00:00+00:00" } ] }

BillingController.php:127-156. Amounts are returned as decimal strings (Paddle stores amounts in the smallest currency unit; formatPaddleAmount() divides by 100 and formats to two decimal places).

GET /api/billing/invoices/{transactionId}/pdf

Fetches a signed PDF URL for a specific transaction invoice. Verifies that the transaction’s customer_id matches the authenticated user’s resolved customer ID before fetching (BillingController.php:167-168). Returns {"url": "https://..."}.

Webhook receiver — POST /api/webhooks/paddle

Route: routes/api.php:29 — registered outside all middleware groups. No authentication, rate limiting, or CSRF protection is applied at the framework level; security is provided entirely by the HMAC signature check.

Route::post('webhooks/paddle', [BillingController::class, 'webhook']);

Note the route path is /api/webhooks/paddle (the api prefix is applied automatically by Laravel’s withRouting() api key).

Signature verification

hasValidSignature() (BillingController.php:568-605) implements Paddle’s v1 webhook signature scheme:

  1. Reads the Paddle-Signature header, which has the form ts=<unix_timestamp>;h1=<hex_hash> (multiple h1 values are supported).
  2. Rejects if PADDLE_WEBHOOK_SECRET is empty or the header is absent.
  3. Rejects if abs(time() - $timestamp) > 300 — a 5-minute replay window.
  4. Computes hash_hmac('sha256', "{$timestamp}:{$rawBody}", $secret).
  5. Uses hash_equals() (constant-time comparison) against each h1 value.

⚠️ Warning: If PADDLE_WEBHOOK_SECRET is not set in the environment, every webhook request returns 403. Verify this env var is present in both sandbox and production environments.

Deduplication

Before processing, the handler checks paddle_webhook_events for a row with the same event_id (BillingController.php:203-206). Duplicates return {"received": true, "duplicate": true} without reprocessing. The PaddleWebhookEvent row is written after processing, not before, so duplicate detection is not strictly atomic — simultaneous delivery of the same event could result in double-processing. In practice Paddle does not deliver the same event simultaneously, so this is acceptable.

Event routing

syncBillingEvent() (BillingController.php:221-232) routes each event to either syncSubscription() or syncTransaction() based on the event type.

Subscription events (8 types)

Handled by syncSubscription()syncUserSubscription() (BillingController.php:234-244, 291-336):

Event typePaddle statusOutcome
subscription.createdFirst write of Paddle IDs to user
subscription.trialingtrialingsubscription_status = paid, tier set from price ID, trial_ends_at = next_billed_at
subscription.activatedactivesubscription_status = paid, tier set from price ID
subscription.updatedactive or otherRe-derives tier and status from current subscription state
subscription.past_duepast_duesubscription_status = unpaid, paddle_last_payment_failed_at stamped
subscription.pausedpausedsubscription_status = unpaid, tier falls back to free
subscription.resumedactiveMirrors subscription.activated
subscription.canceledcanceledsubscription_status = unpaid, tier → free, paddle_canceled_at and paddle_cancellation_source set

User resolution for subscription events (resolveUserForSubscription(), BillingController.php:421-442): tries custom_data.user_id first, then paddle_customer_id column lookup, then paddle_subscription_id column lookup.

Transaction events (3 types)

Handled by syncTransaction() (BillingController.php:247-289):

Event typeOutcome
transaction.paidsubscription_status = paid, tier set from price ID, paddle_last_payment_failed_at cleared
transaction.completedSame as transaction.paid
transaction.payment_failedsubscription_status = unpaid, tier → free, paddle_last_payment_failed_at stamped

If the transaction has a subscription_id, syncTransaction() also fetches the full subscription object from Paddle and calls syncUserSubscription() to ensure all subscription fields are current (BillingController.php:280-288).

User resolution for transaction events (resolveUserForTransaction(), BillingController.php:444-465): same priority chain — custom_data.user_id, paddle_customer_id, paddle_subscription_id.

Staleness guard

isOlderThanLastBillingEvent() (BillingController.php:549-553) compares the event’s occurred_at timestamp against $user->paddle_last_event_at. If the incoming event is older, syncSubscription() / syncTransaction() return without writing. This prevents out-of-order webhook delivery from rolling back a more recent subscription state.

paddle_webhook_events table

After processing, a PaddleWebhookEvent row is created (BillingController.php:209-217). The PaddleWebhookEvent model (app/Models/PaddleWebhookEvent.php) stores:

ColumnPurpose
event_idPaddle’s globally unique event identifier (dedup key)
event_typee.g. subscription.activated
occurred_atPaddle event timestamp
processed_atServer time when the row was written
user_idResolved user ID (nullable if resolution failed)
payload_summaryJSON — {event_type, paddle_id, status, customer_id, subscription_id, price_id} (BillingController.php:556-566)

The full raw webhook payload is not stored. payload_summary captures enough to diagnose billing issues without storing PII-heavy transaction data.

User model billing fields

The following columns on the User model reflect Paddle state (app/Models/User.php:61-80):

ColumnSet by
paddle_customer_idSubscription/transaction sync
paddle_subscription_idSubscription sync
paddle_price_idSubscription/transaction sync
paddle_subscription_statusSubscription sync (raw Paddle status string)
paddle_last_payment_statusTransaction sync
paddle_last_payment_failed_atTransaction sync on payment_failed / past_due
paddle_scheduled_cancellation_atSubscription sync when scheduled_change present
paddle_canceled_atSubscription sync on cancellation
paddle_cancellation_sourceinternal or external (set once; not overwritten on replay)
paddle_last_event_atUpdated on every processed event (staleness guard)
trial_ends_atSubscription sync when status is trialing
subscription_statuspaid or unpaid — the application-level gate
subscription_tierfree, bronze, premium, or custom — drives feature access

subscription_status and subscription_tier are the two columns used by EnsureSubscriptionTier middleware and JWT custom claims. The paddle_* columns are Paddle-specific bookkeeping only.

Tier resolution

resolveTierFromPriceId() (BillingController.php:467-477) maps Paddle Price IDs to tier constants:

  • PADDLE_BRONZE_PRICE_IDUser::SUBSCRIPTION_TIER_BRONZE
  • PADDLE_PREMIUM_PRICE_IDUser::SUBSCRIPTION_TIER_PREMIUM
  • Any other price ID → User::SUBSCRIPTION_TIER_NONE (treated as unprovisionable)

A subscription is considered “provisionable” only when its status is active or trialing and its resolved tier is not none or free. Any other combination forces subscription_status = unpaid and subscription_tier = free.

Subscription tiers (User model constants)

ConstantValueDescription
SUBSCRIPTION_TIER_NONEnoneUnrecognised price ID; not provisioned
SUBSCRIPTION_TIER_FREEfreeDefault; no paid subscription
SUBSCRIPTION_TIER_BRONZEbronzeBronze paid plan
SUBSCRIPTION_TIER_PREMIUMpremiumPremium paid plan
SUBSCRIPTION_TIER_CUSTOMcustomManually set for enterprise accounts

User::booted() sets new users to subscription_status = unpaid and subscription_tier = free (User.php:32-38).