serenity_technologies / cashier-gumroad
Laravel Cashier for Gumroad - Subscription billing and payment management
Package info
github.com/Serenity-Technologies/cashier-gumroad
pkg:composer/serenity_technologies/cashier-gumroad
Requires
- php: ^8.1
- ext-intl: *
- guzzlehttp/guzzle: ^7.0
- illuminate/http: ^9.0|^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^9.0|^10.0|^11.0|^12.0|^13.0
- laravel/framework: ^9.0|^10.0|^11.0|^12.0|^13.0
- moneyphp/money: ^4.0
- serenity_technologies/gumroad: ^1.2
Requires (Dev)
README
Table of Contents
- Overview
- Installation & Setup
- Configuration
- Database Schema
- Core Concepts
- Checkout Flows
- Subscription Management
- Webhook Handling
- License Management
- Offer Codes & Discounts
- Sales Tracking
- Revenue Analytics
- Console Commands
- Events & Listeners
- Security Best Practices
- Common Patterns & Examples
- API Limitations
- Troubleshooting
Overview
Laravel Cashier for Gumroad provides a fluent, expressive interface to Gumroad's subscription billing and payment services. It handles almost all of the boilerplate code needed for subscription management, including:
- Checkout URLs & Widgets - Generate overlay/embed widgets and direct checkout URLs
- Subscription Management - Track and manage subscriptions locally
- Webhook Handling - Process all Gumroad webhook events securely
- License Management - Verify and manage license keys
- Offer Codes - Create and manage discount codes
- Sales Tracking - Record and analyze sales data
- Revenue Analytics - MRR, ARR, churn rate, LTV, and more
Package Architecture
┌─────────────────────────────────────────────────────────────┐
│ Your Laravel App │
│ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ User Model │ │ Event Listeners │ │
│ │ (Billable) │ │ (Welcome emails, etc) │ │
│ └────────┬─────────┘ └──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Cashier Services Layer │ │
│ │ ┌──────────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Checkout │ │ License │ │ Subscription │ │ │
│ │ │ Service │ │ Service │ │ Service │ │ │
│ │ └──────────────┘ └──────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ Webhook │ │ Offer │ │ Revenue │ │ │
│ │ │ Handler │ │ Code │ │ Analytics │ │ │
│ │ └──────────────┘ └──────────┘ └──────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
▲
│ API Calls
▼
┌──────────────┐
│ Gumroad API │
└──────────────┘
Installation & Setup
Prerequisites
- PHP 8.1 or higher
- Laravel 9.0 or higher
- Gumroad account with API access token
- Composer
Step 1: Install Package
composer require serenity_technologies/cashier-gumroad
Step 2: Add Billable Trait to User Model
The Billable trait provides subscription management methods to your user model:
<?php namespace App\Models; use Illuminate\Foundation\Auth\User as Authenticatable; use SerenityTechnologies\Gumroad\Cashier\Billable; class User extends Authenticatable { use Billable; // ... }
What the Billable trait provides:
subscriptions()- Get all subscriptions for the usersubscribedToProduct($productId)- Check if user is subscribed to a producthasValidSubscription($productId)- Check if user has a valid subscriptionactiveSubscriptions()- Get all active subscriptionsonTrial($productId)- Check if user is on trialhasCancelledSubscription($productId)- Check if user has cancelled subscriptionsubscriptionByGumroadId($gumroadSubscriptionId)- Get subscription by Gumroad IDcreateOrUpdateSubscriptionFromWebhook($payload)- Create/update from webhookcancelSubscriptionFromWebhook($payload)- Cancel from webhookupdateSubscriptionFromWebhook($payload)- Update from webhookendSubscriptionFromWebhook($payload)- End from webhookrestartSubscriptionFromWebhook($payload)- Restart from webhook
Step 3: Publish Configuration
php artisan vendor:publish --provider="SerenityTechnologies\Gumroad\Cashier\CashierServiceProvider" --tag="cashier-gumroad-config"
This creates config/cashier-gumroad.php with all configuration options.
Step 4: Publish & Run Migrations
# Publish migrations php artisan vendor:publish --provider="SerenityTechnologies\Gumroad\Cashier\CashierServiceProvider" --tag="cashier-gumroad-migrations" # Run migrations php artisan migrate
This creates 6 database tables (with configured prefix, default gumroad_cashier_):
gumroad_cashier_subscriptions- Local subscription recordsgumroad_cashier_subscription_items- Subscription line itemsgumroad_cashier_sales- Sale recordsgumroad_cashier_licenses- License key recordsgumroad_cashier_offer_codes- Discount code recordsgumroad_cashier_webhook_calls- Webhook call tracking
Step 5: Configure Webhook Route (CSRF Exclusion)
CRITICAL: The webhook endpoint must be excluded from CSRF protection.
// app/Http/Middleware/VerifyCsrfToken.php namespace App\Http\Middleware; use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware; class VerifyCsrfToken extends Middleware { protected $except = [ 'cashier/gumroad/webhook', // Or your custom webhook URI ]; }
Step 6: Configure Gumroad API Access Token
The package uses the serenity_technologies/gumroad package for API calls. Configure your access token:
# .env GUMROAD_ACCESS_TOKEN=your_gumroad_access_token_here
Get your access token from: Gumroad Dashboard → Settings → Advanced → Generate access token
Configuration
Environment Variables
# Currency Settings CASHIER_CURRENCY=usd CASHIER_CURRENCY_LOCALE=en_US # Billable Model CASHIER_BILLABLE_MODEL=App\Models\User # Database Tables CASHIER_TABLE_PREFIX=gumroad_cashier_ # Overlay Checkout CASHIER_OVERLAY_AUTO_TRIGGER=true # Webhook Configuration CASHIER_GUMROAD_WEBHOOK_URI=cashier/gumroad/webhook CASHIER_GUMROAD_WEBHOOK_TOLERANCE=300 CASHIER_GUMROAD_VERIFY_WEBHOOK=true CASHIER_GUMROAD_VERIFY_IP=true CASHIER_GUMROAD_WEBHOOK_SECRET=your-secret CASHIER_GUMROAD_ALLOWED_IPS= CASHIER_GUMROAD_WEBHOOK_RATE_LIMIT=60,1 # Subscription Settings CASHIER_GUMROAD_TRIAL_DAYS=0 CASHIER_GUMROAD_GRACE_PERIOD=3 # Queue Configuration CASHIER_GUMROAD_QUEUE_ENABLED=true CASHIER_GUMROAD_QUEUE_CONNECTION=default CASHIER_GUMROAD_QUEUE_NAME=default # License Verification CASHIER_GUMROAD_DEFAULT_PRODUCT_ID=your_product_id # Analytics Cache CASHIER_ANALYTICS_CACHE_TTL=3600
Configuration File Breakdown
// config/cashier-gumroad.php return [ // Currency display settings 'currency' => 'usd', 'currency_locale' => 'en_US', // Billable model class 'billable_model' => \App\Models\User::class, // Table names (all prefixed) 'tables' => [ 'table_prefix' => 'gumroad_cashier_', 'subscriptions' => 'subscriptions', 'subscription_items' => 'subscription_items', 'webhook_calls' => 'webhook_calls', 'licenses' => 'licenses', 'sales' => 'sales', 'offer_codes' => 'offer_codes', ], // Overlay checkout widget settings 'overlay' => [ 'button_text' => 'Buy on Gumroad', 'button_class' => 'gumroad-button', 'auto_trigger' => true, // Skip product page, go direct to checkout ], // Webhook security 'webhook' => [ 'uri' => 'cashier/gumroad/webhook', 'tolerance' => 300, // seconds 'verify_signature' => true, 'secret' => env('CASHIER_GUMROAD_WEBHOOK_SECRET'), 'allowed_ips' => [], // IP whitelist 'rate_limit' => '60,1', // 60 requests per minute ], // Subscription settings 'subscription' => [ 'trial_days' => 0, 'grace_period_days' => 3, ], // Queue settings 'queue' => [ 'enabled' => true, 'connection' => 'default', 'queue' => 'default', ], // License settings 'license' => [ 'enabled' => true, 'verification_header' => 'X-License-Key', 'cache_ttl' => 3600, 'default_product_id' => env('CASHIER_GUMROAD_DEFAULT_PRODUCT_ID'), ], // Analytics cache 'analytics' => [ 'cache_ttl' => 3600, 'default_period' => '30_days', ], ];
Custom Table Prefix Example
// config/cashier-gumroad.php 'tables' => [ 'table_prefix' => 'app_', 'subscriptions' => 'subs', // Results in table: app_subs ],
Database Schema
1. Subscriptions Table
Purpose: Tracks subscription lifecycle for each user.
Key Fields:
id(ULID) - Primary keybillable_id,billable_type- Polymorphic relation to user modelgumroad_subscription_id- Gumroad's subscription IDgumroad_product_id- Product being subscribed tostatus- Current status (active,cancelled,ended,past_due)recurrence- Billing frequency (monthly,yearly, etc.)price_cents- Current price in centstrial_ends_at- Trial expiration timestampends_at- Subscription end timestampcancelled_at- Cancellation timestamplicense_key- Associated license key
Indexes:
- Unique:
(billable_id, billable_type, gumroad_subscription_id) - Composite:
(status, recurrence, gumroad_product_id) - Composite:
(status, ends_at)
2. Subscription Items Table
Purpose: Line items within a subscription (for multi-product subscriptions).
Key Fields:
id(ULID) - Primary keysubscription_id- Foreign key to subscriptionsgumroad_variant_id- Variant ID if applicablegumroad_product_id- Product IDprice_cents- Item pricequantity- Item quantity
3. Sales Table
Purpose: Records all sales (one-time and recurring).
Key Fields:
id(ULID) - Primary keybillable_id,billable_type- Polymorphic relationgumroad_sale_id- Gumroad's sale ID (unique)gumroad_product_id- Product soldgumroad_subscription_id- Associated subscription (nullable)price_cents,gumroad_fee_cents,net_cents- Revenue trackingemail,full_name- Customer inforefunded,partially_refunded,refund_amount_cents- Refund trackinglicense_key- License key if generatedoffer_code- Discount code usedis_recurring_charge- Is this a recurring payment?sale_timestamp- When sale occurred
Foreign Keys:
gumroad_subscription_id→subscriptions.gumroad_subscription_id
4. Licenses Table
Purpose: Track license keys for software products.
Key Fields:
id(ULID) - Primary keybillable_id,billable_type- Polymorphic relationgumroad_license_key- The license key (unique)gumroad_product_id- Product the license is foruses_count- Current usage countmax_uses- Maximum allowed uses (nullable for unlimited)status- License status (active,disabled)last_verified_at- Last verification timestamp
5. Offer Codes Table
Purpose: Local cache of Gumroad discount codes.
Key Fields:
id(ULID) - Primary keygumroad_offer_code_id- Gumroad's offer code IDgumroad_product_id- Product the code applies toname- The coupon code (e.g., "SAVE20")amount_cents- Fixed discount amount (nullable)percent_off- Percentage discount (nullable)max_purchase_count- Usage limit (nullable for unlimited)times_used- Current usage countuniversal- Applies to all products?
Unique: (gumroad_product_id, name)
6. Webhook Calls Table
Purpose: Track and idempotent webhook processing.
Key Fields:
id(ULID) - Primary keyevent_type- Type of event (sale,refund, etc.)gumroad_sale_id,gumroad_subscription_id- Related entitiesidempotency_key- SHA-256 hash to prevent duplicatespayload- Sanitized webhook payload (JSON)processed- Has this been processed?processed_at- Processing timestampexception- Error message if failed
Core Concepts
Billable Model
The billable model (typically User) is the entity that can have subscriptions. Add the Billable trait:
use SerenityTechnologies\Gumroad\Cashier\Billable; class User extends Authenticatable { use Billable; }
Services
The package provides several services (auto-injected via dependency injection):
use SerenityTechnologies\Gumroad\Cashier\Services\CheckoutService; use SerenityTechnologies\Gumroad\Cashier\Services\SubscriptionService; use SerenityTechnologies\Gumroad\Cashier\Services\LicenseService; use SerenityTechnologies\Gumroad\Cashier\Services\OfferCodeService; use SerenityTechnologies\Gumroad\Cashier\Services\RevenueAnalyticsService;
Models
All models use ULIDs and configurable table names:
use SerenityTechnologies\Gumroad\Cashier\Models\Subscription; use SerenityTechnologies\Gumroad\Cashier\Models\Sale; use SerenityTechnologies\Gumroad\Cashier\Models\License; use SerenityTechnologies\Gumroad\Cashier\Models\OfferCode; use SerenityTechnologies\Gumroad\Cashier\Models\WebhookCall;
Cashier Static Helper
The Cashier class provides static helper methods:
use SerenityTechnologies\Gumroad\Cashier\Cashier; Cashier::formatAmount(1999); // "$19.99" Cashier::customerModel(); // "App\Models\User" Cashier::webhookUri(); // "cashier/gumroad/webhook" Cashier::generatIdempotencyKey($payload); // SHA-256 hash Cashier::determineEventType($payload); // Event type from payload
Checkout Flows
The package supports two primary checkout methods.
1. New Window Checkout (Recommended)
Opens the Gumroad checkout page in a new browser tab. This is now the default behavior for all generated buttons.
Using Blade Directives:
{{-- Anywhere in your views --}} @gumroadOverlay('product-id', ['button_text' => 'Subscribe Now', 'class' => 'btn btn-primary'])
This generates a standard anchor tag with target="_blank" and rel="noopener noreferrer".
Dedicated Checkout Route:
You can link directly to a hosted launcher:
{{ route('cashier.gumroad.checkout', ['product_id' => 'your-product-id']) }}
2. Embed Checkout
Embeds the full product page in an iframe.
$embed = $checkout->generateEmbedWidget('your-product-id', [ 'width' => 100, // percentage 'height' => 600, // pixels ]);
Getting Products
// Get all products $products = $checkout->getProducts(); // Get product by ID $product = $checkout->getProductById('your-product-id'); // Get product by permalink (legacy method) $product = $checkout->getProductByPermalink('product-slug');
Subscription Management
Checking Subscription Status
$user = Auth::user(); // Check if subscribed to specific product if ($user->subscribedToProduct('product-id')) { // User has active subscription } // Check if has any valid subscription if ($user->hasValidSubscription()) { // User has valid subscription (active or on grace period) } // Check if on trial if ($user->onTrial('product-id')) { // User is on trial } // Check if cancelled if ($user->hasCancelledSubscription('product-id')) { // User has cancelled subscription (but may still have access) } // Get all active subscriptions $activeSubs = $user->activeSubscriptions(); // Get specific subscription $sub = $user->subscriptionByGumroadId('gumroad-sub-id');
Subscription Model Methods
use SerenityTechnologies\Gumroad\Cashier\Models\Subscription; $subscription = Subscription::first(); // Status checks $subscription->active(); // true if active or on grace period $subscription->cancelled(); // true if cancelled $subscription->onGracePeriod(); // true if in grace period after cancellation $subscription->onTrial(); // true if on trial $subscription->failed(); // true if payment failed $subscription->ended(); // true if subscription ended $subscription->valid(); // true if active or on trial // Get recurrence label $subscription->recurrence(); // "Monthly", "Yearly", etc. // Check product $subscription->hasProduct('product-id'); // true if matches // Scopes Subscription::active()->get(); Subscription::forProduct('product-id')->get(); Subscription::withRecurrence('monthly')->get();
Syncing Subscribers from Gumroad
Sync subscribers from Gumroad API to local database:
# Sync for specific product php artisan cashier-gumroad:sync-subscribers --product-id=your-product-id # Sync all products php artisan cashier-gumroad:sync-subscribers --all # Queue sync jobs (async) php artisan cashier-gumroad:sync-subscribers --all --queue
Programmatic sync:
use SerenityTechnologies\Gumroad\Cashier\Services\SubscriptionService; $subscriptionService = app(SubscriptionService::class); // Sync single subscriber $subscription = $subscriptionService->syncSubscriber('subscriber-id'); // Sync all subscribers for product $synced = $subscriptionService->syncProductSubscribers('product-id'); // Refresh subscription status from API $subscriptionService->refreshSubscriptionStatus($subscription);
Subscription Lifecycle States
active ──────► cancelled ──────► ended
│ ▲
│ │
▼ │
past_due ──► cancelled ─────────────┘
- active - Subscription is active and current
- past_due - Payment failed, but still within grace period
- cancelled - User cancelled, but still has access until
ends_at - ended - Subscription has fully ended
Webhook Handling
Before your application can receive webhook events, you must first subscribe to the specific resources you want to listen to. Use the provided artisan commands to manage these subscriptions.
Available Artisan Commands for Webhook Subscriptions:
# Subscribe to resources (interactive mode) php artisan cashier-gumroad:subscribe # Subscribe to a specific resource (e.g., sale) php artisan cashier-gumroad:subscribe sale # Subscribe to all available resources php artisan cashier-gumroad:subscribe --all # List all active subscriptions php artisan cashier-gumroad:subscriptions # Unsubscribe from a specific ID php artisan cashier-gumroad:unsubscribe {id} # Unsubscribe from all subscriptions for a resource php artisan cashier-gumroad:unsubscribe --resource=sale # Unsubscribe from ALL resources php artisan cashier-gumroad:unsubscribe --all
Supported Resources:
sale, refund, dispute, dispute_won, cancellation, subscription_updated, subscription_ended, subscription_restarted.
Supported Events
The package handles all 8 Gumroad webhook events: ...
| Gumroad Event | Local Action | Events Dispatched |
|---|---|---|
sale |
Create sale record, create subscription | PaymentReceived, SubscriptionCreated |
refund |
Update sale, cancel subscription | PaymentRefunded, SubscriptionCancelled |
cancellation |
Cancel subscription | SubscriptionCancelled |
subscription_updated |
Update subscription details | SubscriptionUpdated |
subscription_ended |
End subscription | SubscriptionEnded |
subscription_restarted |
Reactivate subscription | SubscriptionRestarted |
dispute |
Log dispute | DisputeCreated |
dispute_won |
Log dispute outcome | DisputeWon |
How Webhooks Work
- Gumroad sends POST to your webhook endpoint (
/cashier/gumroad/webhook) - Middleware verifies the request (secret token, IP whitelist)
- Controller validates payload (50+ fields validated)
- Idempotency check prevents duplicate processing
- Event type determined from payload (never trust headers)
- Webhook call recorded in database
- Job dispatched to queue (or processed synchronously)
- Handler processes the webhook in a database transaction
- Events dispatched after transaction commits
Webhook Security
Three layers of protection:
- Secret Token Verification (optional):
CASHIER_GUMROAD_WEBHOOK_SECRET=your_secret_token CASHIER_GUMROAD_VERIFY_WEBHOOK=true
- IP Whitelisting (optional):
CASHIER_GUMROAD_ALLOWED_IPS=1.2.3.4,5.6.7.8
- Rate Limiting (default: 60 req/min):
CASHIER_GUMROAD_WEBHOOK_RATE_LIMIT=60,1
Idempotency
Webhooks are protected against duplicate processing using SHA-256 idempotency keys:
$key = Cashier::generateIdempotencyKey($payload); // Hash of: sale_id:subscription_id:timestamp
Event Type Detection
The package determines event type from payload fields (never trusts client headers):
$eventType = Cashier::determineEventType($payload); // Logic: // - Has cancelled/cancelled_at? → cancellation // - Has type (upgrade/downgrade)? → subscription_updated // - Has ended_at/ended_reason? → subscription_ended // - Has restarted_at? → subscription_restarted // - Has refunded=true? → refund // - Has disputed=true? → dispute // - Default → sale
Webhook Payload Validation
All incoming webhooks are validated with 50+ field rules:
// Example validation rules 'sale_id' => 'nullable|string|max:255', 'email' => 'nullable|email|max:255', 'price' => 'nullable|integer|min:0', 'recurrence' => 'nullable|string|max:50', 'cancelled' => 'nullable|boolean', 'ended_at' => 'nullable|string|max:50',
Payload Sanitization
Sensitive fields are removed before storage:
$sensitiveFields = [ 'card', 'shipping_information', 'custom_fields', 'url_params', ];
License Management
License keys are used to verify software purchases.
Verify License Middleware
Protect routes with license verification:
use SerenityTechnologies\Gumroad\Cashier\Http\Middleware\VerifyLicense; Route::get('/premium-feature', function () { return view('premium-feature'); })->middleware(['auth', 'verify.license:your-product-id']);
Or apply globally:
// app/Http/Kernel.php protected $routeMiddleware = [ 'verify.license' => \SerenityTechnologies\Gumroad\Cashier\Http\Middleware\VerifyLicense::class, ];
How it works:
- Checks for
X-License-Keyheader orlicense_keyquery parameter - Verifies with Gumroad API (cached for 1 hour)
- Updates local license record
- Throws
AccessDeniedHttpExceptionif invalid
License Service Methods
use SerenityTechnologies\Gumroad\Cashier\Services\LicenseService; $licenseService = app(LicenseService::class); // Verify license with Gumroad $verification = $licenseService->verifyLicense('product-id', 'LICENSE-KEY'); // Enable license $licenseService->enableLicense('product-id', 'LICENSE-KEY'); // Disable license $licenseService->disableLicense('product-id', 'LICENSE-KEY', 'license-id'); // Decrement uses $licenseService->decrementLicenseUses('product-id', 'LICENSE-KEY'); // Rotate license key $licenseService->rotateLicense('product-id', 'LICENSE-KEY'); // Find license locally $license = $licenseService->findByKey('LICENSE-KEY'); // Find and verify $verification = $licenseService->findAndVerify('LICENSE-KEY');
License Model Methods
use SerenityTechnologies\Gumroad\Cashier\Models\License; $license = License::first(); // Status checks $license->isActive(); // true if active $license->isDisabled(); // true if disabled $license->hasUnlimitedUses(); // true if max_uses is null $license->hasReachedUseLimit(); // true if uses >= max_uses $license->canBeUsed(); // true if active AND not at limit // Actions $license->activate(); $license->disable(); $license->recordUse(); // Increments uses_count // Scopes License::active()->get(); License::forProduct('product-id')->get(); License::useLimitReached()->get();
Offer Codes & Discounts
Creating Offer Codes
use SerenityTechnologies\Gumroad\Cashier\Services\OfferCodeService; $offerCodeService = app(OfferCodeService::class); // Create offer code $offerCode = $offerCodeService->createOfferCode( productId: 'your-product-id', name: 'SAVE20', amountOff: 0, // Fixed amount off (cents) percentOff: 20, // Percentage off type: 'percent', // 'cents' or 'percent' maxPurchaseCount: 100, universal: false // true = applies to all products ); // Update offer code $offerCodeService->updateOfferCode( productId: 'your-product-id', offerCodeId: 'offer-id', amountOff: 0, percentOff: 30, maxPurchaseCount: 200 ); // Delete offer code $offerCodeService->deleteOfferCode('your-product-id', 'offer-id');
Syncing Offer Codes
// Sync from Gumroad API to local database $offerCodes = $offerCodeService->syncOfferCodes('your-product-id'); // Get offer codes $offerCodes = $offerCodeService->getOfferCodes('your-product-id'); // Find by name $offerCode = $offerCodeService->findByName('your-product-id', 'SAVE20');
Offer Code Model Methods
use SerenityTechnologies\Gumroad\Cashier\Models\OfferCode; $offerCode = OfferCode::first(); // Check discount type $offerCode->isAmountDiscount(); // true if amount_cents set $offerCode->isPercentDiscount(); // true if percent_off set // Check usage limits $offerCode->hasReachedUsageLimit(); // true if at limit $offerCode->canBeUsed(); // true if can still be used // Get description $offerCode->discountDescription(); // "20% off" or "$5.00 off" // Record usage $offerCode->incrementUsage(); // Scopes OfferCode::universal()->get(); OfferCode::forProduct('product-id')->get(); OfferCode::available()->get(); // Not at usage limit
Checkout with Offer Code
use SerenityTechnologies\Gumroad\Cashier\Services\OfferCodeService; $offerCodeService = app(OfferCodeService::class); // Generate checkout URL with offer code $url = $offerCodeService->generateCheckoutUrl('product-id', 'SAVE20'); // Or use CheckoutService use SerenityTechnologies\Gumroad\Cashier\Services\CheckoutService; $checkoutService = app(CheckoutService::class); $url = $checkoutService->generateDiscountedCheckoutUrl('product-id', 'SAVE20');
Sales Tracking
Sale Model Methods
use SerenityTechnologies\Gumroad\Cashier\Models\Sale; $sale = Sale::first(); // Type checks $sale->isSubscription(); // true if has subscription $sale->isRecurringCharge(); // true if recurring payment $sale->isFullyRefunded(); // true if fully refunded $sale->isPartiallyRefunded(); // true if partially refunded $sale->requiresShipping(); // true if needs shipping $sale->isShipped(); // true if marked shipped // Refund tracking $sale->refundableAmount(); // Remaining refundable amount $sale->recordRefund(500); // Record $5.00 refund // Net revenue $sale->netRevenue(); // price_cents - gumroad_fee_cents // Mark as shipped $sale->markAsShipped('https://tracking.url/123'); // Scopes Sale::subscriptions()->get(); Sale::oneTime()->get(); Sale::refunded()->get(); Sale::forProduct('product-id')->get(); Sale::recurring()->get();
Syncing Sales
# Sync sales for specific product php artisan cashier-gumroad:sync-sales --product-id=your-product-id # Sync all sales php artisan cashier-gumroad:sync-sales --all # Sync since date php artisan cashier-gumroad:sync-sales --all --since=2024-01-01 # Queue sync jobs php artisan cashier-gumroad:sync-sales --all --queue
Revenue Analytics
The package provides comprehensive revenue analytics.
Key Metrics
use SerenityTechnologies\Gumroad\Cashier\Services\RevenueAnalyticsService; $analytics = app(RevenueAnalyticsService::class); // Monthly Recurring Revenue (MRR) $mrr = $analytics->getMRR(); // In cents $mrrProduct = $analytics->getMRR('product-id'); // Annual Recurring Revenue (ARR) $arr = $analytics->getARR(); // Churn Rate (30-day default) $churnRate = $analytics->getChurnRate(); // Percentage $churnRate90 = $analytics->getChurnRate(90); // Customer Lifetime Value (LTV) $ltv = $analytics->getCustomerLifetimeValue(); // Average Revenue Per Customer $avgRevenue = $analytics->getAverageRevenuePerCustomer(); // Total Revenue (30-day default) $totalRevenue = $analytics->getTotalRevenue(); $totalRevenue90 = $analytics->getTotalRevenue(90); // Net Revenue (after fees) $netRevenue = $analytics->getNetRevenue(); // Revenue by Product $revenueByProduct = $analytics->getRevenueByProduct(); // Returns: ['product-id' => ['total_cents' => 10000, 'sales_count' => 10]] // Recurring vs One-time Revenue $breakdown = $analytics->getRecurringVsOneTimeRevenue(); // Returns: ['recurring_cents' => 5000, 'one_time_cents' => 3000, 'total_cents' => 8000] // Subscription Counts $newSubs = $analytics->getNewSubscriptions(); $cancelledSubs = $analytics->getCancelledSubscriptions(); // Clear analytics cache $analytics->clearCache();
How MRR is Calculated
MRR normalizes different recurrence periods to monthly:
monthly: price_cents × 1 quarterly: price_cents / 3 biannually: price_cents / 6 yearly: price_cents / 12 every_2_years: price_cents / 24
LTV Calculation
LTV = Average Revenue Per Customer / (Churn Rate / 100) // Capped at 100 years to prevent absurd values at low churn Max LTV = Average Revenue Per Customer × 1200
Caching
All analytics queries are cached (default: 1 hour) using prefixed keys:
// Cache key format: "cashier-analytics:{metric}" // Example: "cashier-analytics:mrr" // Compatible with all cache drivers (no tags required)
Console Commands
1. Sync Subscribers
Syncs subscribers from Gumroad API to local database.
# Sync specific product php artisan cashier-gumroad:sync-subscribers --product-id=PRODUCT_ID # Sync all products php artisan cashier-gumroad:sync-subscribers --all # Queue sync jobs (async, recommended for large datasets) php artisan cashier-gumroad:sync-subscribers --all --queue
Options:
--product-id- Sync subscribers for specific product--all- Sync subscribers for all products--queue- Queue sync jobs instead of synchronous
2. Sync Sales
Syncs sales from Gumroad API to local database.
# Sync specific product php artisan cashier-gumroad:sync-sales --product-id=PRODUCT_ID # Sync all sales php artisan cashier-gumroad:sync-sales --all # Sync since date php artisan cashier-gumroad:sync-sales --all --since=2024-01-01 # Queue sync jobs php artisan cashier-gumroad:sync-sales --all --queue
Options:
--product-id- Sync sales for specific product--all- Sync all sales--since- Only sync sales since date (YYYY-MM-DD)--queue- Queue sync jobs
3. Webhook Subscriptions
Manage real-time notifications by subscribing to Gumroad resources (Ping/Webhooks).
# Subscribe to resources (interactive mode) php artisan cashier-gumroad:subscribe # Subscribe to a specific resource php artisan cashier-gumroad:subscribe sale # Subscribe to all available resources php artisan cashier-gumroad:subscribe --all # List all active subscriptions php artisan cashier-gumroad:subscriptions # Unsubscribe from a specific ID php artisan cashier-gumroad:unsubscribe {id} # Unsubscribe from all subscriptions for a resource php artisan cashier-gumroad:unsubscribe --resource=sale # Unsubscribe from ALL resources php artisan cashier-gumroad:unsubscribe --all
Supported Resources:
sale, refund, dispute, dispute_won, cancellation, subscription_updated, subscription_ended, subscription_restarted.
Setting Up Cron Jobs
For automatic syncing, add to your server's crontab:
# Sync subscribers daily at 2am 0 2 * * * cd /path/to/project && php artisan cashier-gumroad:sync-subscribers --all --queue # Sync sales every 6 hours 0 */6 * * * cd /path/to/project && php artisan cashier-gumroad:sync-sales --all --queue
Events & Listeners
Available Events
| Event | When Dispatched | Parameters |
|---|---|---|
GumroadWebhookReceived |
Every webhook received | $eventType, $payload, $saleId, $subscriptionId |
PaymentReceived |
Sale completed | $payload, $saleId, $subscriptionId |
PaymentRefunded |
Refund processed | $payload, $saleId |
SubscriptionCreated |
New subscription created | $subscription, $payload |
SubscriptionCancelled |
Subscription cancelled | $subscription, $payload, $cancelledByAdmin, $cancelledByBuyer, $cancelledBySeller, $cancelledDueToRefund |
SubscriptionUpdated |
Subscription details updated | $subscription, $payload, $updateType |
SubscriptionEnded |
Subscription ended | $subscription, $payload, $endedReason |
SubscriptionRestarted |
Subscription reactivated | $subscription, $payload |
DisputeCreated |
Dispute raised | $payload, $saleId |
DisputeWon |
Dispute resolved in buyer's favor | $payload, $saleId |
Registering Event Listeners
Method 1: EventServiceProvider
// app/Providers/EventServiceProvider.php protected $listen = [ \SerenityTechnologies\Gumroad\Cashier\Events\SubscriptionCreated::class => [ \App\Listeners\SendWelcomeEmail::class, \App\Listeners\GrantAccess::class, ], \SerenityTechnologies\Gumroad\Cashier\Events\SubscriptionCancelled::class => [ \App\Listeners\RevokeAccess::class, \App\Listeners\SendCancellationEmail::class, ], \SerenityTechnologies\Gumroad\Cashier\Events\PaymentReceived::class => [ \App\Listeners\SendReceipt::class, ], \SerenityTechnologies\Gumroad\Cashier\Events\PaymentRefunded::class => [ \App\Listeners\ProcessRefund::class, ], ];
Method 2: Event Subscribers
// app/Listeners/GumroadEventSubscriber.php class GumroadEventSubscriber { public function handleSubscriptionCreated($event) { $user = $event->subscription->billable; $user->notify(new WelcomeToPremium($event->subscription)); } public function handlePaymentReceived($event) { // Send receipt } public function subscribe($events) { $events->listen( \SerenityTechnologies\Gumroad\Cashier\Events\SubscriptionCreated::class, [self::class, 'handleSubscriptionCreated'] ); $events->listen( \SerenityTechnologies\Gumroad\Cashier\Events\PaymentReceived::class, [self::class, 'handlePaymentReceived'] ); } } // app/Providers/EventServiceProvider.php protected $subscribe = [ \App\Listeners\GumroadEventSubscriber::class, ];
Example Listener
namespace App\Listeners; use SerenityTechnologies\Gumroad\Cashier\Events\SubscriptionCreated; use App\Mail\WelcomeToPremium; use Illuminate\Support\Facades\Mail; class SendWelcomeEmail { public function handle(SubscriptionCreated $event): void { $user = $event->subscription->billable; Mail::to($user)->send(new WelcomeToPremium( $user, $event->subscription )); } }
Security Best Practices
1. Exclude Webhook from CSRF
CRITICAL: This is required for webhooks to work.
// app/Http/Middleware/VerifyCsrfToken.php protected $except = [ 'cashier/gumroad/webhook', ];
2. Enable Webhook Verification
CASHIER_GUMROAD_VERIFY_WEBHOOK=true CASHIER_GUMROAD_WEBHOOK_SECRET=your_strong_secret_here
Generate a strong secret:
php -r "echo bin2hex(random_bytes(32));"
3. IP Whitelisting (Optional)
If Gumroad provides static IPs, whitelist them:
CASHIER_GUMROAD_ALLOWED_IPS=1.2.3.4,5.6.7.8
4. Use Queue for Webhooks
Queue webhook processing to ensure fast response times:
CASHIER_GUMROAD_QUEUE_ENABLED=true
Configure queue worker:
php artisan queue:work --queue=default --tries=3
5. Rate Limiting
Default rate limiting is 60 requests per minute. Adjust as needed:
CASHIER_GUMROAD_WEBHOOK_RATE_LIMIT=100,1
6. Protect Sensitive Data
The package automatically sanitizes webhook payloads before storage. Do not store:
- Card details
- Full PII in logs
- Raw webhook payloads with sensitive fields
7. Use HTTPS in Production
Always use HTTPS for webhook endpoints in production.
8. Monitor Failed Webhooks
Check for failed webhook processing:
use SerenityTechnologies\Gumroad\Cashier\Models\WebhookCall; // Get unprocessed webhooks $unprocessed = WebhookCall::unprocessed()->get(); // Get failed webhooks $failed = WebhookCall::whereNotNull('exception')->get();
Common Patterns & Examples
1. Product Checkout Page
// app/Http/Controllers/ProductController.php public function show(string $permalink, CheckoutService $checkout) { $product = $checkout->getProductByPermalink($permalink); if (! $product) { abort(404); } $widget = $checkout->generateOverlayWidget($product->id, [ 'button_text' => 'Subscribe to ' . $product->name, 'email' => auth()->check() ? auth()->user()->email : null, 'auto_trigger' => false, ]); return view('products.show', compact('product', 'widget')); }
2. Access Control Middleware
// app/Http/Middleware/RequireSubscription.php public function handle($request, Closure $next) { $productId = config('services.gumroad.premium_product_id'); if (! $request->user()->subscribedToProduct($productId)) { return redirect('/subscribe')->with('error', 'Premium subscription required'); } return $next($request); }
3. Pricing Page with Multiple Plans
// app/Http/Controllers/PricingController.php public function index(CheckoutService $checkout) { $plans = [ 'monthly' => [ 'product_id' => 'monthly-product-id', 'name' => 'Monthly Plan', 'price' => '$19/month', ], 'yearly' => [ 'product_id' => 'yearly-product-id', 'name' => 'Yearly Plan', 'price' => '$199/year', ], ]; foreach ($plans as $key => $plan) { $plans[$key]['widget'] = $checkout->generateOverlayWidget( $plan['product_id'], [ 'include_script' => $key === 'monthly', // Include once 'button_text' => 'Choose ' . $plan['name'], 'class' => 'btn btn-primary', ] ); } return view('pricing', compact('plans')); }
4. Subscription Status Page
// app/Http/Controllers/SubscriptionController.php public function show() { $user = Auth::user(); $subscriptions = $user->subscriptions; return view('account.subscription', compact('subscriptions')); }
{{-- resources/views/account/subscription.blade.php --}} @forelse($subscriptions as $subscription) <div class="subscription-card"> <h3>{{ $subscription->type }}</h3> <p>Status: {{ $subscription->status }}</p> <p>Recurrence: {{ $subscription->recurrence() }}</p> <p>Price: {{ \SerenityTechnologies\Gumroad\Cashier\Cashier::formatAmount($subscription->price_cents) }}</p> @if($subscription->onTrial()) <span class="badge badge-trial">On Trial</span> @elseif($subscription->active()) <span class="badge badge-active">Active</span> @elseif($subscription->cancelled()) <span class="badge badge-cancelled">Cancelled</span> @endif @if($subscription->ends_at) <p>Access until: {{ $subscription->ends_at->format('M d, Y') }}</p> @endif </div> @empty <p>No active subscriptions</p> @endforelse
5. Event Listener for Welcome Email
// app/Listeners/SendWelcomeEmail.php class SendWelcomeEmail { public function handle(SubscriptionCreated $event): void { $user = $event->subscription->billable; $subscription = $event->subscription; $user->notify(new WelcomeToPremium( $user, $subscription )); // Grant access to premium features $user->update(['has_premium_access' => true]); } }
6. Admin Dashboard Analytics
// app/Http/Controllers/Admin/AnalyticsController.php public function index(RevenueAnalyticsService $analytics) { return view('admin.analytics', [ 'mrr' => $analytics->getMRR(), 'arr' => $analytics->getARR(), 'churnRate' => $analytics->getChurnRate(), 'ltv' => $analytics->getCustomerLifetimeValue(), 'totalRevenue' => $analytics->getTotalRevenue(30), 'netRevenue' => $analytics->getNetRevenue(30), 'revenueByProduct' => $analytics->getRevenueByProduct(), 'newSubscriptions' => $analytics->getNewSubscriptions(30), 'cancelledSubscriptions' => $analytics->getCancelledSubscriptions(30), ]); }
7. License-Protected API Route
// routes/api.php Route::middleware(['auth:api', 'verify.license']) ->get('/premium-api/data', function (Request $request) { return response()->json([ 'data' => 'Premium API Response', ]); });
Client request:
curl -H "X-License-Key: YOUR-LICENSE-KEY" \
https://yourapp.com/api/premium-api/data
8. Formatting Currency
use SerenityTechnologies\Gumroad\Cashier\Cashier; // Format cents to dollars echo Cashier::formatAmount(1999); // "$19.99" echo Cashier::formatAmount(9900); // "$99.00" // In Blade views {{ \SerenityTechnologies\Gumroad\Cashier\Cashier::formatAmount($subscription->price_cents) }}
API Limitations
Gumroad's API has several limitations to be aware of:
What Gumroad Does NOT Support:
-
Direct Checkout on Your Site
- Cannot process payments directly
- Must use overlay widget or redirect to Gumroad
-
Payment Method Updates
- Cannot update payment methods via API
- Users must manage via Gumroad dashboard
-
Proration Calculations
- No API support for proration on plan changes
- Gumroad handles this internally
-
Subscription Cancellation via API
- Cannot cancel subscriptions via API
- Must be done via Gumroad dashboard or by user
-
Custom Checkout CSS
- Cannot customize checkout appearance
- Limited to overlay widget styling
-
Price Retrieval for Subscribers
- Subscriber API doesn't return pricing info
- Must track locally or query sales API
Workarounds:
- Overlay Checkout: Provides seamless UX without leaving your site
- Local Tracking: Store all subscription data locally via webhooks
- Sales API Sync: Periodically sync sales for accurate pricing
- Webhook Events: Use events to track lifecycle changes
Troubleshooting
Issue: Webhook Returns 403 (Invalid Webhook Secret)
Cause: The HMAC signature validation failed. This usually means:
- The
CASHIER_GUMROAD_WEBHOOK_SECRETin your.envdoes not match the one used during subscription. - The URL signature was generated with a different secret or event type.
Solution:
- Verify
CASHIER_GUMROAD_WEBHOOK_SECRETis identical in your environment. - Re-subscribe to your resources using the
cashier-gumroad:subscribe --url=https://yourdomain.comcommand to regenerate valid URLs with the correct resource-specific signatures. - If using
TestWebhookCommand, it automatically uses the correct logic, but verify your.envconfiguration matches.
Issue: Webhook Returns 419 (CSRF Token Mismatch)
Solution: Exclude webhook route from CSRF protection.
// app/Http/Middleware/VerifyCsrfToken.php protected $except = [ 'cashier/gumroad/webhook', ];
Issue: generateCheckoutUrl Returns 404
Cause: The URL was being constructed manually without fetching from Gumroad.
Solution: The method now:
- Accepts product ID (not permalink)
- Fetches product from Gumroad API
- Uses the actual
short_urlfrom API response
// Correct usage $url = $checkout->generateCheckoutUrl('your-product-id'); // NOT this (old method): // $url = "https://gumroad.com/l/permalink";
Issue: Duplicate Webhook Processing
Solution: Idempotency is handled automatically using SHA-256 keys. If duplicates still occur, check:
- Queue worker isn't running multiple instances
- Webhook call record exists and is marked as processed
// Check for unprocessed webhooks WebhookCall::unprocessed()->get();
Issue: Subscriber Sync Fails
Common causes:
- User not found - Email in Gumroad doesn't match local user
- Invalid subscriber ID - Check subscriber ID format
Solution: Ensure users have matching emails:
// Add gumroad_purchaser_id to users table for better matching Schema::table('users', function (Blueprint $table) { $table->string('gumroad_purchaser_id')->nullable(); });
Issue: Queue Jobs Failing
Check:
# Check failed jobs php artisan queue:failed # Retry failed jobs php artisan queue:retry all # Clear failed queue php artisan queue:flush
Ensure queue worker is running:
php artisan queue:work --queue=default --tries=3
Issue: License Verification Failing
Common causes:
- Product ID not provided - Set default in config
- Invalid license key - Check key format
- API rate limit - Verification is cached for 1 hour
Solution:
CASHIER_GUMROAD_DEFAULT_PRODUCT_ID=your-product-id
Issue: Analytics Showing Zero
Check:
- Sales are being synced:
Sale::count() - Webhooks are processing:
WebhookCall::unprocessed()->count() - Cache is not stale: Clear analytics cache
$analytics = app(RevenueAnalyticsService::class); $analytics->clearCache();
Issue: Currency Formatting Incorrect
Solution: Configure locale:
CASHIER_CURRENCY=eur CASHIER_CURRENCY_LOCALE=de_DE
// Or dynamically Cashier::formatAmount(1999); // Uses configured locale
Additional Resources
Recommended Queue Configuration
For production, use Redis or database queue driver:
QUEUE_CONNECTION=redis
Supervisor configuration for queue worker:
[program:laravel-worker] process_name=%(program_name)s_%(process_num)02d command=php /path/to/project/artisan queue:work redis --queue=default --tries=3 --timeout=60 autostart=true autorestart=true numprocs=4 redirect_stderr=true stdout_logfile=/path/to/project/storage/logs/worker.log
Database Indexes
All necessary indexes are created in migrations. If adding custom queries, consider:
// Example: Add index for analytics Schema::table('gumroad_cashier_sales', function (Blueprint $table) { $table->index(['sale_timestamp', 'refunded'], 'idx_sales_timestamp_refunded'); });
Testing Webhooks Locally
Use ngrok or similar to expose local server:
ngrok http 8000
Update Gumroad webhook URL to: https://your-ngrok-url.ngrok.io/cashier/gumroad/webhook
Package Updates
When updating the package:
composer update serenity_technologies/cashier-gumroad # Republish migrations if needed php artisan vendor:publish --provider="SerenityTechnologies\Gumroad\Cashier\CashierServiceProvider" --tag="cashier-gumroad-migrations" --force # Run new migrations php artisan migrate
Changelog Highlights
Recent Improvements
- Fixed:
generateCheckoutUrlnow fetches product from API to get correctshort_url(prevents 404s) - Fixed: Webhook race conditions using
firstOrCreatefor atomicity - Fixed: Analytics memory issues using SQL aggregation
- Fixed: Double-counting in churn rate calculation
- Added:
getProductByIdmethod - Added: Idempotency protection for webhooks
- Added: Payload sanitization (removes sensitive fields)
- Improved: Queue job retry logic with safe logging
Support & Contributing
For issues, suggestions, or contributions, please refer to the project repository.
License
This package is open-sourced software licensed under the MIT license.