Stripe Payment Link to a file download with a confirmation email in 30 minutes

This guide walks through payment links for file downloads — Stripe Payment Link checkout, automated delivery, and a confirmation email via Brevo (formerly Sendinblue).

by Mike Neverov

When building the Chapter theme for Ghost and deciding to ship it I asked myself - "how can I let customers download a file from a Stripe Payment Link?", only to find out just how difficult it is to ship a product in a form of a file (or any other downloadable) to a customer once they've paid.

It's supposed to be easy, right? Get Stripe, take payment, download a file. Well, it takes a while to achieve that and in this article I'll show you how to turn it into a seamless experience with Stripe Payment Link.

NOTE

The Chapter Ghost theme referenced here is now open source under the MIT license.

Video showing the full flow from Stripe Payment Link to file download.

Overview

Alternatives

Before writing something ourselves, let's recap the alternatives to this surprisingly difficult ask: KoFi, Gumroad, Lemonsqueezy, Themeforest and so on. Naturally, they all have their own little fees:

ServicePriceNote
KoFi5% + processing (3% + 30c)0% + processing with $9/month membership
Gumroad5% + processing (3.5% + 30c)0% + processing with $10/month membership
Lemonsqueezy5% + processing (3% + 30c)Didn't find a 0% subscription
Themeforest55% (fifty five, yes)12.5%-37.5% if exclusive (volume-dependant)

Whilst KoFi and Gumroad are incredible creator platforms and I applaud them, we can still beat with just processing fees and no monthly subscription whilst owning the entire process ourselves.

Not to mention that the existing options lengthen the "optimal" flow significantly with the redirects and so on, but we can deliver a seamless, 2-second processing experience.

What about Zapier?

Any time I'm thinking something like "well I want to do X when another service does Y" I am thinking Zapier and alternatives. I even signed up to get it rolling, only to find that Zapier's new Stripe Integration does NOT support Test Environment. I am baffled by how that made through Product and stayed that way, but that's not my place to complain. The thread below is:

Goal

Our goal is to achieve as "direct" of a flow as possible from the Stripe Payment Link to the file download. Stripe Payment Link is chosen because it's easy to embed anywhere, in any element of your page, is reputable (hosted by Stripe) and is hosted entirely by Stripe, meaning less downtime risk.

This is the flow we want our customers to experience.

In this article we'll use:

  • Stripe, specifically Stripe Payment Link
  • Brevo (formerly SendInBlue) with a 300 daily email free tier
  • Amazon Web Services for the S3 and Lambda

TIP

I understand that it's possible to further streamline this process by embedding a Stripe payment form directly into a website, but the purpose of this article is to cover the Payment Link specifically.

Browser Flow

The biggest issue we have to solve is serving a file in a secure way, without relying on "security through obscurity". Sadly, because we only have a static website, we can't really store or handle any backend logic there, so we'll need a separate place to process and verify the payment - Lambda, and a third place to securely store the file - S3.

Complete flow diagram showing all the components involved.

Once we're done, we will have created a system that:

  • Handles an HTTP redirect from the Stripe Payment Link with the payment session_id
  • Invokes a Lambda function, which in turn:
    • Verifies with Stripe if the payment did complete using the session_id
    • Generates a pre-signed URL from an S3 bucket with a set lifespan
    • Initiates an email delivery passing through the customer email and the URL
    • Redirects the request to the static page with a download_url parameter
  • Shows a static Confirmation page and initiates a browser download using the download_url parameter

Let's get going!

AWS Lambda, Gateway and S3

I'm assuming that you've already set up your AWS account.

To process the redirects and do the heavy lifting we'll use Lambdas - tiny pieces of self-contained code which will be spun up without a server any time a particular URL is called.

I won't go too deep into the code in this article since there is a plethora of information out there. You can find the main source code and the repo below:

The code for reference

S3 Bucket

To store our file we'll create a new bucket with standard settings:

  • Region of your liking, Europe (London) eu-west-2 for me
  • Bucket name of your choice, file-delivery for me
  • Leave Object Ownership on ACLs Disabled and leave "Block all public access" ticked on
  • Enable Bucket Versioning
  • Leave the Default encryption settings (SSE-S3, Enabled Bucket Key), leave Object Lock disabled
