These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is Stripe?
Stripe is a payment processing platform offering APIs for handling online transactions. Instead of building payment infrastructure from scratch, you integrate Stripe's APIs to create charges, manage subscriptions, and handle payment methods, currencies, and compliance.
For headless architectures, Stripe's API-first approach fits naturally. Your Strapi backend manages content and handles secure payment orchestration through custom controllers, while Stripe processes payment data and maintains authoritative transaction state.
The two systems communicate bidirectionally—Strapi initiates Payment Intent creation via the Stripe SDK and receives webhook callbacks confirming payment outcomes. This separation ensures sensitive card data never reaches your backend, as Stripe Elements tokenizes payment information client-side. Webhooks serve as the authoritative event source for payment confirmation, allowing Strapi to update order status and trigger fulfillment workflows without polling Stripe's API.
Why Integrate Stripe with Strapi?
Combining Stripe with Strapi's headless CMS architecture creates a flexible foundation for payment-enabled applications. Here's why this pairing works well:
- API-first Compatibility: Strapi automatically generates REST and GraphQL endpoints for your content types. Payment logic in your controllers works with any frontend—React, Next.js, Vue, or mobile apps—without backend modifications.
- Content-driven Product Management: Store product catalogs and pricing information through Strapi's content types, exposing them via REST/GraphQL APIs while keeping payment processing logic handled through Stripe's API and Dashboard. Non-technical team members can manage product content while developers maintain payment processing logic and Stripe configuration.
- Built-in Security Patterns: Strapi v5 includes role-based access control (RBAC), JWT authentication, and API token management as core features. These security patterns provide granular payment data permissions, automatic request validation, and rate limiting—enabling developers to protect sensitive payment endpoints without custom security infrastructure.
- Webhook Handling: Strapi's webhook implementation demands careful middleware configuration, including
includeUnparsed: truein body parsing settings to preserve raw request bodies for signature verification. Webhook handlers must be implemented as asynchronous event processors that idempotently update content types to handle Stripe's at-least-once delivery guarantee, ensuring robust payment event processing without duplicates. - Custom Implementation Requirement: Since no official Stripe plugins exist for Strapi v5, you must implement payment processing directly using the Stripe Node.js SDK. While this requires more initial development effort, it provides complete control over your payment flows. Complex scenarios like marketplace payouts or usage-based billing become straightforward extensions after implementing core payment intent and webhook infrastructure from scratch.
- Self-hosting Flexibility: Deploy your payment infrastructure on your own servers for compliance requirements or cost optimization using custom Stripe SDK integration with Strapi v5.
How to Integrate Stripe with Strapi
Prerequisites
Before starting, ensure you have:
- Node.js 18.x, 20.x, or 22.x installed (compatible with Stripe Node.js SDK).
- Strapi v5 project initialized and running.
- Stripe account with access to your API keys (Dashboard → Developers → API keys).
- Stripe CLI installed for local webhook testing (
brew install stripe/stripe-cli/stripeon macOS). - Basic familiarity with Strapi's backend customization patterns.
Step 1: Install the Stripe SDK
Add the official Stripe Node.js library to your Strapi project:
npm install stripeThis package provides TypeScript support, automatic request retries with exponential backoff, and handles the low-level API communication through async/await compatibility.
Step 2: Configure Environment Variables
Store your Stripe credentials securely in your .env file:
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_hereNever commit these values to version control. Store secret keys as environment variables—treat them with the same level of security as database passwords since they grant full API access.
Step 3: Create the Payment Content Type
To track payment records in Strapi v5, create a content type through the admin panel or by defining it in your project's collection schema. This content type should include fields for storing Stripe payment identifiers, transaction amounts, currency, status, and timestamps that correspond to payment events received from Stripe webhooks.
// src/api/payment/content-types/payment/schema.json
{
"kind": "collectionType",
"collectionName": "payments",
"info": {
"singularName": "payment",
"pluralName": "payments",
"displayName": "Payment"
},
"attributes": {
"stripePaymentIntentId": {
"type": "string",
"unique": true,
"required": true,
"description": "Unique Stripe Payment Intent ID from Stripe API"
},
"amount": {
"type": "decimal",
"required": true,
"description": "Payment amount in decimal format (converted to cents by Stripe)"
},
"currency": {
"type": "string",
"default": "usd",
"description": "ISO 4217 currency code"
},
"status": {
"type": "enumeration",
"enum": ["pending", "succeeded", "failed", "canceled"],
"default": "pending",
"description": "Payment status reflecting the current state of the Payment Intent"
},
"customerEmail": {
"type": "email",
"description": "Customer email address for payment confirmation"
},
"metadata": {
"type": "json",
"description": "Flexible JSON metadata to store application-specific identifiers (e.g., orderId)"
},
"completedAt": {
"type": "datetime",
"description": "Timestamp when payment was marked as succeeded"
},
"errorMessage": {
"type": "text",
"description": "Error message stored when payment fails (from Stripe error response)"
}
}
}Step 4: Build the Payment Controller
Create a controller that handles payment intent creation. Strapi v5 controllers should use the createCoreController factory function:
// src/api/payment/controllers/payment.ts
import { factories } from '@strapi/strapi';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2023-10-16',
});
export default factories.createCoreController('api::payment.payment', ({ strapi }) => ({
async createPaymentIntent(ctx) {
try {
const { amount, currency, customerEmail, metadata } = ctx.request.body;
if (!amount || amount <= 0) {
return ctx.badRequest('Valid amount is required');
}
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency: currency || 'usd',
metadata: metadata || {},
receipt_email: customerEmail,
automatic_payment_methods: {
enabled: true,
allow_redirects: 'never'
},
});
await strapi.documents('api::payment.payment').create({
data: {
stripePaymentIntentId: paymentIntent.id,
amount: amount,
currency: currency || 'usd',
status: 'pending',
customerEmail: customerEmail,
metadata: metadata,
},
});
return ctx.send({
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
});
} catch (error) {
strapi.log.error('Payment intent creation failed:', error);
if (error.type === 'StripeCardError') {
return ctx.badRequest(error.message);
} else if (error.type === 'StripeInvalidRequestError') {
return ctx.badRequest('Invalid payment parameters');
} else if (error.type === 'StripeAPIError') {
return ctx.internalServerError('Payment service temporarily unavailable');
}
return ctx.internalServerError('Payment processing failed');
}
},
}));Note the use of strapi.documents() instead of strapi.entityService. This is a breaking change in v5—the documents API is now the standard approach for content operations.
Step 5: Define Custom Routes
Register your payment endpoint with proper configuration:
// src/api/payment/routes/custom-payment.ts
export default {
routes: [
{
method: 'POST',
path: '/payments/create-intent',
handler: 'api::payment.payment.createPaymentIntent',
config: {
auth: false,
policies: [],
},
},
],
};Set auth: false for public checkout flows (webhook endpoints must use signature verification instead of session authentication), or configure authentication using Strapi's role-based access control (RBAC) and policies.
Step 6: Configure Middleware for Webhooks
Critical: Raw body parsing must be enabled for webhook signature verification. Stripe's signature verification requires the raw, unparsed request body to compute the HMAC signature. By default, Strapi's body parser converts requests to JSON objects before your controller receives them.
// config/middlewares.ts
export default [
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'connect-src': ["'self'", 'https:'],
upgradeInsecureRequests: null,
},
},
},
},
'strapi::cors',
'strapi::poweredBy',
'strapi::logger',
'strapi::query',
{
name: 'strapi::body',
config: {
includeUnparsed: true,
},
},
'strapi::session',
'strapi::favicon',
'strapi::public',
];The includeUnparsed: true configuration is mandatory for webhook signature verification in Strapi v5. This preserves access to the raw body via ctx.request.body[Symbol.for('unparsedBody')].
Step 7: Implement the Webhook Handler
Create a dedicated controller for processing Stripe events:
// src/api/webhook/controllers/stripe-webhook.ts
import { factories } from '@strapi/strapi';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2023-10-16',
});
export default factories.createCoreController('api::webhook.webhook', ({ strapi }) => ({
async handleStripeWebhook(ctx) {
const sig = ctx.request.headers['stripe-signature'];
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
let event;
try {
const rawBody = ctx.request.body[Symbol.for('unparsedBody')];
event = stripe.webhooks.constructEvent(rawBody, sig, endpointSecret);
} catch (err) {
strapi.log.error(`Webhook signature verification failed: ${err.message}`);
ctx.status = 400;
ctx.body = { error: 'Invalid signature' };
return;
}
switch (event.type) {
case 'payment_intent.succeeded':
await this.handlePaymentSuccess(event.data.object);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentFailure(event.data.object);
break;
default:
strapi.log.info(`Unhandled event type: ${event.type}`);
}
ctx.status = 200;
ctx.body = { received: true };
},
async handlePaymentSuccess(paymentIntent) {
const payments = await strapi.documents('api::payment.payment').findMany({
filters: { stripePaymentIntentId: paymentIntent.id },
});
if (payments.length > 0) {
await strapi.documents('api::payment.payment').update({
documentId: payments[0].documentId,
data: {
status: 'succeeded',
completedAt: new Date(),
},
});
strapi.log.info(`Payment ${paymentIntent.id} marked as succeeded`);
}
},
async handlePaymentFailure(paymentIntent) {
const payments = await strapi.documents('api::payment.payment').findMany({
filters: { stripePaymentIntentId: paymentIntent.id },
});
if (payments.length > 0) {
await strapi.documents('api::payment.payment').update({
documentId: payments[0].documentId,
data: {
status: 'failed',
errorMessage: paymentIntent.last_payment_error?.message,
},
});
}
},
}));Notice the use of documentId instead of id when updating records—another v5 breaking change where records are identified by document IDs.
Step 8: Register the Webhook Route
// src/api/webhook/routes/stripe-webhook.ts
export default {
routes: [
{
method: 'POST',
path: '/webhooks/stripe',
handler: 'api::webhook.stripe-webhook.handleStripeWebhook',
config: {
auth: false,
},
},
],
};Webhook endpoints disable session-based authentication because they use cryptographic signature verification instead.
Step 9: Test Locally with Stripe CLI
Forward webhook events to your local development server:
stripe login
stripe listen --forward-to localhost:1337/api/webhooks/stripeThe CLI outputs a webhook signing secret—use this value for STRIPE_WEBHOOK_SECRET during development.
Trigger test events to verify your handler:
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failedUse Stripe's test card numbers for end-to-end testing:
4242 4242 4242 4242- Successful payment4000 0000 0000 0002- Card declined4000 0000 0000 9995- Insufficient funds4000 0000 0000 0069- Expired card4000 0025 0000 3155- 3D Secure authentication required
Project Example: E-Commerce Checkout System
Let's build a complete checkout flow that demonstrates how Strapi and Stripe work together for a product purchase scenario.
Architecture Overview
The system follows a three-tier pattern:
- Frontend (React/Next.js) - Displays products, collects payment details via Stripe.js or Elements.
- Strapi Backend (v5) - Manages products, creates payment intents/checkout sessions via custom Stripe SDK integration, processes and verifies webhooks with signature verification.
- Stripe - Handles payment processing, hosts checkout UI, sends webhook events for payment confirmation.
Product Content Type
Define a product content type in Strapi:
// src/api/product/content-types/product/schema.json
{
"kind": "collectionType",
"collectionName": "products",
"info": {
"singularName": "product",
"pluralName": "products",
"displayName": "Product"
},
"attributes": {
"name": {
"type": "string",
"required": true
},
"description": {
"type": "richtext"
},
"price": {
"type": "decimal",
"required": true
},
"image": {
"type": "media",
"allowedTypes": ["images"]
},
"inventory": {
"type": "integer",
"default": 0
}
}
}Manage your product catalog through the Strapi admin panel, and your frontend can query products via the auto-generated REST API or GraphQL.
Order Content Type
Track orders separately from payments:
// src/api/order/content-types/order/schema.json
{
"kind": "collectionType",
"collectionName": "orders",
"info": {
"singularName": "order",
"pluralName": "orders",
"displayName": "Order"
},
"attributes": {
"stripeSessionId": {
"type": "string",
"unique": true
},
"customerEmail": {
"type": "email",
"required": true
},
"items": {
"type": "json",
"required": true
},
"total": {
"type": "decimal",
"required": true
},
"status": {
"type": "enumeration",
"enum": ["pending", "paid", "fulfilled", "canceled"],
"default": "pending"
},
"shippingAddress": {
"type": "json"
}
}
}Checkout Session Controller
This controller builds on the patterns from Step 4, using Stripe Checkout for a hosted payment page:
// src/api/checkout/controllers/checkout.ts
import { factories } from '@strapi/strapi';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2023-10-16',
});
export default factories.createCoreController('api::order.order', ({ strapi }) => ({
async createCheckoutSession(ctx) {
try {
const { items, customerEmail } = ctx.request.body;
if (!items || items.length === 0) {
return ctx.badRequest('Cart items are required');
}
const lineItems = await Promise.all(
items.map(async (item) => {
const product = await strapi.documents('api::product.product').findOne({
documentId: item.documentId,
});
if (!product) {
strapi.log.error(`Product ${item.documentId} not found`);
return ctx.badRequest('Product not found', {
documentId: item.documentId,
});
}
return {
price_data: {
currency: 'usd',
product_data: {
name: product.name,
description: product.description?.substring(0, 500),
},
unit_amount: Math.round(product.price * 100),
},
quantity: item.quantity,
};
})
);
const total = items.reduce((sum, item, index) => {
return sum + (lineItems[index].price_data.unit_amount / 100) * item.quantity;
}, 0);
const order = await strapi.documents('api::order.order').create({
data: {
customerEmail,
items,
total,
status: 'pending',
},
});
const session = await stripe.checkout.sessions.create({
automatic_payment_methods: {
enabled: true,
},
line_items: lineItems,
mode: 'payment',
customer_email: customerEmail,
success_url: `${process.env.FRONTEND_URL}/order/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/cart`,
metadata: {
orderId: order.documentId,
},
});
await strapi.documents('api::order.order').update({
documentId: order.documentId,
data: {
stripeSessionId: session.id,
},
});
return ctx.send({
sessionId: session.id,
url: session.url,
});
} catch (error) {
strapi.log.error('Checkout session creation failed:', error);
return ctx.internalServerError('Failed to create checkout session');
}
},
}));Checkout Webhook Handler
Extend your webhook controller (from Step 7) to handle checkout completion:
// Add this case to the switch statement in your webhook handler
case 'checkout.session.completed':
await this.handleCheckoutComplete(event.data.object);
break;
// Add this method to the controller
async handleCheckoutComplete(session) {
const orderId = session.metadata.orderId;
if (!orderId) {
strapi.log.warn('Checkout session missing orderId in metadata', {
sessionId: session.id
});
return;
}
const orders = await strapi.documents('api::order.order').findMany({
filters: { documentId: orderId }
});
if (orders.length === 0) {
strapi.log.error(`Order ${orderId} not found`);
return;
}
const order = orders[0];
await strapi.documents('api::order.order').update({
documentId: orderId,
data: {
status: 'paid',
completedAt: new Date()
},
});
for (const item of order.items) {
const product = await strapi.documents('api::product.product').findOne({
documentId: item.documentId,
});
if (product) {
await strapi.documents('api::product.product').update({
documentId: product.documentId,
data: {
inventory: Math.max(0, product.inventory - item.quantity),
},
});
}
}
strapi.log.info(`Order ${orderId} marked as paid, inventory updated`);
}Frontend Integration
Connect your React or Next.js frontend to the checkout flow:
// Frontend checkout component
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
async function handleCheckout(cartItems, customerEmail) {
const response = await fetch(`${process.env.NEXT_PUBLIC_STRAPI_URL}/api/checkout/create-session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
items: cartItems.map(item => ({
documentId: item.documentId,
quantity: item.quantity,
})),
customerEmail,
}),
});
const { url } = await response.json();
window.location.href = url;
}Stripe Checkout handles card input, validation, and authentication through a hosted payment page, maintaining PCI compliance by preventing card data from reaching your Strapi server. Customers complete payment on Stripe's secure interface, then Stripe sends webhook events to confirm payment completion. The authoritative payment state comes from Stripe's webhook notification, not the client-side redirect.
Route Configuration
// src/api/checkout/routes/checkout.ts
export default {
routes: [
{
method: 'POST',
path: '/checkout/create-session',
handler: 'api::checkout.checkout.createCheckoutSession',
config: {
auth: false,
policies: [],
},
},
],
};Handlers must use the fully qualified format api::<api-name>.<controllerName>.<actionName> (this is a breaking change from v4).
Strapi Open Office Hours
If you have any questions about Strapi 5 or want to stop by and say hi, join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and Stripe documentation.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.