serenity_technologies/cashier-gumroad

Laravel Cashier for Gumroad - Subscription billing and payment management

Maintainers

Package info

github.com/Serenity-Technologies/cashier-gumroad

pkg:composer/serenity_technologies/cashier-gumroad

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.1.19 2026-04-22 22:18 UTC

README

Table of Contents

  1. Overview
  2. Installation & Setup
  3. Configuration
  4. Database Schema
  5. Core Concepts
  6. Checkout Flows
  7. Subscription Management
  8. Webhook Handling
  9. License Management
  10. Offer Codes & Discounts
  11. Sales Tracking
  12. Revenue Analytics
  13. Console Commands
  14. Events & Listeners
  15. Security Best Practices
  16. Common Patterns & Examples
  17. API Limitations
  18. 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 user
  • subscribedToProduct($productId) - Check if user is subscribed to a product
  • hasValidSubscription($productId) - Check if user has a valid subscription
  • activeSubscriptions() - Get all active subscriptions
  • onTrial($productId) - Check if user is on trial
  • hasCancelledSubscription($productId) - Check if user has cancelled subscription
  • subscriptionByGumroadId($gumroadSubscriptionId) - Get subscription by Gumroad ID
  • createOrUpdateSubscriptionFromWebhook($payload) - Create/update from webhook
  • cancelSubscriptionFromWebhook($payload) - Cancel from webhook
  • updateSubscriptionFromWebhook($payload) - Update from webhook
  • endSubscriptionFromWebhook($payload) - End from webhook
  • restartSubscriptionFromWebhook($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_):

  1. gumroad_cashier_subscriptions - Local subscription records
  2. gumroad_cashier_subscription_items - Subscription line items
  3. gumroad_cashier_sales - Sale records
  4. gumroad_cashier_licenses - License key records
  5. gumroad_cashier_offer_codes - Discount code records
  6. gumroad_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 key
  • billable_id, billable_type - Polymorphic relation to user model
  • gumroad_subscription_id - Gumroad's subscription ID
  • gumroad_product_id - Product being subscribed to
  • status - Current status (active, cancelled, ended, past_due)
  • recurrence - Billing frequency (monthly, yearly, etc.)
  • price_cents - Current price in cents
  • trial_ends_at - Trial expiration timestamp
  • ends_at - Subscription end timestamp
  • cancelled_at - Cancellation timestamp
  • license_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 key
  • subscription_id - Foreign key to subscriptions
  • gumroad_variant_id - Variant ID if applicable
  • gumroad_product_id - Product ID
  • price_cents - Item price
  • quantity - Item quantity

3. Sales Table

Purpose: Records all sales (one-time and recurring).

Key Fields:

  • id (ULID) - Primary key
  • billable_id, billable_type - Polymorphic relation
  • gumroad_sale_id - Gumroad's sale ID (unique)
  • gumroad_product_id - Product sold
  • gumroad_subscription_id - Associated subscription (nullable)
  • price_cents, gumroad_fee_cents, net_cents - Revenue tracking
  • email, full_name - Customer info
  • refunded, partially_refunded, refund_amount_cents - Refund tracking
  • license_key - License key if generated
  • offer_code - Discount code used
  • is_recurring_charge - Is this a recurring payment?
  • sale_timestamp - When sale occurred

Foreign Keys:

  • gumroad_subscription_idsubscriptions.gumroad_subscription_id

4. Licenses Table

Purpose: Track license keys for software products.

Key Fields:

  • id (ULID) - Primary key
  • billable_id, billable_type - Polymorphic relation
  • gumroad_license_key - The license key (unique)
  • gumroad_product_id - Product the license is for
  • uses_count - Current usage count
  • max_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 key
  • gumroad_offer_code_id - Gumroad's offer code ID
  • gumroad_product_id - Product the code applies to
  • name - 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 count
  • universal - Applies to all products?

Unique: (gumroad_product_id, name)

6. Webhook Calls Table

Purpose: Track and idempotent webhook processing.

Key Fields:

  • id (ULID) - Primary key
  • event_type - Type of event (sale, refund, etc.)
  • gumroad_sale_id, gumroad_subscription_id - Related entities
  • idempotency_key - SHA-256 hash to prevent duplicates
  • payload - Sanitized webhook payload (JSON)
  • processed - Has this been processed?
  • processed_at - Processing timestamp
  • exception - 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

  1. Gumroad sends POST to your webhook endpoint (/cashier/gumroad/webhook)
  2. Middleware verifies the request (secret token, IP whitelist)
  3. Controller validates payload (50+ fields validated)
  4. Idempotency check prevents duplicate processing
  5. Event type determined from payload (never trust headers)
  6. Webhook call recorded in database
  7. Job dispatched to queue (or processed synchronously)
  8. Handler processes the webhook in a database transaction
  9. Events dispatched after transaction commits

Webhook Security

Three layers of protection:

  1. Secret Token Verification (optional):
CASHIER_GUMROAD_WEBHOOK_SECRET=your_secret_token
CASHIER_GUMROAD_VERIFY_WEBHOOK=true
  1. IP Whitelisting (optional):
CASHIER_GUMROAD_ALLOWED_IPS=1.2.3.4,5.6.7.8
  1. 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:

  1. Checks for X-License-Key header or license_key query parameter
  2. Verifies with Gumroad API (cached for 1 hour)
  3. Updates local license record
  4. Throws AccessDeniedHttpException if 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:

  1. Direct Checkout on Your Site

    • Cannot process payments directly
    • Must use overlay widget or redirect to Gumroad
  2. Payment Method Updates

    • Cannot update payment methods via API
    • Users must manage via Gumroad dashboard
  3. Proration Calculations

    • No API support for proration on plan changes
    • Gumroad handles this internally
  4. Subscription Cancellation via API

    • Cannot cancel subscriptions via API
    • Must be done via Gumroad dashboard or by user
  5. Custom Checkout CSS

    • Cannot customize checkout appearance
    • Limited to overlay widget styling
  6. 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:

  1. The CASHIER_GUMROAD_WEBHOOK_SECRET in your .env does not match the one used during subscription.
  2. The URL signature was generated with a different secret or event type.

Solution:

  1. Verify CASHIER_GUMROAD_WEBHOOK_SECRET is identical in your environment.
  2. Re-subscribe to your resources using the cashier-gumroad:subscribe --url=https://yourdomain.com command to regenerate valid URLs with the correct resource-specific signatures.
  3. If using TestWebhookCommand, it automatically uses the correct logic, but verify your .env configuration 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:

  1. Accepts product ID (not permalink)
  2. Fetches product from Gumroad API
  3. Uses the actual short_url from 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:

  1. User not found - Email in Gumroad doesn't match local user
  2. 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:

  1. Product ID not provided - Set default in config
  2. Invalid license key - Check key format
  3. API rate limit - Verification is cached for 1 hour

Solution:

CASHIER_GUMROAD_DEFAULT_PRODUCT_ID=your-product-id

Issue: Analytics Showing Zero

Check:

  1. Sales are being synced: Sale::count()
  2. Webhooks are processing: WebhookCall::unprocessed()->count()
  3. 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: generateCheckoutUrl now fetches product from API to get correct short_url (prevents 404s)
  • Fixed: Webhook race conditions using firstOrCreate for atomicity
  • Fixed: Analytics memory issues using SQL aggregation
  • Fixed: Double-counting in churn rate calculation
  • Added: getProductById method
  • 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.