How to Add Stripe Payments to Your Keel App

Stripe is a mainstream payment processor that allows businesses to handle online payments. Keel, on the other hand, is an all-in-one backend platform, designed to give you everything you need to build your product.

In this guide, we will build a backend for handling Stripe payments, refunds, and plan changes with Keel; all within 15 minutes!

Prerequisites

Before getting started, you should have:

Setting Up a Todo App with Keel

In this guide, we will use a Keel todo app example (opens in a new tab) that will connect to Stripe.

Start by initialising a project in your terminal with:

npx keel init

Enter the directory where you want the project to be created.

Directory
Where should we create your project?: keel-stripe-demo

Choose the Starter template.

Template
Use the arrow keys to navigate: ↓ ↑ → ←
How would you like to start your new project?
  <Starter template>
  Blank project

Choose the Simple Todo app.

v Starter template
Use the arrow keys to navigate: ↓ ↑ → ←
Which template would you like to use?
  Simple Todo app

Follow the next steps like initialising a Git repo in your project, which is required for deploying to Keel.

Setting Up Stripe Models with Keel

Keel utilizes a schema-driven approach to application development. Meaning, that all you need to make a fully-fledged backend is to define a schema (opens in a new tab) comprising of data models, APIs, and permissions.

We will have to edit the todo/schema.keel file, which is currently populated with the todo example schema.

schema.keel
model Profile {
    fields {
        email Text?
        identity Identity
        subscriptionStatus SubscriptionStatus?
        subscriptionActive Boolean @default(false)
        subscriptionPlan Text?
        stripeCustomerId Text?
    }
}

We'll create an enum (opens in a new tab) for subscription statuses used in the subscriptionStatus field in the Profile model.

schema.keel
enum SubscriptionStatus {
    Paid
    AwaitingPayment
}

Setting Up Stripe Actions

Set up a handleBilling custom function (opens in a new tab) that will respond to Stripe's webhook events.

schema.keel
model Profile {
    fields {
        email Text?
        identity Identity
        subscriptionStatus SubscriptionStatus?
        subscriptionActive Boolean @default(false)
        subscriptionPlan Text?
        stripeCustomerId Text?
    }
    actions {
        write handleBilling(Any) returns (
            WebhookResponse
        ) {
            @permission(expression: true)
        }
    }
}

The @permission attribute (opens in a new tab) in this function has a true expression (opens in a new tab), meaning that all requests will be let through, without performing authentication - also known as a public route. This is important since we are unable to provide any authentication headers to the Stripe webhook events.

The function takes Any message (opens in a new tab) as input, as Stripe's webhook event data is heavily structured, it would bloat up the code and possibly cause failures in the future.

We will also add the WebhookResponse message (opens in a new tab), which will be sent back to Stripe to acknowledge the receipt of the webhook.

schema.keel
message WebhookResponse {
    received Boolean
}

Next, we'll create a function for changing plans for a subscription called changePlan.

schema.keel
model Profile {
    fields {
        email Text?
        identity Identity
        subscriptionStatus SubscriptionStatus?
        subscriptionActive Boolean @default(false)
        subscriptionPlan Text?
        stripeCustomerId Text?
    }
    actions {
        write handleBilling(Any) returns (
            WebhookResponse
        ) {
            @permission(expression: true)
        }
        write changePlan(stripeCustomerId, subscriptionPlan) returns (
            Any
        ) {
            @permission(roles: [Staff])
        }
    }
}

In this function, the @permission attribute requires authentication from the request sender, as the plan change is something we want to keep away from unauthorized actors.

Let's create the Staff role (opens in a new tab). The following schema will give everyone with a @keel.so email address the Staff role.

schema.keel
role Staff {
    domains {
        "keel.so"
    }
}

Next, create a function for issuing refunds. This will be used as an internal tool, which will in the end process the request to Stripe issuing a refund.

schema.keel
model Profile {
    fields {
        email Text?
        identity Identity
        subscriptionStatus SubscriptionStatus?
        subscriptionActive Boolean @default(false)
        subscriptionPlan Text?
        stripeCustomerId Text?
    }
    actions {
        write handleBilling(Any) returns (
            WebhookResponse
        ) {
            @permission(expression: true)
        }
        write changePlan(stripeCustomerId, subscriptionPlan) returns (
            Any
        ) {
            @permission(roles: [Staff])
        }
        write issueRefund(identity.email) returns (
            RefundData
        ) {
            @permission(roles: [Staff])
        }
    }
}

The function takes identity.email as an input, which has a relationship with the identity field, preventing from sending of phantom or non-existent data.

We'll also need to create a message for the RefundData, containing the enum representing the Stripe refund statuses.

