├── .gitignore ├── .env.example ├── client ├── css │ ├── account.css │ ├── common.css │ └── login.css ├── login.js ├── account.html ├── login.html └── account.js ├── package.json ├── LICENSE.md ├── README.md └── server.js /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules/ -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | STATIC_DIR='./client' 2 | PAYSTACK_SECRET_KEY= 3 | PORT='5000' 4 | SERVER_URL='http://localhost:5000' -------------------------------------------------------------------------------- /client/css/account.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: auto; 3 | max-width: 680px; 4 | padding: 0 15px; 5 | } 6 | -------------------------------------------------------------------------------- /client/css/common.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | 6 | .btn-primary { 7 | background-color: #00c3f7; 8 | border: none; 9 | } 10 | .btn-primary:hover { 11 | background-color: #00c2f7c1; 12 | } 13 | -------------------------------------------------------------------------------- /client/css/login.css: -------------------------------------------------------------------------------- 1 | body { 2 | height: 100vh; 3 | display: flex; 4 | align-items: center; 5 | background-color: #f5f5f5; 6 | } 7 | 8 | #signin-form { 9 | width: 100%; 10 | max-width: 330px; 11 | padding: 15px; 12 | margin: 0 auto; 13 | } 14 | 15 | #signin-form .form-control { 16 | position: relative; 17 | box-sizing: border-box; 18 | height: auto; 19 | padding: 10px; 20 | font-size: 16px; 21 | } 22 | 23 | #signin-form input[type="email"] { 24 | margin-bottom: -1px; 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Sample Subscriptions App", 3 | "version": "1.0.0", 4 | "description": "A sample application showing how to use Paystack's Subscription API", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server.js", 8 | "dev": "nodemon server.js" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@paystack/paystack-sdk": "^1.0.1", 15 | "dotenv": "^16.0.1", 16 | "express": "^4.19.2" 17 | }, 18 | "devDependencies": { 19 | "nodemon": "^2.0.21" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/login.js: -------------------------------------------------------------------------------- 1 | window.addEventListener("DOMContentLoaded", async () => { 2 | const form = document.getElementById("signin-form"); 3 | if (form) { 4 | form.addEventListener("submit", async (e) => { 5 | e.preventDefault(); 6 | 7 | const email = document.getElementById("email").value; 8 | 9 | const customer = await fetch("/create-customer", { 10 | method: "post", 11 | headers: { 12 | "Content-Type": "application/json", 13 | }, 14 | body: JSON.stringify({ 15 | email: email, 16 | }), 17 | }) 18 | .then((res) => res.json()) 19 | .catch((error) => console.log(error)); 20 | window.localStorage.setItem("customer_email", email); 21 | window.localStorage.setItem("customer_code", customer.customer_code); 22 | window.localStorage.setItem("customer_id", customer.id); 23 | 24 | window.location.href = "/account.html"; 25 | }); 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 PaystackOSS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/account.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Account 5 | 6 | 7 | 11 | 12 | 18 | 19 | 20 | 21 | 22 |
23 |

Account Dashboard

24 |
25 |
26 |
27 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /client/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 17 | 18 | 19 | Sign In 20 | 21 | 22 |
23 |
24 |

Log in

25 |

26 | A simple demo on collecting subscription payments using Paystack 27 |

28 |
29 |
30 | 37 |
38 | 39 | 40 |
41 |
42 |
43 | 44 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paystack Subscriptions Sample App 2 | 3 | This sample application shows how to integrate Paystack's Subscriptions API in your apps. For the official documentation for Paystack Subscriptions, [head over to the docs](https://paystack.com/docs/payments/subscriptions) 4 | 5 | 6 | ## Demo 7 | 8 | View a live demo of the app [here](https://codesandbox.io/p/github/PaystackOSS/sample-subscriptions-app/draft/condescending-borg). 9 | 10 | ## Get Started 11 | 12 | ### Requirements 13 | - **A Paystack account**: If you don't already have one, [sign up for a Paystack account](https://dashboard.paystack.com/#/signup). You'll need to do this to get your API keys. 14 | - **API keys**: You can grab these [from your Paystack dashboard](https://dashboard.paystack.com/#/settings/developers) 15 | - **Existing plans**: You'll need to have existing (active) plan objects that you can subscribe your customers to. If you don't already have any plans, you can just create a couple [from your Paystack dashboard](https://dashboard.paystack.com/#/plans?status=active) 16 | 17 | ### Running the sample locally 18 | 19 | 1. Clone this repo: 20 | ``` 21 | git clone https://github.com/PaystackOSS/sample-subscriptions-app 22 | ``` 23 | 24 | 2. Navigate to the root directory and install dependencies 25 | ``` 26 | npm install 27 | ``` 28 | 29 | 3. Rename the `.env.example` file to `.env` and add your Paystack secret key, and your server's URL including the port. This is necessary for the callback URL you'll be redirected to after completing a transaction. You can also change the default port from 5000 to a port of your choosing: 30 | 31 | ``` 32 | PAYSTACK_SECRET_KEY=sk_domain_xxxxxx 33 | SERVER_URL=http://localhost:5000 34 | ``` 35 | 36 | 4. Start the application 37 | 38 | ``` 39 | npm start 40 | ``` 41 | 42 | 5. Visit http://localhost:5000 in your browser to interact with the app. You should be able to signup/login, subscribe to a plan, and view/manage your existing plan(s). 43 | 44 | 45 | 46 | ## Contributing 47 | If you notice any issues with this app, please [open an issue](https://github.com/PaystackOSS/sample-subscriptions-app/issues/new). PRs are also more than welcome, so feel free to [submit a PR](https://github.com/PaystackOSS/sample-subscriptions-app/compare) to fix an issue, or add a new feature! 48 | 49 | ## License 50 | 51 | This repository is made available under the MIT license. Read [LICENSE.md](https://github.com/PaystackOSS/sample-subscriptions-app/blob/master/LICENSE.md) for more information. 52 | 53 | -------------------------------------------------------------------------------- /client/account.js: -------------------------------------------------------------------------------- 1 | window.addEventListener('DOMContentLoaded', async () => { 2 | const customer_id = window.localStorage.getItem('customer_id'); 3 | const customer_email = window.localStorage.getItem('customer_email'); 4 | 5 | const subscriptions = await fetch( 6 | `/subscription?customer=${customer_id}` 7 | ).then((res) => res.json()); 8 | let subscription = subscriptions[0]; 9 | 10 | if (subscription) { 11 | const subscriptionInfoDiv = document.getElementById('subscription-info'); 12 | subscriptionInfoDiv.innerHTML = ` 13 |
14 |

Hi ${customer_email}

15 |

You're currently on the ${subscription.plan.name} plan

16 |

17 | Status: ${subscription.status} 18 |

19 |

20 | Subscription Code: ${subscription.subscription_code} 21 |

22 |

23 | Card on file: ${subscription.authorization.brand} card ending in ${ 24 | subscription.authorization.last4 25 | } expires on ${subscription.authorization.exp_month}/${ 26 | subscription.authorization.exp_year 27 | } 28 |

29 |

30 | Next payment date: ${new Date(subscription.next_payment_date)} 31 |

32 | 33 | Manage subscription
36 | `; 37 | } else { 38 | const plans = await fetch('/plans', { 39 | method: 'get', 40 | }) 41 | .then((res) => res.json()) 42 | .catch((error) => console.log(error)); 43 | 44 | let accountDashDiv = document.getElementById('account-dashboard'); 45 | accountDashDiv.innerHTML += 46 | '

You are currently not on any plan. Select a plan below to subscribe.

'; 47 | 48 | let selectPlanDiv = document.createElement('div'); 49 | selectPlanDiv.style.display = 'flex'; 50 | selectPlanDiv.style.flexDirection = 'row'; 51 | 52 | plans.forEach((plan) => { 53 | let planDiv = document.createElement('div'); 54 | planDiv.innerHTML = ` 55 |
56 |
57 |
${plan.name}
58 |

${plan.currency} ${ 59 | plan.amount / 100 60 | }/month

61 |

${plan.description}

62 | 65 |
66 |
67 | `; 68 | 69 | selectPlanDiv.append(planDiv); 70 | }); 71 | accountDashDiv.append(selectPlanDiv); 72 | } 73 | }); 74 | 75 | async function signUpForPlan(plan_code) { 76 | let email = window.localStorage.getItem('customer_email'); 77 | let { authorization_url } = await fetch('/initialize-transaction-with-plan', { 78 | method: 'POST', 79 | headers: { 80 | 'Content-Type': 'application/json', 81 | }, 82 | body: JSON.stringify({ 83 | email, 84 | amount: 50000, 85 | plan: plan_code, 86 | }), 87 | }) 88 | .then((res) => res.json()) 89 | .catch((error) => console.log(error)); 90 | 91 | window.location.href = authorization_url; 92 | } 93 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: './.env' }); 2 | const express = require('express'); 3 | const { resolve } = require('path'); 4 | const Paystack = require('@paystack/paystack-sdk'); 5 | const crypto = require('crypto'); 6 | 7 | const app = express(); 8 | const paystack = new Paystack(process.env.PAYSTACK_SECRET_KEY); 9 | 10 | app.use(express.json()); 11 | app.use(express.urlencoded({ extended: true })); 12 | app.use(express.static(process.env.STATIC_DIR)); 13 | 14 | app.get('/', async (req, res) => { 15 | const path = resolve(process.env.STATIC_DIR + '/login.html'); 16 | res.sendFile(path); 17 | }); 18 | 19 | app.get('/plans', async (req, res) => { 20 | let fetchPlansResponse = await paystack.plan.list({}); 21 | 22 | if (fetchPlansResponse.status === false) { 23 | console.log('Error fetching plans: ', fetchPlansResponse.message); 24 | return res 25 | .status(400) 26 | .send(`Error fetching subscriptions: ${fetchPlansResponse.message}`); 27 | } 28 | 29 | return res.status(200).send(fetchPlansResponse.data); 30 | }); 31 | 32 | app.get('/subscription', async (req, res) => { 33 | try { 34 | let { customer } = req.query; 35 | 36 | if (!customer) { 37 | throw Error('Please include a valid customer ID'); 38 | } 39 | 40 | let fetchSubscriptionsResponse = await paystack.subscription.list({ 41 | customer, 42 | }); 43 | 44 | if (fetchSubscriptionsResponse.status === false) { 45 | console.log( 46 | 'Error fetching subscriptions: ', 47 | fetchSubscriptionsResponse.message 48 | ); 49 | return res 50 | .status(400) 51 | .send( 52 | `Error fetching subscriptions: ${fetchSubscriptionsResponse.message}` 53 | ); 54 | } 55 | 56 | let subscriptions = fetchSubscriptionsResponse.data.filter( 57 | (subscription) => 58 | subscription.status === 'active' || 59 | subscription.status === 'non-renewing' 60 | ); 61 | 62 | return res.status(200).send(subscriptions); 63 | } catch (error) { 64 | console.log(error); 65 | return res.status(400).send(error.message); 66 | } 67 | }); 68 | 69 | app.post('/initialize-transaction-with-plan', async (req, res) => { 70 | try { 71 | let { email, amount, plan } = req.body; 72 | 73 | if (!email || !amount || !plan) { 74 | throw Error( 75 | 'Please provide a valid customer email, amount to charge, and plan code' 76 | ); 77 | } 78 | 79 | let initializeTransactionResponse = await paystack.transaction.initialize({ 80 | email, 81 | amount, 82 | plan, 83 | channels: ['card'], // limiting the checkout to show card, as it's the only channel that subscriptions are currently available through 84 | callback_url: `${process.env.SERVER_URL}/account.html`, 85 | }); 86 | 87 | if (initializeTransactionResponse.status === false) { 88 | return console.log( 89 | 'Error initializing transaction: ', 90 | initializeTransactionResponse.message 91 | ); 92 | } 93 | let transaction = initializeTransactionResponse.data; 94 | return res.status(200).send(transaction); 95 | } catch (error) { 96 | return res.status(400).send(error.message); 97 | } 98 | }); 99 | 100 | app.post('/create-subscription', async (req, res) => { 101 | try { 102 | let { customer, plan, authorization, start_date } = req.body; 103 | 104 | if (!customer || !plan) { 105 | throw Error('Please provide a valid customer code and plan ID'); 106 | } 107 | 108 | let createSubscriptionResponse = await paystack.subscription.create({ 109 | customer, 110 | plan, 111 | authorization, 112 | start_date, 113 | }); 114 | 115 | if (createSubscriptionResponse.status === false) { 116 | return console.log( 117 | 'Error creating subscription: ', 118 | createSubscriptionResponse.message 119 | ); 120 | } 121 | let subscription = createSubscriptionResponse.data; 122 | return res.status(200).send(subscription); 123 | } catch (error) { 124 | return res.status(400).send(error.message); 125 | } 126 | }); 127 | 128 | app.post('/cancel-subscription', async (req, res) => { 129 | try { 130 | let { code, token } = req.body; 131 | 132 | if (!code || !token) { 133 | throw Error( 134 | 'Please provide a valid customer code and subscription token' 135 | ); 136 | } 137 | 138 | let disableSubscriptionResponse = await paystack.subscription.disable({ 139 | code, 140 | token, 141 | }); 142 | 143 | return res.send('Subscription successfully disabled'); 144 | } catch (error) { 145 | return res.status(400).send(error); 146 | } 147 | }); 148 | 149 | app.get('/update-payment-method', async (req, res) => { 150 | try { 151 | const { subscription_code } = req.query; 152 | const manageSubscriptionLinkResponse = 153 | await paystack.subscription.manageLink({ 154 | code: subscription_code, 155 | }); 156 | if (manageSubscriptionLinkResponse.status === false) { 157 | console.log(manageSubscriptionLinkResponse.message); 158 | } 159 | 160 | let manageSubscriptionLink = manageSubscriptionLinkResponse.data.link; 161 | return res.redirect(manageSubscriptionLink); 162 | } catch (error) { 163 | console.log(error); 164 | } 165 | }); 166 | 167 | app.post('/create-customer', async (req, res) => { 168 | try { 169 | let { email } = req.body; 170 | 171 | if (!email) { 172 | throw Error('Please include a valid email address'); 173 | } 174 | 175 | let createCustomerResponse = await paystack.customer.create({ 176 | email, 177 | }); 178 | 179 | if (createCustomerResponse.status === false) { 180 | console.log('Error creating customer: ', createCustomerResponse.message); 181 | return res 182 | .status(400) 183 | .send(`Error creating customer: ${createCustomerResponse.message}`); 184 | } 185 | let customer = createCustomerResponse.data; 186 | 187 | // This is where you would save your customer to your DB. Here, we're mocking that by just storing the customer_code in a cookie 188 | res.cookie('customer', customer.customer_code); 189 | return res.status(200).send(customer); 190 | } catch (error) { 191 | console.log(error); 192 | return res.status(400).send(error.message); 193 | } 194 | }); 195 | 196 | // Handle subscription events sent by Paystack 197 | app.post('/webhook', async (req, res) => { 198 | const hash = crypto 199 | .createHmac('sha512', secret) 200 | .update(JSON.stringify(req.body)) 201 | .digest('hex'); 202 | if (hash == req.headers['x-paystack-signature']) { 203 | const webhook = req.body; 204 | res.status(200).send('Webhook received'); 205 | 206 | switch (webhook.event) { 207 | case 'subscription.create': // Sent when a subscription is created successfully 208 | case 'charge.success': // Sent when a subscription payment is made successfully 209 | case 'invoice.create': // Sent when an invoice is created to capture an upcoming subscription charge. Should happen 2-3 days before the charge happens 210 | case 'invoice.payment_failed': // Sent when a subscription payment fails 211 | case 'subscription.not_renew': // Sent when a subscription is canceled to indicate that it won't be charged on the next payment date 212 | case 'subscription.disable': // Sent when a canceled subscription reaches the end of the subscription period 213 | case 'subscription.expiring_cards': // Sent at the beginning of each month with info on what cards are expiring that month 214 | } 215 | } 216 | }); 217 | 218 | app.listen(process.env.PORT, () => { 219 | console.log(`App running at http://localhost:${process.env.PORT}`); 220 | }); 221 | --------------------------------------------------------------------------------