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 key | Env var | Purpose |
|---|---|---|
services.paddle.environment | PADDLE_ENVIRONMENT | sandbox or production |
services.paddle.api_key | PADDLE_API_KEY | Bearer token for outbound Paddle REST calls |
services.paddle.client_token | PADDLE_CLIENT_TOKEN | Passed to the frontend for Paddle.js initialisation |
services.paddle.webhook_secret | PADDLE_WEBHOOK_SECRET | HMAC secret for webhook signature verification |
services.paddle.bronze_price_id | PADDLE_BRONZE_PRICE_ID | Paddle Price ID for the Bronze plan |
services.paddle.premium_price_id | PADDLE_PREMIUM_PRICE_ID | Paddle Price ID for the Premium plan |
services.paddle.base_url | PADDLE_API_BASE_URL | Override 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$tierviapriceIdForTier()(amatchagainst the configured price IDs)custom_data.user_idandcustom_data.subscription_tier— used by the webhook handler to resolve the user when nocustomer_idexists yetcollection_mode: automaticcustomer_id— included only if$user->paddle_customer_idis 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:
- Reads the
Paddle-Signatureheader, which has the formts=<unix_timestamp>;h1=<hex_hash>(multipleh1values are supported). - Rejects if
PADDLE_WEBHOOK_SECRETis empty or the header is absent. - Rejects if
abs(time() - $timestamp) > 300— a 5-minute replay window. - Computes
hash_hmac('sha256', "{$timestamp}:{$rawBody}", $secret). - Uses
hash_equals()(constant-time comparison) against eachh1value.
⚠️ Warning: If
PADDLE_WEBHOOK_SECRETis 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 type | Paddle status | Outcome |
|---|---|---|
subscription.created | — | First write of Paddle IDs to user |
subscription.trialing | trialing | subscription_status = paid, tier set from price ID, trial_ends_at = next_billed_at |
subscription.activated | active | subscription_status = paid, tier set from price ID |
subscription.updated | active or other | Re-derives tier and status from current subscription state |
subscription.past_due | past_due | subscription_status = unpaid, paddle_last_payment_failed_at stamped |
subscription.paused | paused | subscription_status = unpaid, tier falls back to free |
subscription.resumed | active | Mirrors subscription.activated |
subscription.canceled | canceled | subscription_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 type | Outcome |
|---|---|
transaction.paid | subscription_status = paid, tier set from price ID, paddle_last_payment_failed_at cleared |
transaction.completed | Same as transaction.paid |
transaction.payment_failed | subscription_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:
| Column | Purpose |
|---|---|
event_id | Paddle’s globally unique event identifier (dedup key) |
event_type | e.g. subscription.activated |
occurred_at | Paddle event timestamp |
processed_at | Server time when the row was written |
user_id | Resolved user ID (nullable if resolution failed) |
payload_summary | JSON — {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):
| Column | Set by |
|---|---|
paddle_customer_id | Subscription/transaction sync |
paddle_subscription_id | Subscription sync |
paddle_price_id | Subscription/transaction sync |
paddle_subscription_status | Subscription sync (raw Paddle status string) |
paddle_last_payment_status | Transaction sync |
paddle_last_payment_failed_at | Transaction sync on payment_failed / past_due |
paddle_scheduled_cancellation_at | Subscription sync when scheduled_change present |
paddle_canceled_at | Subscription sync on cancellation |
paddle_cancellation_source | internal or external (set once; not overwritten on replay) |
paddle_last_event_at | Updated on every processed event (staleness guard) |
trial_ends_at | Subscription sync when status is trialing |
subscription_status | paid or unpaid — the application-level gate |
subscription_tier | free, 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_ID→User::SUBSCRIPTION_TIER_BRONZEPADDLE_PREMIUM_PRICE_ID→User::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)
| Constant | Value | Description |
|---|---|---|
SUBSCRIPTION_TIER_NONE | none | Unrecognised price ID; not provisioned |
SUBSCRIPTION_TIER_FREE | free | Default; no paid subscription |
SUBSCRIPTION_TIER_BRONZE | bronze | Bronze paid plan |
SUBSCRIPTION_TIER_PREMIUM | premium | Premium paid plan |
SUBSCRIPTION_TIER_CUSTOM | custom | Manually set for enterprise accounts |
User::booted() sets new users to subscription_status = unpaid and subscription_tier = free (User.php:32-38).