[Slack App] Add paid features to your bot for Slack

updated on 19 September 2024

This post is part of tutorial series: Introduction

It's common for apps for Slack to have multiple plans - free and paid. 

Many Slack apps offer both free and paid plans, but implementing a payment flow to activate paid features for a specific Slack workspace isn't always straightforward. This challenge arises because there are two independent components that need to work together:

  1. Your app’s website with a checkout form.
  2. The customer's Slack workspace where your app is installed.

While creating paid feature plans is common in public Slack apps, there is limited information available on how to seamlessly integrate these two sides into a functioning payment flow.

In this article, I'll guide you through connecting these pieces to create a simple and effective paid features subscription flow. We’ll start with a high-level overview of the "Sign in with Slack" flow, followed by a detailed walkthrough of the implementation using a Node.js backend and Paddle as the payment provider. (Paddle is an alternative to Stripe that offers better VAT handling and built-in invoice support.)

This post is part of a series on creating and distributing a publicly installed Slack bot with paid features. You can find the accompanying repository with step-by-step commits that outline the process leading up to this point.

First approach - checkout with "Sign with Slack"

The first approach is to develop a complete authentication process via "Sign In With Slack" button and have user data from Slack authentication provider.

Then we can store authentication data on the client and pass Slack workspace ID to payment flow additional data. 

Once a user authenticates through Slack, we can extract essential information like the Slack workspace ID. This ID is critical as it uniquely identifies the workspace and links it to the payment flow. By storing the authentication data on the client side, you can pass the Slack workspace ID as additional data when initiating the payment process.

This approach ensures that payment processing and feature activation are tied directly to a specific Slack workspace, allowing for seamless subscription management and feature gating based on the chosen plan.

"Sign in with Slack" checkout flow
User interaction with App paid feature in Slack
User interaction with App paid feature in Slack

Second approach - checkout with email

The second approach is faster to develop and is often sufficient for managing payment checkouts for your first 50 customers.

If you're a solopreneur, you understand how crucial it is to launch quickly, and this method can help you do just that.

This approach doesn't require developing anything extra on your website.

Instead of authenticating users through Slack, you simply have them enter their work email in the checkout form.

The backend then saves the subscription details, along with the user’s email, and marks the subscription as "not matched" to any Slack workspace.

Email checkout form
Email checkout form

Once a user from a Slack workspace on the free plan interacts with your app, the backend will kick off a simple process:

  1. Fetch Unmatched Subscriptions: The backend queries the database for any new subscriptions that haven't been linked to a Slack workspace yet. Each subscription should have the user’s email, which Paddle provides, so it's easy to store with the subscription data.
  2. Check Slack for a Matching Email: The app makes a request to the Slack API to see if there’s a user in the current workspace with the same email as the one from the subscription.
  3. Link Subscription to Workspace: If a match is found, the backend updates the stored subscription to associate it with the Slack workspace. It also updates the workspace configuration to link it with the Paddle subscription.
  4. Respond to the user: Finally, the app returns a success response to the user, confirming that their workspace is now linked to the paid subscription.
User interaction with App paid feature in Slack
User interaction with App paid feature in Slack

Before writing the code, you should complete steps that are not covered in this tutorial. Subscribe to this blog, as I'm planning to release series of blog posts about how to create a bot for Slack, add paid features and checkout flow, distribute app publicly and submit it to Slack App Directory.

So by now you should have:

  • Slack bot backend created with node.js and Bolt.js library
  • Paddle account
  • Registered Paddle webhook
  • Configured Paddle products, checkout form
  • Website with checkout form
        How to build Paddle checkout form

Code steps

  • Handle Paddle subscription lifecycle events and store relevant data to DB
const handlePaddleEvent = async (eventData) => {
    switch (eventData.eventType) {
        case EventName.CustomerUpdated:
        case EventName.CustomerCreated:
            await handleCustomerCreated(eventData);
            break;
        case EventName.TransactionCompleted:
            // todo send email to user about successful payment and next steps
            await handleTransactionCompleted(eventData);
            break;
        case EventName.SubscriptionCreated:
            await handleSubscriptionCreated(eventData);
            break;
        default:
            console.log("[PADDLE] not-handled event", eventData.eventType);
    }
}