S3 Bucket Creation Images
S3 bucket creation steps.

Once created, upload the .zip archive with your files, done, ready to move on. We'll assume moving forward that our file is file-delivery/file.zip.

Uploading file to the S3 Bucket
Uploading a file to the S3 bucket.

IAM User

We want our customers to be able to both download the file right after they purchased it but also download it from their email link within the next 7 days (hard AWS limit). In order to do that, we'll need to set up a standalone IAM user, otherwise the Signed URL will expire within a few hours. Scroll here to learn why if you're curious.

  • Navigate to the Users section
  • Create a new User, fill out the form:
    • Name: file-delivery
    • Permissions: skip without selection
    • Confirm creation
  • View User once it's created and under Permissions tab, go to Add permissions -> Create inline policy.
  • Within the inline policy, select the S3 service and type in GetObject, select the checkbox with GetObject permission, then specify the ARN. Bucket name - file-delivery and object name - file.zip, confirm twice
  • Review the policy, provide a name, say, file-delivery-policy and finally "Create"

At the end of this step, your Permissions tab for the file-delivery user should list a file-delivery-policy containing the following JSON:

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::file-delivery/file.zip"
}
]
}

Finally, we need to create an Access Key for this user:

  • Press "Create access key"
  • Select "Application running on an AWS compute service", disregard the alternative recommendation and checkbox that you understand it
  • Leave the description tag blank
  • Securely store the Access Key and Secret Access Key, press "Done"
Perfect, now we're fully done with both S3 and IAM, halfway there!
S3 User Creation
Complete IAM user creation process.

Lambda

To set up the Lambda itself:

  • Navigate to Lambda Functions, create a new one
  • Select "from scratch", name it, select arm64 for cheaper execution
  • Once inside select "add trigger", API Gateway and "create a new API", "HTTP API", security - "Open"
Lambda Bucket Setup Images
Lambda function setup steps.

As you complete these steps we should end up on the following page with our Lambda skeleton ready to go.

Lambda overview page after initial setup.

We now have to complete the following steps:

  • Increase the allocated memory
  • Upload the Lambda Code
  • Configure the Lambda Environment Variables
  • Configure the API Gateway Stage Variables

Once done, our Lambdas and API Gateway (Stages) will be configured to process the requests as we want, grabbing the file from S3, creating a pre-signed URL and redirecting the user.

Increasing Lambda's memory