schema.keel
message RefundData {
    status RefundStatus
    timestamp Timestamp? 
}

Create the RefundStatus enum which will be later used in the code to map to Stripe's refund statuses.

schema.keel
enum RefundStatus {
    Refunded
    RefundPending
    RefundFailed
    RefundRequiresAction
    RefundCanceled
}

And, finally, for the sake of simplicity of creating a profile, we'll create a function for that purpose.

schema.keel
model Profile {
    fields {
        email Text?
        identity Identity
        subscriptionStatus SubscriptionStatus?
        subscriptionActive Boolean @default(false)
        subscriptionPlan Text?
        stripeCustomerId Text?
    }
    actions {
        write handleBilling(Any) returns (
            WebhookResponse
        ) {
            @permission(expression: true)
        }
        write changePlan(stripeCustomerId, subscriptionPlan) returns (
            Any
        ) {
            @permission(roles: [Staff])
        }
        write issueRefund(identity.email) returns (
            RefundData
        ) {
            @permission(roles: [Staff])
        }
        create createProfile() with (email) {
            @set(profile.identity = ctx.identity)
            @set(profile.email = ctx.identity.email)
            @permission(expression: true)
        }
    }
}

Setting Up Webhooks in Stripe

Enter the test mode in Stripe's dashboard (opens in a new tab).

Under Developers > Webhooks (opens in a new tab) press Add endpoint. In the Endpoint URL, you need to provide the URL to the handleBilling action.

👋

Before heading to the Keel Console, you must deploy or run the project with:

keel run