Store customer email:

const handleCustomerCreated = async (eventData) => {
    console.log(`[PADDLE] Customer ${eventData.data.id} was created`);
    const example = {
        "event_id": "evt_01j098s82y1gzgg69r4sqbhyyf",
        "event_type": "customer.created",
        "occurred_at": "2024-06-13T17:02:04.387938Z",
        "notification_id": "ntf_01j098s8fhgrc7g4a2yyykxn97",
        "data": {
            "id": "ctm_01j098s7dm9hzabrtrv3trjswv",
            "name": null,
            "email": "[email protected]",
            "locale": "en",
            "status": "active",
            "created_at": "2024-06-13T17:02:03.7Z",
            "updated_at": "2024-06-13T17:02:03.7Z",
            "custom_data": null,
            "import_meta": null,
            "marketing_consent": true
        }
    }

    return createSubscription(eventData.data.id, {
        paddle_customer_email: eventData.data.email,
    });
}

Store subscription data:

const handleSubscriptionCreated = async (eventData) => {
    console.log(`Subscription ${eventData.data.id} was created`);
    const example = {
        "event_id": "evt_01j098v9kt4zbsvwy0pdzex03s",
        "event_type": "subscription.created",
        "occurred_at": "2024-06-13T17:03:11.482909Z",
        "notification_id": "ntf_01j098v9tgqxymhbqd1sry3cdw",
        "data": {
            "id": "sub_01j098v8sar4r0jxh5dqkvb6vd",
            "items": [
                {
                    "price": {
                        "id": "pri_01j04e5ynw6q9adg6mxty21pk4",
                        "name": "ReviewNudgeBot Plus monthly",
                        "type": "standard",
                        "status": "active",
                        "quantity": {
                            "maximum": 1,
                            "minimum": 1
                        },
                        "tax_mode": "account_setting",
                        "created_at": "2024-06-11T20:00:11.453Z",
                        "product_id": "pro_01j04dw2a1z9s6xr2c9y1jaq0z",
                        "unit_price": {
                            "amount": "1000",
                            "currency_code": "USD"
                        },
                        "updated_at": "2024-06-13T16:35:26.015282Z",
                        "custom_data": null,
                        "description": "ReviewNudgeBot Plus monthly",
                        "import_meta": null,
                        "trial_period": null,
                        "billing_cycle": {
                            "interval": "month",
                            "frequency": 1
                        },
                        "unit_price_overrides": []
                    },
                    "status": "active",
                    "quantity": 1,
                    "recurring": true,
                    "created_at": "2024-06-13T17:03:10.634Z",
                    "updated_at": "2024-06-13T17:03:10.634Z",
                    "trial_dates": null,
                    "next_billed_at": "2024-07-13T17:03:09.865266Z",
                    "previously_billed_at": "2024-06-13T17:03:09.865266Z"
                }
            ],
            "status": "active",
            "discount": null,
            "paused_at": null,
            "address_id": "add_01j098s7e623anjce94efxxv2j",
            "created_at": "2024-06-13T17:03:10.634Z",
            "started_at": "2024-06-13T17:03:09.865266Z",
            "updated_at": "2024-06-13T17:03:10.634Z",
            "business_id": "biz_01j098t2dta4fym66c0z2b53dr",
            "canceled_at": null,
            "custom_data": null,
            "customer_id": "ctm_01j098s7dm9hzabrtrv3trjswv",
            "import_meta": null,
            "billing_cycle": {
                "interval": "month",
                "frequency": 1
            },
            "currency_code": "USD",
            "next_billed_at": "2024-07-13T17:03:09.865266Z",
            "transaction_id": "txn_01j098rkyax69765hrsh4v9rvj",
            "billing_details": null,
            "collection_mode": "automatic",
            "first_billed_at": "2024-06-13T17:03:09.865266Z",
            "scheduled_change": null,
            "current_billing_period": {
                "ends_at": "2024-07-13T17:03:09.865266Z",
                "starts_at": "2024-06-13T17:03:09.865266Z"
            }
        }
    }

    return updateSubscription(eventData.data.customerId, {
        paddle_subscription_id: eventData.data.id,
        paddle_subscription_status: eventData.data.status,
        paddle_subscription_cancelled_at: eventData.data.canceledAt,
        paddle_subscription_updated_at: eventData.data.updatedAt,
        paddle_subscription_currency_code: eventData.data.currencyCode,
        paddle_subscription_collection_mode: eventData.data.collectionMode,
        paddle_subscription_next_billed_at: eventData.data.nextBilledAt,
        paddle_subscription: JSON.stringify(eventData.data)
    });
}

