Stripe Payment Integration in Magento — Official Extension vs Custom Module
Stripe ships stripe/module-payments, the official Magento Marketplace extension that handles Payment Element, 3DS2 SCA, webhooks, partial refunds, and Stripe Billing subscriptions out of the box. It covers 95% of stores. The other 5% — B2B with biometric-bypass 3DS2, micro-fee rounding into the customer's total, ACH/SEPA mandate caching, high-risk merchants, marketplace platforms running Stripe Connect Direct Charges with custom application_fee_amount logic — need either a fork of the official module or a full from-scratch integration. Here is the decision tree, the PaymentIntent + SetupIntent code, and a realistic cost estimate for each path.
Stripe Magento integration is the work of wiring Stripe's PaymentIntent and SetupIntent APIs into Magento's quote, order, and refund flows on a Magento 2.4.4 — 2.4.9 storefront, and the right answer for most stores is to install stripe/module-payments and configure it. This guide separates the 5% of stores that genuinely need a fork or rebuild from the 95% that don't, with API references and cost estimates for each path.
The official Stripe extension solves 95% of integrations
Stripe maintains stripe/module-payments on Packagist and the Magento Marketplace — the canonical integration written by the same team that ships the Stripe API. Of 24 Stripe-on-Magento projects I scoped between January 2025 and May 2026, 22 shipped on the official module with config-only changes. Two needed a fork. Zero needed a from-scratch rebuild.
Custom Stripe modules are a procurement signal, not a technical requirement. Most teams that asked for one had not read the official module's etc/config.xml.The official extension covers, out of the box:
- Payment Element — single widget for cards, wallets (Apple Pay, Google Pay, Link), BNPL (Klarna, Afterpay), and bank debits.
- 3DS2 SCA — automatic challenge handling per EU PSD2 and UK FCA rules.[1]
- Webhooks — signed handler at
/stripe/webhooksprocessingpayment_intent.succeeded,charge.refunded, subscription updates. - Partial refunds — admin UI calling
POST /v1/refundswith charge ID and amount. - Stripe Billing — recurring product mapping with proration on plan changes.
- Stripe Radar — fraud rules engine, dashboard-configured.
- Saved cards — multi-use SetupIntent vault against a Customer object.
If your requirements match that bullet list, install the module and stop. The next sections are for the 5%.
1. When to fork the official extension
Forking means cloning stripe/module-payments into app/code/Vendor/StripeOverrides and overriding only the block, controller, or service that needs to change. You keep 90% of upstream code and absorb security patches by merging from upstream each quarter.
Fork trigger A: custom 3DS2 challenge UI for B2B accounts
3DS2 issuers can return a frictionless result for low-risk transactions — no challenge shown. For B2B accounts with corporate cards on file, frictionless is the norm. The default Payment Element still mounts the iframe that would render the challenge. On corporate networks where iframes from js.stripe.com are CSP-blocked or proxied, that mount triggers a failed network call even when no challenge fires. Detect the frictionless path before mounting and skip the iframe entirely on B2B customer groups.
// app/code/Vendor/StripeOverrides/view/frontend/web/js/.../stripe-payments.js
// Fork of vendor/stripe/module-payments/view/frontend/web/js/.../stripe-payments.js
define([
'Stripe_Payments/js/view/payment/method-renderer/stripe-payments',
'Magento_Customer/js/customer-data'
], function (StripeRenderer, customerData) {
'use strict';
return StripeRenderer.extend({
confirmPaymentIntent: async function (clientSecret, paymentMethod) {
const isB2B = customerData.get('customer')().group_id === 3; // wholesale group
if (!isB2B) {
return this._super(clientSecret, paymentMethod);
}
// Frictionless path — skip the iframe mount.
const result = await stripe.confirmPayment({
clientSecret: clientSecret,
confirmParams: { return_url: this.getReturnUrl(), payment_method: paymentMethod },
redirect: 'if_required'
});
if (result.error && result.error.code === 'authentication_required') {
return this._super(clientSecret, paymentMethod); // rare B2B card needs 3DS
}
return result;
}
});
});The redirect: 'if_required' parameter is the key — Stripe's SDK only mounts the challenge UI if the PaymentIntent actually requires one.[2] Frictionless B2B transactions resolve immediately without an iframe. Sprint cost: ~12h @ $25/hr = $300.
Fork trigger B: rounding Stripe fees into the customer total
Stripe charges 2.9% + 30 cents per US card transaction. On low-margin verticals, merchants want to pass the fee to the customer with a clean cart total — a $19.99 cart should not become $20.91, it should round to $20.99 with the fee absorbed. The official module surfaces Stripe fees as a separate quote total line; to hide it and round into the grand total, fork the total collector:
<?php
// app/code/Vendor/StripeOverrides/Model/Quote/Total/StripeFeeRounded.php
namespace Vendor\StripeOverrides\Model\Quote\Total;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address\Total;
use Magento\Quote\Model\Quote\Address\Total\AbstractTotal;
class StripeFeeRounded extends AbstractTotal
{
public function collect(Quote $quote, $shippingAssignment, Total $total)
{
parent::collect($quote, $shippingAssignment, $total);
$base = (float)$total->getSubtotal() + (float)$total->getShippingAmount() + (float)$total->getTaxAmount();
$rawFee = ($base * 0.029) + 0.30; // Stripe US card rate
$rounded = ceil($base + $rawFee) - 0.01; // nearest .99 above base
$finalFee = $rawFee + ($rounded - ($base + $rawFee));
$total->addTotalAmount('stripe_fee', $finalFee);
$total->addBaseTotalAmount('stripe_fee', $finalFee);
return $this;
}
public function fetch(Quote $quote, Total $total) { return null; } // hide from breakdown
}fetch() returning null hides the fee from the customer-facing breakdown while still counting it in the grand total. Sprint cost: ~16h @ $25/hr = $400 including finance-team review of the rounding rule.
Fork trigger C: ACH/SEPA Direct Debit with mandate caching
ACH (US bank debit) and SEPA Direct Debit require the customer to sign a mandate — a digital authorization to debit their bank account on a recurring basis. The official module creates a fresh mandate on every checkout via SetupIntent with payment_method_types: ['us_bank_account'] or ['sepa_debit']. For repeat guest checkouts that adds 3–5 seconds per checkout. The fork caches the mandate against the customer email + bank fingerprint and re-uses it for 13 months (the SEPA validity window):
<?php
// app/code/Vendor/StripeOverrides/Service/MandateCache.php
namespace Vendor\StripeOverrides\Service;
use Magento\Framework\App\CacheInterface;
use Stripe\SetupIntent;
use Stripe\StripeClient;
class MandateCache
{
private const TTL = 13 * 30 * 86400; // 13 months
public function __construct(private CacheInterface $cache, private StripeClient $stripe) {}
public function getOrCreate(string $customerEmail, string $bankFingerprint, string $type): SetupIntent
{
$key = sprintf('stripe_mandate_%s_%s_%s', md5($customerEmail), $bankFingerprint, $type);
$cachedId = $this->cache->load($key);
if ($cachedId) {
try {
$intent = $this->stripe->setupIntents->retrieve($cachedId);
if ($intent->status === 'succeeded' && $this->mandateStillValid($intent)) {
return $intent;
}
} catch (\Stripe\Exception\InvalidRequestException $e) { /* expired — recreate */ }
}
$intent = $this->stripe->setupIntents->create([
'payment_method_types' => [$type],
'usage' => 'off_session',
'mandate_data' => ['customer_acceptance' => ['type' => 'online', 'online' => [
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
]]]
]);
$this->cache->save($intent->id, $key, ['STRIPE_MANDATE'], self::TTL);
return $intent;
}
private function mandateStillValid(SetupIntent $intent): bool
{
return $intent->mandate && $this->stripe->mandates->retrieve($intent->mandate)->status === 'active';
}
}Cache the mandate, check status before every reuse via GET /v1/mandates/{id}, fall back to fresh creation on any non-active status. Sprint cost: ~24 hours @ $25/hr = $600 — most of the time is in mandate-status edge cases (revoked, pending, suspended).
2. When to rebuild from scratch
Rebuilding means writing a new payment method module that calls Stripe's API directly — not extending the official one. You give up the webhook handler, refund flow, and saved-card UI and re-implement them. Two cases justify it.
Rebuild trigger A: Stripe will offboard you anyway
Stripe Restricted Businesses (firearms, cannabis, CBD in non-permitting states, high-chargeback verticals, unlicensed financial services) cannot use the platform.[3] If Stripe will close your account on onboarding, you're routing through a non-Stripe processor (Authorize.Net, Adyen, NMI). Build a custom module against that processor's API — don't fork Stripe's module to graft Authorize.Net in; PaymentIntent has no equivalent in legacy gateways. Cost: ~80h @ $25/hr = $2,000 plus 3DS2 conformance testing.
Rebuild trigger B: Stripe Connect Direct Charges with custom application_fee_amount
Multi-vendor marketplaces use Stripe Connect. Two charge types: Destination Charges charge the platform then route funds; Direct Charges charge the seller's account directly while the platform takes a cut via application_fee_amount. Direct Charges are right when each seller needs their own Stripe Dashboard (own chargebacks, payouts, Radar rules) — but the official module assumes the platform's account receives. Redirecting every PaymentIntent to a seller-specific account rewrites 70% of the codebase; a clean from-scratch implementation is faster.
<?php
// app/code/Vendor/MarketplaceStripe/Service/DirectChargeIntent.php
namespace Vendor\MarketplaceStripe\Service;
use Magento\Quote\Api\Data\CartInterface;
use Stripe\StripeClient;
use Vendor\Marketplace\Api\SellerRepositoryInterface;
class DirectChargeIntent
{
public function __construct(
private StripeClient $stripe,
private SellerRepositoryInterface $sellers,
private FeeCalculator $fees
) {}
public function create(CartInterface $quote): array
{
$sellerIds = $this->extractSellerIds($quote);
if (count($sellerIds) !== 1) {
throw new \InvalidArgumentException('Direct Charges require a single-seller cart.');
}
$seller = $this->sellers->get($sellerIds[0]);
$amount = (int)round($quote->getGrandTotal() * 100);
$fee = $this->fees->calculate($seller, $amount);
$intent = $this->stripe->paymentIntents->create(
[
'amount' => $amount,
'currency' => strtolower($quote->getQuoteCurrencyCode()),
'automatic_payment_methods' => ['enabled' => true],
'application_fee_amount' => $fee,
'metadata' => ['quote_id' => $quote->getId(), 'seller_id' => $seller->getId()]
],
['stripe_account' => $seller->getStripeAccountId()]
);
return ['client_secret' => $intent->client_secret, 'stripe_account' => $seller->getStripeAccountId()];
}
}The ['stripe_account' => ...] option tells Stripe to create this PaymentIntent on the seller's account.[4] The frontend Payment Element needs stripeAccount in the loadStripe() call to match. Cost: ~120h @ $25/hr = $3,000 for the core, plus ~$2,500 for Connect onboarding (KYC, payout reporting, per-seller dashboard). Total: ~$5,500–$8,000.
3. The PaymentIntent lifecycle, demystified
Whether you fork or rebuild, you need the PaymentIntent state machine. The official module hides it; a custom module must handle it.
{
"id": "pi_3NxAbCxxxxx",
"object": "payment_intent",
"amount": 2099,
"currency": "usd",
"status": "requires_payment_method",
"client_secret": "pi_3NxAbCxxxxx_secret_yyy",
"automatic_payment_methods": { "enabled": true },
"next_action": null,
"last_payment_error": null
}States the integration must handle:
requires_payment_method— created on server, client collects method.requires_confirmation— method attached, server callsconfirm. Skipped withautomatic_payment_methods.requires_action— 3DS2 challenge. Client SDK handles viahandleNextAction.processing— async (ACH, SEPA) submitted but not confirmed. Webhook delivers final state.succeeded— create Magento order.canceled— re-open cart.
The official module covers all six transitions plus signature verification, replay-attack prevention, and order idempotency keys. A custom rebuild must reproduce all of that.
4. Decision matrix
| Scenario | Official + adjustments | Fork | Rebuild |
|---|---|---|---|
| Cards + wallets + standard 3DS2 | Config only. ~$0 | No | No |
| BNPL via Stripe (Klarna, Afterpay) | Payment Element auto-enables. ~$0 | No | No |
| Stripe Billing subscriptions | Built-in. ~$0–$500 | No | No |
| B2B custom 3DS2 challenge UI | No — iframe always mounts | ~12h = $300 | No |
| Rounded fee absorbed into cart total | No — fee shows separately | ~16h = $400 | No |
| ACH/SEPA mandate caching | No — fresh mandate each checkout | ~24h = $600 | No |
| Saved cards (multi-use SetupIntent) | Built-in. ~$0 | No | No |
| Stripe Radar custom rules | Dashboard config. ~$0 | No | No |
| Marketplace, Destination Charges | Partial — one platform account | ~40h = $1,000 | If >5 sellers |
| Marketplace, Direct Charges + custom app_fee | No | Not viable (70% rewrite) | $5,500–$8,000 |
| High-risk vertical (Stripe restricted) | No — account will close | N/A | ~$2,000+ alt gateway |
| Custom recurring (not Stripe Billing) | No | ~40h = $1,000 | Per-item cycles |
5. Webhook handling — the part everyone gets wrong
The official module verifies Stripe-Signature using your webhook secret. The most common rebuild bug is verifying the signature but not the timestamp tolerance — Stripe's signing payload includes a Unix timestamp; reject events older than 5 minutes to prevent replay attacks.
<?php
// app/code/Vendor/MarketplaceStripe/Controller/Webhook/Index.php
namespace Vendor\MarketplaceStripe\Controller\Webhook;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Stripe\Webhook;
class Index implements HttpPostActionInterface, CsrfAwareActionInterface
{
private const SIGNATURE_TOLERANCE = 300; // 5 minutes
public function execute()
{
$payload = $this->request->getContent();
$sig = $this->request->getHeader('Stripe-Signature');
$secret = $this->config->getValue('payment/stripe/webhook_secret');
try {
$event = Webhook::constructEvent($payload, $sig, $secret, self::SIGNATURE_TOLERANCE);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return $this->jsonFactory->create()->setHttpResponseCode(400)
->setData(['error' => 'invalid_signature']);
}
$this->processor->processOnce($event); // idempotency — Stripe retries duplicates
return $this->jsonFactory->create()->setData(['received' => true]);
}
public function createCsrfValidationException(RequestInterface $r): ?InvalidRequestException { return null; }
public function validateForCsrf(RequestInterface $r): ?bool { return true; }
}The processOnce method must store the event ID in a table with a unique constraint — Stripe retries webhooks for up to 3 days on non-2xx responses, so you'll see duplicates. The official module uses a stripe_webhook_event table. Replicate that or you'll create duplicate orders.
6. Operational gotchas across all paths
- Test vs live keys — secret, publishable, and webhook secrets all differ. Misconfiguring the webhook secret on go-live means every event 400s and orders never reach
complete. - SCA exemptions are issuer-discretionary — your code requests
off_session+recurringflags, but the issuer decides. Plan UX for the case where a recurring charge is challenged anyway. - Apple Pay domain verification — the file at
.well-known/apple-developer-merchantid-domain-associationmust be served without redirecting to HTTPS canonical. - Order state machine — the official module uses
pending_payment→processingon webhook. Custom rebuilds that jump straight toprocessingbreak inventory consistency on async failure. - Refunds via Stripe Dashboard don't sync to Magento by default. The webhook must process
charge.refundedand create a credit memo programmatically. - Connect account closed mid-checkout — Direct Charge sellers can be
active,restricted, orrestricted_soon. Check before creating the PaymentIntent or it'll 400 withaccount_invalid.
FAQ
Can I use the official Stripe module on Magento 2.4.4?
Yes. stripe/module-payments targets Magento 2.4.4 — 2.4.9. PHP 8.4 support landed in version 4.x.
PaymentIntent vs SetupIntent?
PaymentIntent collects payment now — there's an amount and the customer is charged on confirm. SetupIntent collects payment-method details to use later — no amount, no charge. Stripe Billing uses both: SetupIntent on signup, PaymentIntent per cycle.
Does the official module support Stripe Tax?
Yes, as of version 4.1. Enable Stripe Tax in the dashboard, toggle "Use Stripe Tax" in Magento admin. It overrides Magento's tax calculation for the PaymentIntent's tax line.
What is Stripe Radar and do I need it?
Radar is Stripe's fraud detection engine. It scores every PaymentIntent and either allows, reviews, or blocks the transaction. Enabled by default — the default ruleset blocks more than 95% of card-testing attacks without custom rules.
How do I absorb security patches in a forked module?
Quarterly merge from upstream via git remotes: git remote add upstream https://github.com/stripe/stripe-magento2, then git fetch upstream && git merge upstream/main. Run PHPUnit, deploy to staging. Budget ~4 hours per quarter.
Can custom Stripe code coexist with the official module?
Yes — add a custom module with a di.xml plugin on the service that needs to change. Plugins are upgrade-safe; full forks are not. Use plugins until you need to override more than three classes; past that, a fork is cleaner.
Related reading
- Magento checkout step customization — splitting payment into multi-step
- Magento checkout conversion rate — 12 friction points
- Magento 2 development service
Citations
- [1] Stripe Docs — Strong Customer Authentication and 3D Secure 2 overview, including the
requires_actionstate on PaymentIntent. stripe.com/docs/strong-customer-authentication - [2] Stripe Docs —
stripe.confirmPaymentreference, includingredirect: 'if_required'for frictionless flows. stripe.com/docs/js/payment_intents/confirm_payment - [3] Stripe — Restricted Businesses list, the canonical reference for what cannot be processed on Stripe. stripe.com/restricted-businesses
- [4] Stripe Docs — Connect Direct Charges, including
application_fee_amountand theStripe-Accountrequest header. stripe.com/docs/connect/direct-charges - [5] Stripe — Official Magento 2 extension on the Adobe Commerce Marketplace. commercemarketplace.adobe.com/stripe-module-payments
- [6] Stripe Docs — Webhook signature verification and the 5-minute timestamp tolerance. stripe.com/docs/webhooks/signatures
I scope and ship Stripe on Magento 2.4.4 — 2.4.9 + Hyvä on kishansavaliya.com — official module configuration, targeted forks for B2B 3DS2 / fee rounding / ACH mandate caching, or full Connect Direct Charges rebuilds for marketplace platforms. Each engagement includes webhook hardening, refund-sync verification, and a 30-day patch window. Fixed quote from $499 audit · $2,499 sprint · ~24h @ $25/hr. See hire me.