DEV Community

Cover image for First Time with Stripe: Fully Serverless Ticket Sales
Michael Tedder for AWS Community Builders

Posted on

First Time with Stripe: Fully Serverless Ticket Sales

[ 本ブログの日本語版はこちらです。 ]

This blog entry was a part of the Stripe Advent Calendar 2021, originally posted in Japanese on December 3rd, 2021.


Hello everyone! My name is Michael Tedder and I'm one of the main organizers for Tokyo Demo Fest. I've been using AWS for over 9 years, and help run the JAWS-UG Sapporo AWS User Group in Japan, as well as assist with organizing larger AWS community events such as JAWS DAYS 2021 and JAWS PANKRATION 2021. I've also been an AWS Community Builder since 2020.

In this post, I'll be explaining how we implemented online ticket sales using Stripe on AWS for this year's Tokyo Demo Fest 2021. While this post does focus on the technical side of things, I'll try to cover everything necessary as if this is the first time you're using Stripe. All the sample code presented here is in Node.js 14.x.

About Tokyo Demo Fest

Tokyo Demo Fest (also called "TDF") is currently the only active demoparty in Japan. At a demoparty, people who are interested in computer programming and art gather together -- not only from Japan but also from all over the world -- and hold competitions and seminars about demo productions.

For all past TDFs we had previously been using PayPal for our ticket sales, but this year we finally made the switch over to Stripe. By using Stripe Checkout, we were able to get an implementation working in as little as a few hours.

System Design

Below is a diagram showing the overall design of the system. Note that as this is post is only about Stripe and ticket sales, other parts of the TDF system (such as live streaming) have been omitted for clarity.

Serverless Stripe Diagram

Visitors first access the TDF Website, published through Amplify. After choosing which of the two types of tickets to purchase, the visitor is then forwarded to Stripe Checkout. Once payment has been completed, Stripe calls out to our webhook, and we then send a votekey by email to the address which was entered during checkout. Visitors then use the votekey to access and login to the Wuhu Party System which is running on ECS/Fargate.

Creating Products in Stripe

This year's TDF has two different types of tickets available for purchase.

  1. Visitor Ticket (1,000 yen)
  2. Bronze Supporter (10,000 yen, includes T-shirt + shipping)

As the Visitor Ticket requires nothing special, it is simply added directly as a Product in the Stripe dashboard.

The Bronze Supporter ticket has a different price than the Visitor Ticket, and so requires registering a different Product in Stripe. However, as the Bronze Supporter ticket includes a T-shirt (with a selectable size of S/M/L/XL), we also need to add separate Products for each T-shirt size, resulting in a total of four more Products, and all with a price of zero (0 yen) as the T-shirt is included in the ticket price. Since the T-shirt is a physical item and needs to be shipped, we will also require the purchaser to enter their address during Checkout.

TDF Stripe Products

In order to require the address fields to be shown on the Checkout page, a Shipping rate is required. Since shipping is included in our Bronze Supporter ticket price, the amount for this is set to zero (0 yen) here as well.

TDF Stripe Shipping

Stripe API Key Security

Your Stripe API key needs to be kept secret, and it's important to ensure you never use it in your source code or accidentally commit it to source control. I typically use the Parameter Store functionality of AWS Systems Manager to store secrets when using them with Lambda functions, and I'll be doing the same here.

Stripe has various keys and values -- such as your API key, Webhook secret, and Price IDs -- that all need to be kept secret. You could store each of these into individual SecureStrings, but as the Standard parameter can store up to 4KB of data, it's much easier to encode all of the necessary keys and values into a block of JSON, and store the JSON as a single SecureString in Parameter Store.

I've hidden the values of our keys below, but this is the JSON we use to store our data for Stripe:

{"stripe_api_secret_key":"sk_test_51JU2XXXXXXXXXXXX",
"webhook_signing_secret":"whsec_TqW4TXXXXXXXXXXXX",
"product_visitor_ticket":"price_1JX1zXXXXXXXXXXXX",
"product_bronze_ticket":"price_1JrKaXXXXXXXXXXXX",
"product_tshirt_s":"price_1JrKlXXXXXXXXXXXX",
"product_tshirt_m":"price_1JrKmXXXXXXXXXXXX",
"product_tshirt_l":"price_1JrKnXXXXXXXXXXXX",
"product_tshirt_xl":"price_1JrKoXXXXXXXXXXXX",
"success_url":"https://tokyodemofest.jp/success.html",
"cancel_url":"https://tokyodemofest.jp#registration",
"shipping_rate":"shr_1JrKNXXXXXXXXXXXX",
"shipping_countries":"US,JP,IE,GB,NO,SE,FI,RU,PT,ES,FR,DE,CH,IT,PL,CZ,AT,HU,BA,BY,UA,RO,BG,GR,AU,NZ,KR,TW,IS"}
Enter fullscreen mode Exit fullscreen mode

In order to read the JSON from Parameter Store, we'll be using the aws-sdk package included with Node.js Lambda function runtime. Once we have the JSON configuration read, we'll pass the Stripe API key to the Stripe API initialization.