In the Keel Console (opens in a new tab) go to API explorer. Find Handle billing under the Profile section. Copy the POST URL shown on the right side (e.g. https://staging-keel-demo-12345.keelapps.xyz/web/json/handleBilling (opens in a new tab)) and paste it into the Stripe's Webhook Endpoint URL.

Under Select events to listen to select Checkout and Select all Checkout events

Checkout events shown in the Stripe dashboard

Finally, press Add events, and after that Add endpoint

Endpoint setup shown in the Stripe dashboard

Getting a Stripe API Key

Under Developers > API keys, on the Secret key row press Reveal test key and copy it.

In order for Keel to perform requests to the Stripe API, it needs that key stored in a secure environment.

Open the keelconfig.yaml file and add the STRIPE_API_KEY under secrets:

keelconfig.yaml
# Visit https://docs.keel.so/authentication/getting-started for more information about authentication
auth:
 
# Visit https://docs.keel.so/envvars for more information about environment variables
environment:
 
# Visit https://docs.keel.so/secrets for more information about secrets
secrets:
 - name: STRIPE_API_KEY

Open your terminal window and run the following command:

keel secrets set STRIPE_API_KEY <API key from the Stripe Dashboard>

You should get the following response:

Secret STRIPE_API_KEY set for environment development

For production deployments, please consider using a restricted Stripe API key (opens in a new tab)

Setting Up Webhooks in Keel

Run keel generate in your terminal window, which will generate all the necessary TypeScript files for the actions defined in the schema.

After running the command, your folder structure should look like this:

├── functions
│   ├── changePlan.ts
│   ├── handleBilling.ts
│   ├── issueRefund.ts
│   └── setCompletion.ts
├── jobs
├── keel.mdx
├── keelClient.ts
├── keelconfig.yaml
├── package-lock.json
├── package.json
├── schema.keel
├── subscribers
└── tsconfig.json

Handling the Checkout Webhook

Import dependencies and create an enum to map Stripe's webhook event types.

handleBilling.ts
import { HandleBilling, SubscriptionStatus, models } from "@teamkeel/sdk";
 
export enum StripeWebhooks {
  Completed = "checkout.session.completed",
  PaymentFailed = "checkout.session.async_payment_failed",
}

In the HandleBilling function, add a switch statement that corresponds to the above enum.

handleBilling.ts
export default HandleBilling(async (ctx, inputs) => {
    switch (inputs.type) {
        case StripeWebhooks.Completed: {
            break;
        }
        case StripeWebhooks.PaymentFailed: {
            break;
        }
    }
    return {
        received: true
    };
});

In the StripeWebhooks.Completed case, update the Profile model with the new data received from the webhook.

handleBilling.ts
export default HandleBilling(async (ctx, inputs) => {
    switch (inputs.type) {
        case StripeWebhooks.Completed: {
            await models.profile.update({ id: inputs.data.object.metadata.id }, {
                subscriptionActive: true,
                stripeCustomerId: inputs.data.object.customer,
                subscriptionPlan: '{{YOUR_SUBSCRIPTION_PLAN}}',
                subscriptionStatus: SubscriptionStatus.Paid,
            })
            break;
        }
        case StripeWebhooks.PaymentFailed: {
            break;
        }
    }
    return {
        received: true
    };
});

Handling the Issue Refund Function

Install the Stripe package.

npm install stripe

Import dependencies and create a refund status map that maps Stripe's refund status codes to the RefundStatus enum defined in the schema.

issueRefund.ts
import { IssueRefund, RefundStatus, models } from "@teamkeel/sdk";
import Stripe from "stripe";
 
const RefundStatusMap = {
  pending: RefundStatus.RefundPending,
  succeeded: RefundStatus.Refunded,
  failed: RefundStatus.RefundFailed,
  requires_action: RefundStatus.RefundRequiresAction,
  canceled: RefundStatus.RefundCanceled
}

Find the customer's ID with an email query.

issueRefund.ts
import { IssueRefund, RefundStatus, models } from "@teamkeel/sdk";
import Stripe from "stripe";
 
const RefundStatusMap = {
  pending: RefundStatus.RefundPending,
  succeeded: RefundStatus.Refunded,
  failed: RefundStatus.RefundFailed,
  requires_action: RefundStatus.RefundRequiresAction,
  canceled: RefundStatus.RefundCanceled
}
 
export default IssueRefund(async (ctx, inputs) => {
  const stripe = new Stripe(ctx.secrets.STRIPE_API_KEY)
  const customerId = (await models.profile.where({ email: inputs.identityEmail || '' }).findOne()).stripeCustomerId!
});

Find last charge for that customer with their ID.

issueRefund.ts
import { IssueRefund, RefundStatus, models } from "@teamkeel/sdk";
import Stripe from "stripe";
 
const RefundStatusMap = {
  pending: RefundStatus.RefundPending,
  succeeded: RefundStatus.Refunded,
  failed: RefundStatus.RefundFailed,
  requires_action: RefundStatus.RefundRequiresAction,
  canceled: RefundStatus.RefundCanceled
}
 
export default IssueRefund(async (ctx, inputs) => {
  const stripe = new Stripe(ctx.secrets.STRIPE_API_KEY)
  const customerId = (await models.profile.where({ email: inputs.identityEmail || '' }).findOne()).stripeCustomerId!
  const charge = await stripe.charges.list({
    customer: customerId,
    limit: 1,
  })
});

Initiate a refund.

issueRefund.ts
import { IssueRefund, RefundStatus, models } from "@teamkeel/sdk";
import Stripe from "stripe";
 
const RefundStatusMap = {
  pending: RefundStatus.RefundPending,
  succeeded: RefundStatus.Refunded,
  failed: RefundStatus.RefundFailed,
  requires_action: RefundStatus.RefundRequiresAction,
  canceled: RefundStatus.RefundCanceled
}
 
export default IssueRefund(async (ctx, inputs) => {
  const stripe = new Stripe(ctx.secrets.STRIPE_API_KEY)
  const customerId = (await models.profile.where({ email: inputs.identityEmail || '' }).findOne()).stripeCustomerId!
  const charge = await stripe.charges.list({
    customer: customerId,
    limit: 1,
  })
  const refund = await stripe.refunds.create({
    charge: charge.data[0].id
  })
});

Return the data with the refund status and a timestamp.

issueRefund.ts
import { IssueRefund, RefundStatus, models } from "@teamkeel/sdk";
import Stripe from "stripe";
 
const RefundStatusMap = {
  pending: RefundStatus.RefundPending,
  succeeded: RefundStatus.Refunded,
  failed: RefundStatus.RefundFailed,
  requires_action: RefundStatus.RefundRequiresAction,
  canceled: RefundStatus.RefundCanceled
}
 
export default IssueRefund(async (ctx, inputs) => {
  const stripe = new Stripe(ctx.secrets.STRIPE_API_KEY)
  const customerId = (await models.profile.where({ email: inputs.identityEmail || '' }).findOne()).stripeCustomerId!
  const charge = await stripe.charges.list({
    customer: customerId,
    limit: 1,
  })
  const refund = await stripe.refunds.create({
    charge: charge.data[0].id
  })
  return {
    status: RefundStatusMap[refund.status!],
    timestamp: new Date()
  }
});

Handling Change Plan Function

Import the dependencies.

changePlan.ts
import { ChangePlan } from '@teamkeel/sdk';
import Stripe from 'stripe';

Initialise Stripe and find the latest subscription from the customer.

changePlan.ts
import { ChangePlan } from '@teamkeel/sdk';
import Stripe from 'stripe';
 
export default ChangePlan(async (ctx, inputs) => {
    const stripe = new Stripe(ctx.secrets.STRIPE_API_KEY)
    const customerId = (await models.profile.where({ email: inputs.email || '' }).findOne()).stripeCustomerId!
    const subscription = stripe.subscriptions.list({
        customer: customerId,
        limit: 1
    })
})

Update the subscription.

changePlan.ts
import { ChangePlan } from '@teamkeel/sdk';
import Stripe from 'stripe';
 
export default ChangePlan(async (ctx, inputs) => {
    const stripe = new Stripe(ctx.secrets.STRIPE_API_KEY)
    const customerId = (await models.profile.where({ email: inputs.email || '' }).findOne()).stripeCustomerId!
    const subscription = stripe.subscriptions.list({
        customer: customerId,
        limit: 1
    })
    await stripe.subscriptions.update(subscription['id'], {
        items: [
            {
                id: '{{SUB_ITEM_ID}}',
                price: '{{NEW_PRICE_ID}}',
            }
        ]
    })
})

Checking for Subscription Status

To check whether a user has a subscription active, you can access the subscriptionActive property from the relationship with a Profile model.

@permission(expression: profile.subscriptionActive)

For example, owner is of type Profile and has the subscriptionActive property, we can add the @permission attribute that checks if the owner has an active subscription like so:

schema.keel
model Project {
    fields {
        title Text
        owner Profile
        tasks Todo[]
    }
 
    actions {
        get getProject(id)
        list listProjects() {
            @where(project.owner.identity == ctx.identity)
        }
        delete deleteProject(id)
        create createProject() with (title) {
            @permission(expression: ctx.isAuthenticated)
            @permission(expression: project.owner.subscriptionActive)
            @set(project.owner.id = ctx.identity.id)
        }
        update updateProject(id) with (title)
    }
 
    @permission(
        actions: [get, list, update, delete],
        expression: project.owner.identity == ctx.identity
    )
}

Creating a Stripe Checkout Session

You can initiate a Stripe Checkout session with a simple cURL request. To begin with, you must provide the following POST data:

Run the following cURL, making sure to add your real data.

curl https://api.stripe.com/v1/checkout/sessions \
  -u "{{STRIPE_API_KEY}}:" \
  --data-urlencode success_url="https://example.com/success" \
  -d "line_items[0][price]"={{STRIPE_PRICE_ID}} \
  -d "line_items[0][quantity]"=1 \
  -d mode=payment \
  -d "metadata[id]"={{KEEL_USER_ID}} \
  -d customer={{STRIPE_CUSTOMER_ID}}

You will get a response, and at the end, a URL to the checkout page.

...
"ui_mode": "hosted",
"url": "https://checkout.stripe.com/c/pay/cs_test_a1GoLGkyD20C5J90ziZSZyUoGWqtOIunxuYr8PbRMKjuR4O9tjeG4KPZNj#fidkdWxOYHwnPyd1blpxYHZxWjA0SXNIT0ZEPEJ9TDxMPXIxMF9NbDxzSjZxSHJ0Qk5sSWpndnRHNTxAdTNtN0xUVX01PXNJUW1Ha1NPN09SQmJhS09XNjJ1ckk2SERjZjRuVG1jRkl8Y39VNTVoanNINmtqdicpJ2N3amhWYHdzYHcnP3F3cGApJ2lkfGpwcVF8dWAnPyd2bGtiaWBabHFgaCcpJ2BrZGdpYFVpZGZgbWppYWB3dic%2FcXdwYHgl"

Copy the url, paste it into your browser, and perform the test payment.

Stripe Checkout page

Now, you can go to the Database in the Keel Console, and select the profile table. You will see the data populated after the checkout has completed.

Profile table shown in the Database section of the Keel Console

Internal Tools for Managing Payments in Keel

Internal tools give you a simple and secure user interface to interact with your data out-of-the-box. A tool is an action (opens in a new tab) that can be accessed and run via the Keel Console (opens in a new tab).

Tools are automatically generated from APIs (opens in a new tab) that Keel creates and exposes from your schema. Keel creates a page for each action and handles the hard UI bits for you, such as navigation, forms, data tables, and filters. When you update your schema, tools will automatically update as well.

Navigate to your Keel Console (opens in a new tab). Under Operate select All tools.

Keel Console showing the Internal Tools tab

Press Web. From this panel, you can select the Issue refund or Change plan action, which will open the given internal tool.

A GIF showing the Issue Refund tool

Conclusion

Congratulations! In this guide, you've learned how to implement Stripe payments with Keel, in a very short timeframe. This guide should serve as a base for further implementation within Stripe's and Keel's ecosystem.

We've covered how to create custom functions, as well as internal tools that make it super easy to perform certain actions, like issuing refunds with a few clicks, reducing unnecessary hustle.

Take a look at some of real-world examples (opens in a new tab) of Keel being used with different technologies.