A native-feeling payment flow with Gumroad

Published in Programming on Mar 25, 2021

Gumroad is generally a pain if you want to integrate payments into an app and give them a native feel. But there is a way.

When I was adding payments to StackJobs, I was faced with a decision:

  1. use Stripe and deal with taxes myself
  2. use Paddle for the first time
  3. use Gumroad, which I can reliably set up within minutes, but miss out on a native payment flow

I chose option 3, because it was a weekend project, and I didn't want to spend a day or two implementing payments (or a day every month dealing with Value Added Torture).

But ... I still want the nice feeling payments. I want users to create a job posting, review it, and if they like what they see in the preview, pay.

Gumroad means telling users to go to a Gumroad URL, copy some sort of license code from the purchase email, and come back to the payment page and paste it in. Painful!

The one thing that Gumroad does make easy is fetching the sale when using Custom Delivery, i.e. redirecting the user to a custom URL after payment. You can't specify any details prior to setting up the payment intent, but you can fetch the details after the fact. Gumroad adds a query string to the redirect URL.

So I realized I can create a universal route for all payments, fetch the license key from the URL, and apply it to the latest visited item.

This will break on any site where users may visit multiple pages randomly, in between paying, and where there are multiple products.

But that's not my case! I just want them to pay for the last listing they visited.

So it's easy.

The payment flow

Here's how it works:

  1. User visits a product, the product gets stored in the session
  2. User clicks "pay", gets redirectd to a Gumroad page
  3. User pays, gets redirected to a universal route
  4. The route has a query parameter with the sale id
  5. We fetch the sale using the id, grab the license key
  6. The license key gets inserted into the correct input field
  7. The user confirms the use of the license (optional)

Implementation

This is also easy to implement.

Step 1: Tell your app how to talk to Gumroad

Go to your Gumroad settings, Advanced, Applications, and create an app. Generate a token and save it as the GUMROAD_TOKEN environment variable.

Make the services.gumroad.token config read from that environment variable.

Next, create a class for managing calls to the Gumroad API (to make things cleaner):

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;

class GumroadManager
{
    public static function fetchSale(string $sale_id): Response
    {
        return static::client()->get('https://api.gumroad.com/v2/sales/' . $sale_id);
    }

    public static function verifyLicense(string $code, string $product): Response
    {
        $response = static::client()->post('https://api.gumroad.com/v2/licenses/verify', [
            'product_permalink' => $product,
            'license_key' => $code,
        ]);

        return $response;
    }

    public static function client(): PendingRequest
    {
        return Http::withHeaders([
            'Authorization' => 'Bearer ' . config('services.gumroad.token')
        ]);
    }
}

Step 2: Create a Gumroad product

Create a Gumroad product and set the delivery method to "Custom Delivery", with the URL being https://yourapp.com/gumroad/handle (for example).

Step 3: Create a universal route

This route will handle the requests made after the user is redirected back from Gumroad, or after the user clicks "View content" in the email delivered by Gumroad.

Here's the code from my job board. Change it to use your route paths and names.

Route::get('/jobs/publish/callback', function () {
    if (session()->has('published_job')) {
        return redirect(route('jobs.postings.publish', array_merge(request()->query(), [
            'job' => session('published_job')
        ])));
    }

    return redirect(route('jobs.postings.index'));
})->name('jobs.publish.callback');

Step 4: Payment page

The redirect takes the user back to the payment page. That page has the following code:

if ($sale = request()->query('sale_id')) {
    $sale = GumroadManager::fetchSale($sale);

    if ($sale->json('success')) {
        $this->license = $sale->json('sale.license_key') ?? '';
    }
}

session()->put('published_job', $job->id);

Basically, if there's a sale_id in the query string, we try to fetch the sale with that id, and if it exists, we store the associated license key in the $license property (we're using Livewire here).

That property is wire:model'd to an input which will now have a value. The user will click "Publish" which will validate the license and publish the job.

We store the license key on the job model to make sure that one license key can only be used once in the entire database.

This page will also have a button that redirects the user to the Gumroad payment page. Use the product URL with ?wanted=true to only show the page with payment form rather than the product details

Step 5: Validating the license

Following the paragraph about ensuring that the license key is only used once, we need to validate the license key.

You can do that easily like this:

'license' => ['required', 'unique:job_postings', function ($attribute, $value, $fail) {
    if (! GumroadManager::verifyLicense($value, config('services.gumroad.product'))->json('success')) {
        $fail('Invalid license key.');
    }
}],

Of course, configure the product config key. And you may want to refactor this to a class Rule rather than using closure rules, depending on if you want to reuse this in other places of your codebase or not.

And that's all there's to it. I was pleasantly surprised how smooth the payment flow is, and so was everyone that I showed it to.

Hope this helps someone.

Newsletter

You will be notified whenever I publish an article about the topics you selected. Unsubscribe anytime.

Comments

Your comment will appear once it's approved (to fight spam).