Store subscription transaction completed. Until that event, the user has not yet made a payment or enrolled into the free try period and can drop at any moment.

const handleTransactionCompleted = async (eventData) => {
    console.log(`Transaction ${eventData.data.id} was completed`);
    const example = {
        "event_id": "evt_01j098va65hjv8fmnjqwdmjbzp",
        "event_type": "transaction.completed",
        "occurred_at": "2024-06-13T17:03:12.069692Z",
        "notification_id": "ntf_01j098vac1esvav6c9p97vfw1r",
        "data": {
            "id": "txn_01j098rkyax69765hrsh4v9rvj",
            "items": [
                {
                    "price": {
                        "id": "pri_01j04e5ynw6q9adg6mxty21pk4",
                        "name": "ReviewNudgeBot Plus monthly",
                        "type": "standard",
                        "status": "active",
                        "quantity": {
                            "maximum": 1,
                            "minimum": 1
                        },
                        "tax_mode": "account_setting",
                        "created_at": "2024-06-11T20:00:11.453Z",
                        "product_id": "pro_01j04dw2a1z9s6xr2c9y1jaq0z",
                        "unit_price": {
                            "amount": "1000",
                            "currency_code": "USD"
                        },
                        "updated_at": "2024-06-13T16:35:26.015282Z",
                        "custom_data": null,
                        "description": "ReviewNudgeBot Plus monthly",
                        "trial_period": null,
                        "billing_cycle": {
                            "interval": "month",
                            "frequency": 1
                        },
                        "unit_price_overrides": []
                    },
                    "price_id": "pri_01j04e5ynw6q9adg6mxty21pk4",
                    "quantity": 1,
                    "proration": null
                }
            ],
            "origin": "web",
            "status": "completed",
            "details": {
                "totals": {
                    "fee": "100",
                    "tax": "0",
                    "total": "1000",
                    "credit": "0",
                    "balance": "0",
                    "discount": "0",
                    "earnings": "900",
                    "subtotal": "1000",
                    "grand_total": "1000",
                    "currency_code": "USD",
                    "credit_to_balance": "0"
                },
                "line_items": [
                    {
                        "id": "txnitm_01j098t32k129dbha8cf9jn27m",
                        "totals": {
                            "tax": "0",
                            "total": "1000",
                            "discount": "0",
                            "subtotal": "1000"
                        },
                        "item_id": null,
                        "product": {
                            "id": "pro_01j04dw2a1z9s6xr2c9y1jaq0z",
                            "name": "ReviewNudgeBot Plus",
                            "type": "standard",
                            "status": "active",
                            "image_url": "https://ucarecdn.com/ab7fd42d-62b9-4168-b962-7ecd46323fa8/",
                            "created_at": "2024-06-11T19:54:47.489Z",
                            "updated_at": "2024-06-11T19:54:47.489Z",
                            "custom_data": null,
                            "description": "ReviewNudgeBot Plus package",
                            "tax_category": "standard"
                        },
                        "price_id": "pri_01j04e5ynw6q9adg6mxty21pk4",
                        "quantity": 1,
                        "tax_rate": "0",
                        "unit_totals": {
                            "tax": "0",
                            "total": "1000",
                            "discount": "0",
                            "subtotal": "1000"
                        }
                    }
                ],
                "payout_totals": {
                    "fee": "100",
                    "tax": "0",
                    "total": "1000",
                    "credit": "0",
                    "balance": "0",
                    "discount": "0",
                    "earnings": "900",
                    "fee_rate": "0.05",
                    "subtotal": "1000",
                    "grand_total": "1000",
                    "currency_code": "USD",
                    "exchange_rate": "1",
                    "credit_to_balance": "0"
                },
                "tax_rates_used": [
                    {
                        "totals": {
                            "tax": "0",
                            "total": "1000",
                            "discount": "0",
                            "subtotal": "1000"
                        },
                        "tax_rate": "0"
                    }
                ],
                "adjusted_totals": {
                    "fee": "100",
                    "tax": "0",
                    "total": "1000",
                    "earnings": "900",
                    "subtotal": "1000",
                    "grand_total": "1000",
                    "currency_code": "USD"
                }
            },
            "checkout": {
                "url": "https://reviewnudgebot.com/pay-test?_ptxn=txn_01j098rkyax69765hrsh4v9rvj"
            },
            "payments": [
                {
                    "amount": "1000",
                    "status": "captured",
                    "created_at": "2024-06-13T17:03:06.923828Z",
                    "error_code": null,
                    "captured_at": "2024-06-13T17:03:09.865266Z",
                    "method_details": {
                        "card": {
                            "type": "visa",
                            "last4": "4242",
                            "expiry_year": 2025,
                            "expiry_month": 11,
                            "cardholder_name": "Maksym Harbovskyi"
                        },
                        "type": "card"
                    },
                    "payment_method_id": "paymtd_01j098v54rwxrfcdhm2a3d12gn",
                    "payment_attempt_id": "6c273d16-4903-4046-b4b2-56a88aa50869",
                    "stored_payment_method_id": "9c487b06-f557-4fe6-9c8b-655768045c6b"
                }
            ],
            "billed_at": "2024-06-13T17:03:10.349048Z",
            "address_id": "add_01j098s7e623anjce94efxxv2j",
            "created_at": "2024-06-13T17:01:43.85161Z",
            "invoice_id": "inv_01j098v8tgb3tw22qecet77y9s",
            "updated_at": "2024-06-13T17:03:11.490722218Z",
            "business_id": "biz_01j098t2dta4fym66c0z2b53dr",
            "custom_data": null,
            "customer_id": "ctm_01j098s7dm9hzabrtrv3trjswv",
            "discount_id": null,
            "receipt_data": null,
            "currency_code": "USD",
            "billing_period": {
                "ends_at": "2024-07-13T17:03:09.865266Z",
                "starts_at": "2024-06-13T17:03:09.865266Z"
            },
            "invoice_number": "7492-10002",
            "billing_details": null,
            "collection_mode": "automatic",
            "subscription_id": "sub_01j098v8sar4r0jxh5dqkvb6vd"
        }
    }

    return updateSubscription(eventData.data.customerId, {
        is_subscription_completed: true,
        product_id: eventData.data.items[0].price.productId,
    });
}
  • Match Slack workspace with Paddle subscription