The only configuration change we want to make is set the Memory to 1,769 MB (or whatever AWS requires to get 1 vCPU when you're reading this) since that will massively increase the speed of our Lambda's processing, which will negate most of the cost increase as well:

  • Go to Configuration, General Configuration, Edit
  • Set the Memory value to 1769, feel free to leave storage at 512 and timeout at 3 sec
  • Save

Upload the Lambda Code

Download the .zip archive with code for our Lambda. Navigate to the "Code" tab and click "Upload from", .zip file.

Uploading Lambda code from a .zip file.

Configuring Environment Variables

The code relies on a number of Environment variables which we need to configure. The variable labels are provided below:

LabelNote
S3_ACCESS_KEY_IDThe IAM user Access Key
S3_SECRET_ACCESS_KEYThe IAM user Access Secret Key
S3_REGIONThe region of your S3 bucket. eu-west-2 in this guide
bucket_nameThe name of S3 bucket with the file, file-delivery in our case
object_keyThe file's object key, file.zip
redirect_hostThe url of our confirmation page
brevo_api_keyThe API Key from Brevo which we'll set up later, leave it as TBD
brevo_template_idThe ID of the template in Brevo, by default it's 1
link_expirationThe number of seconds the file will be accessible via the link. Cannot exceed 7 days, which is 604800 seconds
fallback_emailAn email to send message to in case customer haven't provided their email during checkout
support_emailA parameter to show customers if payment confirmation failed to contact
email_modeWhether the code should await email server response and log it. Unless the value is ensure-delivery it will be sent but the answer won't be awaited and logged
utm_parametersOptional UTM parameters to add after the redirect url. Enter &none by default

To fill in the values:

  • Go to Configuration tab
  • Expand the Environment Variables sub-tab
  • Press "Edit"
  • Fill out the dictionary of environment variables
Lambda Environment Variables Images
Configuring Lambda environment variables.

Note that at the current stage you won't have some of the values to configure this, namely redirect_host, brevo_api_key and brevo_template_id but we can fill them preemptively:

  • Redirect host is https://chapter.devguild.ltd/purchase-confirmed/?download_url= for me and something similar for you, make sure to include the download_url= parameter
  • Brevo API Key can be left as TBD
  • Template ID is most likely to be 1 if it's your first Brevo template, otherwise - it's sequentially numbered from the Brevo Templates page.

API Gateway

The final part of the AWS setup - the API Gateway. This is how we'll route the customer's browser to our Lambda we've just set up:

  • Navigate to Configuration Tab, Triggers sub-tab
  • Proceed to the API Gateway URL, in our case, file-delivery-API
  • Navigate to Stages in the left navigation menu
API Gateway stages view.

Now we'll set up two Stages, or, essentially Routes for our Gateway: test and prod.

TIP

This is a good practice, because we'll be able to do full cycle test runs in Test Mode and then just specify a different URL in Stripe Payment Link for live environment, ensuring identical behaviour. You know, the thing that Zapier improved away last year.

We'll create two identical stages, so let's start with a clean slate:

  • Go to default, Delete
  • Click "Create" in the Stages left menu:
    • Name this one test
    • Enable automatic deployment
    • Leave stage variables and tags empty for now
  • Repeat the above for prod Stage
  • Now, for both test and prod go to Stage Variables and create:
    • environment with test and production accordingly
    • stripe_secret_api_key with the Test Mode and Live Stripe Secret key which we'll add later, put TBD for now

Now that we've done it we have everything we need, including the test and prod URLs for our internal APIs, which you can see as InvokeURL which will be the base for the Lambda URLs:

  • test with https://ldk4dpw362.execute-api.us-east-1.amazonaws.com/test/file-delivery?session_id={CHECKOUT_SESSION_ID} as the value
  • prod with https://ldk4dpw362.execute-api.us-east-1.amazonaws.com/prod/file-delivery?session_id={CHECKOUT_SESSION_ID} as the value

NOTE

The ldk4dpw362.execute-api.us-east-1 part is unique to you and will be in your API Gateway InvokeURL field.

Gateway Settings Images
API Gateway stage configuration.

Recap

To recap what we just did:

  • We set up a single S3 bucket and IAM user with persistent access to it
  • We configured the Lambda's memory and uploaded the code to redirect the user's browser and send the email
  • We have configured the Lambda's Environment variables
  • Finally, we have configured the API Gateway with two routes: test and prod which we'll use with Stripe and Brevo and our Static Site to test whether everything was set up correctly.

Now that all of the above is achieved we can set up the other three important elements: Brevo, Stripe and the Static Site.

Brevo

Brevo allows us to send up to 300 daily emails for free, has an excellent API and templating. Steps here are easy:

Brevo API key page.

Once done, you'll have a key that looks like this: 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. Securely note it down and proceed.

Create a Template

Since we'll be sending like-for-like transactional emails why not make them look well and use Brevo's.

  • Navigate to Templates in Brevo
  • Create a new Template, fill out the form. If you're using a noreply email, expand the "Show Advanced Options" and fill out the Reply-To Email address
  • Proceed to the Design tab, select the Template you like, we'll go with "Sell a Product"
  • Customise the template the way you want, when creating a Link button, make sure to change the Link target field to {{params.download_url}}
  • Optionally, if you want to hide Brevo's logo (which usually requires a paid plan), you can delete the entire bottom Footer section after dragging out elements from it and applying the background to them individually, deleting the Footer
Brevo Template Setup Images
Brevo template creation and customization.

Update the Environment Variables

Now that we have two key variables we want to go back to the Lambda and complete the brevo_template_id and brevo_api_key variables with the ones we've obtained just now.

Stripe

Naturally, we'll need Stripe. Similar approach as Brevo:

Stripe API keys dashboard.

The key will looks something like this: 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

We will start with a Stripe Payment Link creation as the entry into our checkout flow.

  • Switch over to Test Mode
  • Create a new Payment Link
  • Configure the Product tab however you like
  • On the After Payment tab select Don't show confirmation page and paste the URL from the Lambda Trigger, something like https://ldk4dpw362.execute-api.us-east-1.amazonaws.com/test/file-delivery?session_id={CHECKOUT_SESSION_ID}, pasting the Trigger URL from Lambda and adding the Stripe Session ID

Make sure to create a Live Payment Link with the similar settings, but replace the route's /test part with /prod, see the API endpoint value in your Configuration -> Triggers menu.

Stripe Payment Link 'After Payment' configuration.
Stripe Payment Link Setup Images
Stripe Payment Link setup process.

That's all of the setup we'll need to do on Stripe's side, honest.

Update the Stage Variables

We have obtained the only Stage Variable that we now need to insert into the API Gateway Stage (Route) configurations for both Test and Live: stripe_secret_api_key. Replace TBD with the values obtained.

Static Site

We will need to add two elements on two pages on our static site which, I assume, you have for your product or site.

  • Our main page or pages for the button with the payment link
  • A standalone Confirmation page with a script that would find the download_url URL query parameter and initiate the download
<a href="https://buy.stripe.com/%your-link-here%"> Buy my file for $42.42 </a>

Code snippet to be inserted into the header.

<script>
document.addEventListener("DOMContentLoaded", function () {
var urlParams = new URLSearchParams(window.location.search);
var download_url = urlParams.get("download_url");
if (download_url) {
var linkElement = document.querySelector("#your-download-link a");
if (linkElement) {
linkElement.href = download_url;
}
var downloadElement = document.createElement("a");
downloadElement.href = download_url;
downloadElement.download = "";
downloadElement.style.display = "none";
downloadElement.click();
}
});
</script>

Code snippet to be inserted into the Confirmation page.

Now that we have our static site ready we can test.

Going live

Putting it together

Congratulations, you've done it. You can run a full flow in Test Mode, and, assuming you did everything correctly, you'll witness the same thing you did when you watched the video at the top of the page:

  • Go to your Static Page that has the button or otherwise navigate to your Stripe Payment Link from Test Mode, for me it's https://buy.stripe.com/test_somethingorrather
  • Once there, enter your own email address, a card with number 4242 4242 4242 4242, expiration any time in the future i.e. 12/30 and any CVV, i.e. 123.
  • Enter any name and valid address, check any box required
  • Wait for the payment to process, and after the checkmark wait another 2-3 seconds for everything on our end to fire and finish
  • Observe you being redirected to your Confirmation static page and a file download starting

Going live

Now you can replace the URL in your buttons and other various assets on your Static Site with the Stripe Live Payment Link URL.

That Payment Link URL also should have the Redirect customers to your website option enabled and configured to point to your Lambda API endpoint, but with /prod, so something like https://ldk4dpw362.execute-api.us-east-1.amazonaws.com/prod/file-delivery?session_id={CHECKOUT_SESSION_ID}

Ideally that's the only thing you need to do, as everything else is controlled via variables.

TIP

I would encourage you to also create a 100% off coupon for yourself via Stripe and test that the Live environment works. It should, but it's often not the case and you wouldn't want your customers to be the ones to tell you.

Debugging

There are many places where things can go wrong, but I'll equip you with tools to debug the most important ones:

  • AWS Lambda - if you want to see the logs of your Lambda running, go to the Monitor tab and from there go to View Cloudwatch Logs. This tab will show you "log streams" or, essentially, the calls that your Lambda processed as well as any logs you put in there using console.log
    • This is where you want to look if your emails are having issues as well as any errors will be logged in there
  • If you don't see anything in the Cloudwatch, find your API endpoint and try calling it from your browser, taking a real session_id from Stripe Test Mode, you can find them in the Workbench by pressing ~ in the Stripe Interface. This will allow you to confirm that your Gateway is routing requests correctly to the Lambda

With these two tools you should be good to go for most issues. Just make sure to fully test the entire flow, including email generation and link lifespan before moving to Live. And don't share your Test credentials if you decided to ship your real files from there as anyone would be able to "buy" them with the 4242 card.

Notes & Issues

Performance

With AWS Lambda we will usually have two performance scenarios:

  • Cold start, where the Lambda would have to first initialise and then start processing the request
  • Warm start, where the environment is already set up and some things are cached

As of version 1.2.0, the cold start is a sub-750ms: 450ms for initialisation and 300ms for running. Hot start is 200ms thanks to caching.

This means that for the user, especially if the Lambda is already "warm", the delay after Stripe Payment Link processing is almost unperceivable in most cases.

If you want, you can keep a Lambda always "warm" (get a server at that point?) but about $10/month.

NOTE

The figures assume that the email_mode is NOT set to ensure-delivery. If that environment variable is present, the time will increase by 120-150ms on average, for both cold and hot starts.

Changing linked variables

In this setup we have a few dependencies that might be not entirely obvious. I'll document them now:

  1. The IAM User Permission policy specifies both the Bucket and the ARN explicitly, meaning if you change the bucket_name and object_key without changing the IAM permissions the code would stop working. You're entirely free to allow unrestricted access to all bucket or even the entirety of S3, albeit, I would not recommend doing that.
  2. The API Gateway stage names and API Endpoints are connected since the Gateway Stage InvokeURL is what's responsible for the part of the Lambda path. Same applies to the Lambda name itself. If you were to change any of those you'll need to update your Stripe Payment Link redirect.

Stage vs Environment Variables

Whilst I have put most of the things into Environment Variables it could make perfect sense to store some of them in the Stage Variables, namely:

  • bucket_name and object_key. Storing them away would allow you to ship different files in test environment, making it impossible for someone to "buy" them with 4242 card
  • The S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY can also be moved into Stage Variables, especially if you want to already move bucket and object variables, since then it's likely you want to maintain two IAM users, one for test and the other one for production.
  • Technically both the brevo_api_key and brevo_template_id can be moved to Stage in case you would want to use different accounts, different keys on account or different templates
  • Same is true for the link_expiration as well as support and fallback emails, and even the redirect_host, all of that that really depends on your use case.

The provided token is malformed or otherwise invalid

If you're seeing this error when accessing your S3 Signed URLs you might be wondering why it occurs.

This is due to the nature of Lambda-based IAM credentials. They are rotated every hour or so, meaning that even if you have set the link_expiration to, say, a month, the link would stop working as soon as the rotation would occur. This is the reason why we're creating a standalone IAM user with S3 access policy.

A telltale sign of your links being misconfigured and relying upon "rotating" tokens is the URL itself. If it has an X-AMZ-SECURITY segment it's not going to live to see the next day. This is likely fixed by providing the S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY variables or setting up the role if you didn't in the first place.

Security through Obscurity

It's important to understand that no file delivered is ever truly secured. Once someone, even one person, has downloaded it - they can share it with their friends, both offline and online.

Therefore, there's a point of not making too much fuss over this entire AWS-Gateway-Lambda thingy and just inserting a fixed redirect with a fixed file uploaded to, say, S3 or online file hosting of your choice.

To do that you would go to the Stripe Payment Link and in the redirect field put something like https://chapter.devguild.ltd/purchase-confirmed/?download_url=https%3A%2F%2Ffile-delivery.s3.amazonaws.com%2Ffile.zip and do note that the download_url part has to be URL Encoded.

Just don't forget to actually go to the S3 Bucket and untick the Block all access box, and then go to the file and set in the Access control list (ACL) the Read opposite to Everyone (public access).

You might want to further change the name of the .zip file to make it less predictable. Just saying.

Bucket and File Public Access Images
Configuring public access for S3 bucket and file.

Acknowledgements

Support me directly

If you want to donate an arbitrary sum as a show of support you can do that via Stripe directly or via crypto below:

Support Mike Neverov

  • BTC: bc1qg367r0ncctmvperemsk7rc0yhzlhlsquqv9xhf
  • ETH: 0x3199351Ac82B55D3C57691c8Ac4778EA3E86c42a