const loadConfig = async function() {
  const aws = require('aws-sdk');
  const ssm = new aws.SSM();
  const res = await ssm.getParameter( { Name: '/tdf/config-stripe', WithDecryption: true } ).promise();
  return JSON.parse(res.Parameter.Value);
}

exports.handler = async (event) => {
  const config = await loadConfig();
  const stripe = require('stripe')(config.stripe_api_secret_key);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Adding the HTML Purchase Buttons

Below are the ticket purchase buttons we've used for this year's TDF.

TDF Visitor Ticket

TDF Bronze Supporter

Note that even though there are two different ticket types, both of them POST through the same endpoint, as you can see in the HTML below.

TDF Ticket HTML

The way the Lambda function determines the difference between the ticket types is through the <input type="hidden" name="type" value="bronze"> tag. When type=bronze is set, the T-shirt size is also available through the tshirt value.

I'll explain more on how these values direct to the proper items within Stripe Checkout below.

Generating a Stripe Checkout URL

Once a visitor clicks on a ticket purchase button from the TDF website, we direct them to Stripe Checkout using a specifically generated URL. This generated URL contains all of the information needed to perform the checkout, including what products to purchase, and any other required information in order to make the purchase (such as whether a physical address is needed or not). The Stripe SDK automatically handles the generation of this URL, and we forward the browser to it using HTTP 303 (See Other).

In order to generate a Checkout session URL and forward the browser to it, we use the following code in our Lambda:

exports.handler = async (event) => {
  const config = await loadConfig();
  const stripe = require('stripe')(config.stripe_api_secret_key);

  const session = await stripe.checkout.sessions.create( {
    line_items: /* TODO */,
    payment_method_types: [ 'card' ],
    mode: 'payment',
    success_url: config.success_url,
    cancel_url: config.cancel_url
  } );

  const response = {
    statusCode: 303,
    headers: {
      'Location': session.url
    }
  };

  return response;
}
Enter fullscreen mode Exit fullscreen mode

The line_items in the session data specifies which products are to be purchased. We look at the data POSTed from the browser to the Lambda, and change what product should be added to the line_items value. Note that as payloads from the browser can be Base64 encoded, we check for this and decode as necessary.

  if (event.body) {
    let payload = event.body;
    if (event.isBase64Encoded)
      payload = Buffer.from(event.body, 'base64').toString();

    const querystring = require('querystring');
    const res = querystring.parse(payload);
    if ((res.type) && (res.type == 'bronze')) {
      // ...
    }
  }
Enter fullscreen mode Exit fullscreen mode

If this is a Visitor Ticket, we simply add it's product to the item array.

  let items = [ {
    price: config.product_visitor_ticket,
    quantity: 1
  } ];
Enter fullscreen mode Exit fullscreen mode

The resulting Stripe Checkout page when purchasing a Visitor Ticket looks like this:

Stripe Checkout Visitor

For the Bronze Supporter ticket, we match up the selected T-shirt size with the Price ID, and add two Products (both the ticket and the T-shirt) into the line_items array.

  let tshirt_type = config.product_tshirt_s;

  if (res.tshirt) {
    if (res.tshirt == 's') tshirt_type = config.product_tshirt_s;
    if (res.tshirt == 'm') tshirt_type = config.product_tshirt_m;
    if (res.tshirt == 'l') tshirt_type = config.product_tshirt_l;
    if (res.tshirt == 'xl') tshirt_type = config.product_tshirt_xl;
  }

  items = [ {
    price: config.product_bronze_ticket,
    quantity: 1
  }, {
    price: tshirt_type,
    quantity: 1
  } ];
Enter fullscreen mode Exit fullscreen mode

Next, we also need to require the entry of a physical address since the Bronze Supporter ticket includes the T-shirt. In the Checkout session data, this is specified with both shipping_rates and the supported countries (which countries you want to allow shipping to) via the allowed_countries in the shipping_address_collection.

Putting it all together, the resulting code looks like this:

  const session = await stripe.checkout.sessions.create( {
    line_items: [ {
      price: config.product_bronze_ticket,
      quantity: 1
    }, {
      price: tshirt_type,
      quantity: 1
    } ],
    payment_method_types: [ 'card' ],
    mode: 'payment',
    success_url: config.success_url,
    cancel_url: config.cancel_url,
    shipping_rates: [ config.shipping_rate ],
    shipping_address_collection: {
      allowed_countries: config.shipping_countries.split(',')
    }
  } );
Enter fullscreen mode Exit fullscreen mode

With the shipping fields added to the session data, you can now see that the physical address entry fields are now visible on the Stripe Checkout form:

Stripe Checkout Bronze

Just by implementing these few lines of code, you're now able to accept payments through Stripe Checkout. In the next session I'll cover how to configure a Webhook through Stripe, which can be used to send email or perform other processing in response to events from Stripe, such as once a payment has successfully completed.

Implementing a Stripe Webhook

As mentioned above, once a ticket purchase has successfully completed, TDF needs to email the ticket information (containing the login information to the Wuhu Party System) to the purchaser. This is accomplished by using a separate Lambda function (with access via API Gateway) as a Stripe Webhook.

TDF Stripe Webhook

Since implementing a webhook will be different depending on the whatever functionality you will need, I'll introduce up to what's required to properly decode the POST data from Stripe.

The first important thing to notice is that as your webhook URL is public, anyone can potentially access it. Stripe will always call your webhook with a signature included in the HTTP header, and you can use the Webhook secret key to verify that the payload data is correct and valid.

exports.handler = async (event) => {
  // require Stripe signature in header
  if (!event.headers['stripe-signature']) {
    console.log('no Stripe signature received in header, returning 400 Bad Request');
    return {
      statusCode: 400
    };
  }

  const sig = event.headers['stripe-signature'];

  // require an event body
  if (!event.body) {
    console.log('no event body received in POST, returning 400 Bad Request');
    return {
      statusCode: 400
    };
  }

  // decode payload
  let payload = event.body;
  if (event.isBase64Encoded)
    payload = Buffer.from(event.body, 'base64').toString();

  // construct a Stripe Webhook event
  const config = await loadConfig();
  const stripe = require('stripe')(config.stripe_api_secret_key);

  try {
    let ev = stripe.webhooks.constructEvent(payload, sig, config.webhook_signing_secret);
  } catch (err) {
    console.log('error creating Stripe Webhook event');
    console.log(err);
    return {
      statusCode: 400
    };
  }

  // ...TODO...

  return {
    statusCode: 200
  };
}
Enter fullscreen mode Exit fullscreen mode

Once you've successfully constructed the Stripe Webhook event from the payload, signature, and signing secret, you can examine the event type to determine how the status changed. For simple payments with Checkout, handling the following three events is typical:

  1. checkout.session.completed : A purchase through Stripe Checkout has been completed. Depending on the payment method, the actual transaction may not have completed yet. For credit cards, the payment_status is typically set to paid, which means the transaction has finished.
  2. checkout.session.async_payment_succeeded : An incomplete purchase that was notified through a previous completed event has succeeded.
  3. checkout.session.async_payment_failed : An incomplete purchase that was notified through a previous completed event has failed.

In order to implement the functionality for these three events, we mostly follow the same code as shown in the Stripe Sample Code documentation.

const createOrder = async function(session) {
  // we (TDF) don't need to do anything here
}

const fulfillOrder = async function(session) {
  // send ticket info to customer by email
  console.log('customer email is: ' + session.customer_details.email);
}

const emailCustomerAboutFailedPayment = async function(session) {
  // send email about failed payment
}

exports.handler = async (event) => {
  // ...
  const session = ev.data.object;
  switch (ev.type) {
    case 'checkout.session.completed':
      // save an order in your database, marked as 'awaiting payment'
      await createOrder(session);

      // check if the order is paid (e.g., from a card payment)
      // a delayed notification payment will have an `unpaid` status
      if (session.payment_status === 'paid') {
        await fulfillOrder(session);
      }
      break;

    case 'checkout.session.async_payment_succeeded':
      // fulfill the purchase...
      await fulfillOrder(session);
      break;

    case 'checkout.session.async_payment_failed':
      // send an email to the customer asking them to retry their order
      await emailCustomerAboutFailedPayment(session);
      break;
  }
Enter fullscreen mode Exit fullscreen mode

After implementing the three functions createOrder(), fulfillOrder(), and emailCustomerAboutFailedPayment(), your Webhook is complete.

In the event of an error or a non HTTP 2xx response in your Webhook, Stripe will automatically wait and handle retries as necessary. For more details, please see the Stripe Webhook Best Practices documentation.

...and that's it! You've just finished everything necessary to process payments with Stripe Checkout.

Merging Multiple Endpoints with API Gateway Custom Domains

With the implementation presented here, there are two endpoints: the Stripe Checkout URL generation, and the Stripe Webhook. It's completely fine to use the generated endpoint URL that you're given by API Gateway ( https://7q6f1e5os2.execute-api.ap-northeast-1... ), but configuring this to be something like stripe.tokyodemofest.jp as a subdomain within your domain looks better to the end user.

API Gateway Custom Domain

Using the above settings, we've configured both the checkout and fulfill Lambda functions and API Gateways into a single Custom Domain.

Thoughts on Using Stripe for the First Time for TDF

To be honest, implementing Stripe Checkout on serverless was incredibly easy. The amount of code needed is tiny, and allows anyone to accept payments from their website in just a few lines of code.

Additionally, the Stripe Dashboard shows full HTTP request and response logs along with helpful and detailed error messages that make debugging your Checkout or Webhook implementation easy.

Up until this point we've been fighting with keeping PayPal active for many years that my only regret is not switching over to using Stripe sooner. :)

Thank you for reading this post, hopefully it was helpful to someone. If you should have any questions, feel free to ask in the comment section below!

Top comments (0)