At any place you need to check access to paid feature - add this code:

const config = await getConfig(user.team_id);
let isSubscriptionActive = checkIsSubscriptionActive(config);

if (!isSubscriptionActive) {
   isSubscriptionActive = await tryMatchTeamWithSubscription(user.team_id, client);
}

This is high-level code, but if you need some details for reference, here is how paid subscription is checked in my case:

const getConfig = async (teamId = null) => {
    console.log('[DYNAMODB] Get config', teamId);

    return getItemByCompositeKey(
        TABLE_NAME,
        {
            team_id: teamId,
            record_type: RECORD_TYPE_GLOBAL
        }).then((data) => {
            return {
                team_id: teamId,
                ...DEFAULT_CONFIG,
                ...data.Item
            };
        });
}
function checkIsSubscriptionActive(config) {
    return !!config.paddle_is_subscription_active;
}

And the code to do an actual check

const tryMatchTeamWithSubscription = async (teamId, client) => {
    const unassignedSubscriptions = await getUnassignedSubscriptions();

    console.log('Unassigned subscriptions', unassignedSubscriptions);

    for (const subscription of unassignedSubscriptions) {
        if (await tryAssignSubscriptionToTeam(subscription, teamId, client)) {
            return true;
        }
    }
}

I'm using DynamoDB, so this how to list unassigned subscriptions:

const getUnassignedSubscriptions = async () => {
    console.log('[DYNAMODB] Get unassigned subscriptions');
    const subscriptions = await getAllSubscriptions();
    return subscriptions.filter(subscription => !subscription.slack_team_id);
}

const getAllSubscriptions = async () => {
    return getAllItems(
        TABLE_NAME,
    ).then((data) => {
        console.log('[DYNAMODB] Get all subscriptions', data.Items);
        return data.Items ?? [];
    });
}

Try match Slack workspace and Paddle subscription with user email

const tryAssignSubscriptionToTeam = async (subscription, teamId, client) => {
    const user = await getSlackUserByEmail(client, subscription.paddle_customer_email);
    if (!user) {
        return false;
    }

    await updateSubscriptionSlackTeam(subscription.paddle_customer_id, teamId);
    await updateConfigPaddleCustomerId(
        teamId,
        subscription.paddle_customer_id,
        !!subscription.paddle_subscription_status && subscription.paddle_subscription_status !== 'cancelled',
    );
    return true;
}

Check if user with subscription email exists in current workspace

Important: to perform lookup by email, bot should have a scope: users:read.email
Method API reference: https://api.slack.com/methods/users.lookupByEmail

async function getSlackUserByEmail(client, email) {
    if (!email) {
        return null;
    }
    try {
        const response = await client.users.lookupByEmail({ email });
        console.log('Lookup by email result', email, response);
        return response.user;
    } catch (error) {
        if (error.data && error.data.error === 'users_not_found') {
            return null;
        } else {
            throw error;
        }
    }
}

Then update stored subscription:

const updateSubscriptionSlackTeam = async (customerId, slackTeamId) => {
    console.log('[DYNAMODB] Update subscription', customerId, slackTeamId);
    const subscription = await getSubscriptionByCustomerId(customerId);
    const updatedSubscription = {
        ...subscription,
        paddle_customer_id: customerId,
        slack_team_id: slackTeamId
    }
    return putItem(TABLE_NAME, updatedSubscription);
}

And update workspace config as matched and linked:

const updateConfigPaddleCustomerId = async (teamId, paddleCustomerId, isSubscriptionActive) => {
    console.log('[DYNAMODB] Update config: Paddle subscription', teamId, paddleCustomerId);
    const config = await getConfig(teamId);
    const updatedConfig = {
        ...config,
        team_id: teamId,
        record_type: RECORD_TYPE_GLOBAL,
        last_updated: new Date().toISOString(),
        paddle_customer_id: paddleCustomerId,
        paddle_is_subscription_active: isSubscriptionActive,
    };
    return putItem(TABLE_NAME, updatedConfig);
}

Conclusion

This post is part of a series walking you through the step-by-step creation of a complete Slack bot product, including:

  • Building the bot itself
  • Setting up public installation
  • Deploying infrastructure to AWS
  • Developing paid features and a checkout flow
  • Submitting your app to the Slack App Directory
  • Bonus: How to create a no-code landing page for your bot in just one day, complete with an installation button, demo booking, pricing page with checkout, free live support chat, and a custom domain

I hope this helped clarify how to easily introduce paid features to your Slack bot. The code snippets should guide you through developing it on your own. If you run into any issues or need more details on creating a checkout flow, feel free to reach out or check repo with all the code 

Thanks for reading, and stay tuned for more posts!

Useful links: 

  • Checklist on how to create and monetize Slack bot in 2024: Step-by-step how to: Link
  • Paddle setup checklist: 
    https://developer.paddle.com/build/onboarding/set-up-checklist
  • Paddle subscription creation and lifecycle: 
    https://developer.paddle.com/build/lifecycle/subscription-creation
  • Slack API reference: 
    https://api.slack.com/methods/users.lookupByEmail
  • Repo with step-by-step commits how to create complete bot for Slack:
    https://github.com/maksouth/code-review-reminder-steps/tree/step-10-slack-bot-add-paid-features

Read more

Built on Unicorn Platform