Accept Bizum in Stripe via Custom Payment Methods
Stripe's Custom Payment Methods (CPM) framework lets you show a payment method in Stripe's Payment Element that Stripe doesn't process itself. This guide shows how to offer Bizum to your Spanish customers inside your existing Stripe checkout, while MONEI processes the Bizum payment under its own Bizum acquiring license. The result is reported back to Stripe's Payment Records API so Bizum appears alongside your other payments in Stripe reporting.
MONEI holds a direct Bizum acquiring license, so it can settle Bizum funds to your account — something a Stripe-native processor cannot do for Bizum today.
You accept Bizum through Stripe's Custom Payment Methods framework, so the flow runs on your own backend: a small server endpoint creates the MONEI payment and reports the outcome to Stripe, paired with the client-side handler Stripe's Payment Element requires for any custom method. MONEI handles the Bizum processing and settlement; you authenticate to both APIs with your own keys. A runnable reference is in the sample repository.
How it works
The customer's experience is driven by the redirect (steps 6–7); the Stripe report (steps 8–9) is server-to-server reconciliation that runs a few seconds later. Treat the callback, not the redirect, as the authoritative outcome.
Before you begin
- A MONEI account with Bizum enabled (Dashboard → Settings → Payment Methods) and an API key. Bizum is available to customers whose bank account is held in Spain (also AD/IE) and enrolled in Bizum.
- A Stripe account with the Payment Element and access to Custom Payment Methods.
- A server that can call the MONEI and Stripe APIs and expose two public URLs (a redirect return page and a callback endpoint).
1. Create the Bizum custom payment method type in Stripe
In the Stripe Dashboard, create a custom payment method type for Bizum (name, logo, the countries/currencies it applies to). Stripe returns a custom payment method type id (cpmt_…). You reference this id both when rendering the Payment Element and when creating the custom PaymentMethod.
Enable the new type in your Payment Element configuration so Bizum appears for eligible customers.
2. Build the CPM handler Client-side
Stripe requires a client-side handler for every custom payment method — CPM has no native "redirect to provider" mode, so your handler is what bridges Stripe to MONEI. When the customer selects Bizum and submits, your handler calls your server, then sends the customer to the MONEI hosted page returned by step 3.
How you hook in depends on the CPM display type. For type: 'static', call elements.submit() in your own submit handler and branch on the returned selectedPaymentMethod. For type: 'embedded', implement the handleRender and handleDestroy callbacks. Either way, Stripe does not pass the amount or a return URL to your code — your server owns those. See Stripe's custom payment methods in the Payment Element.
With the static display type, your submit handler calls elements.submit() and, when the customer picked your Bizum CPM, hands off to your server and redirects to the MONEI hosted page:
const {submitError, selectedPaymentMethod} = await elements.submit();
if (submitError) {
// show submitError.message
} else if (selectedPaymentMethod === 'cpmt_xxx') {
// your /pay endpoint creates the MONEI payment (Section 3) and returns its URL
const {redirectUrl} = await fetch('/pay', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({orderId: 'stripe-order-123'})
}).then((r) => r.json());
window.location.href = redirectUrl; // MONEI hosted Bizum page
}
3. Create the MONEI Bizum payment Server-side
When your CPM handler calls your server, do two things: create the Stripe custom PaymentMethod, then create the MONEI payment.
First, create the custom PaymentMethod from your CPM type id. The Payment Element does not create it for you — you create it server-side, and its pm_… id is what you'll report to Stripe later.
const paymentMethod = await stripe.paymentMethods.create({
type: 'custom',
custom: {type: process.env.STRIPE_CPMT_ID} // cpmt_… from step 1
});
// store paymentMethod.id (pm_…) — it goes in the MONEI metadata below
// and becomes payment_method_details.payment_method when you report to Stripe
Then create a Payment with MONEI. Always set the amount on the server, never from the client. Amounts use the currency's minor unit (1000 = €10.00).
Set the return URL and a callback, and carry the Stripe identifiers in metadata (use the pm_… id from above as stripe_payment_method_id) so you can reconcile later:
completeUrl— where MONEI returns the customer when the payment finishes, for both success and failure. Read thestatusquery parameter to tell them apart (see the redirect contract).callbackUrl— the server-to-server endpoint MONEI POSTs the final Payment object to. This is what you reconcile with Stripe.
A separate failUrl is optional — set it only to route failed or canceled payments to a different page than successes; if you omit it, those return to completeUrl. (cancelUrl is unrelated — it's only the back-button / "return to merchant" target.)
- cURL
- Node.js
- PHP
- Python
curl --request POST 'https://api.monei.com/v1/payments' \
--header 'Authorization: YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data-raw '{
"amount": 1000,
"currency": "EUR",
"orderId": "stripe-order-123",
"paymentMethod": {
"bizum": {
"phoneNumber": "+34600000000"
}
},
"completeUrl": "https://example.com/return",
"callbackUrl": "https://example.com/monei/callback",
"metadata": {
"stripe_payment_method_id": "pm_xxx",
"stripe_cpmt_id": "cpmt_xxx",
"integration": "stripe_cpm"
}
}'
import {Monei} from '@monei-js/node-sdk';
const monei = new Monei('YOUR_API_KEY');
const payment = await monei.payments.create({
amount: 1000,
currency: 'EUR',
orderId: 'stripe-order-123',
paymentMethod: {
bizum: {phoneNumber: '+34600000000'}
},
completeUrl: 'https://example.com/return',
callbackUrl: 'https://example.com/monei/callback',
metadata: {
stripe_payment_method_id: 'pm_xxx',
stripe_cpmt_id: 'cpmt_xxx',
integration: 'stripe_cpm'
}
});
// Send the customer to the hosted Bizum page
const redirectUrl = payment.nextAction.redirectUrl;
<?php
require_once 'vendor/autoload.php';
use Monei\Model\CreatePaymentRequest;
use Monei\Model\PaymentPaymentMethodInput;
use Monei\Model\PaymentPaymentMethodBizumInput;
use Monei\MoneiClient;
$monei = new MoneiClient('YOUR_API_KEY');
$payment = $monei->payments->create(
new CreatePaymentRequest([
'amount' => 1000,
'currency' => 'EUR',
'order_id' => 'stripe-order-123',
'payment_method' => new PaymentPaymentMethodInput([
'bizum' => new PaymentPaymentMethodBizumInput([
'phone_number' => '+34600000000'
])
]),
'complete_url' => 'https://example.com/return',
'callback_url' => 'https://example.com/monei/callback',
'metadata' => [
'stripe_payment_method_id' => 'pm_xxx',
'stripe_cpmt_id' => 'cpmt_xxx',
'integration' => 'stripe_cpm'
]
])
);
// Send the customer to the hosted Bizum page
$redirectUrl = $payment->getNextAction()->getRedirectUrl();
?>
import Monei
from Monei import (
CreatePaymentRequest,
PaymentPaymentMethodInput,
PaymentPaymentMethodBizumInput,
)
monei = Monei.MoneiClient(api_key="YOUR_API_KEY")
payment = monei.payments.create(
CreatePaymentRequest(
amount=1000,
currency="EUR",
order_id="stripe-order-123",
payment_method=PaymentPaymentMethodInput(
bizum=PaymentPaymentMethodBizumInput(phone_number="+34600000000")
),
complete_url="https://example.com/return",
callback_url="https://example.com/monei/callback",
metadata={
"stripe_payment_method_id": "pm_xxx",
"stripe_cpmt_id": "cpmt_xxx",
"integration": "stripe_cpm",
},
)
)
# Send the customer to the hosted Bizum page
redirect_url = payment.next_action.redirect_url
The phone number is optional — if you omit it, the customer enters it on the hosted page. The response includes the payment id (store it; it becomes the Stripe payment_reference) and a hosted-page redirect URL. Return that URL to your CPM handler so the browser can navigate to it.
4. Handle the redirect return Client-side
After the customer finishes (or abandons) the Bizum flow on the hosted page, MONEI returns them to your completeUrl — for both outcomes — with the result in the query string:
?id=<paymentId>&status=<STATUS>&amount=<amount>¤cy=<currency>&orderId=<orderId>&message=<message>
- The payment id parameter is
id(notpayment_id). statusis the uppercase status enum:SUCCEEDED,FAILED, orCANCELED.messageis a human-readable status description — useful to show the customer on failure.- Success and failure both return to
completeUrl, so branch onstatus— don't treat the redirect itself as success. (Set an optionalfailUrlonly if you want failures on a separate page.)
statusBecause both successful and failed payments land on completeUrl, a redirect alone doesn't mean success — read the uppercase status and render accordingly.
Show a success or failure screen based on status, but don't fulfill the order from the redirect — the redirect can be interrupted (the customer closes the tab). Fulfillment and Stripe reporting happen in the callback below.
5. Reconcile with Stripe Server-side
MONEI POSTs the full Payment object (status, metadata, amount…) to your callbackUrl when the payment reaches a final state. This is the authoritative outcome.
A CPM payment doesn't go through a Stripe PaymentIntent — Stripe doesn't process it. Instead you create a Payment Records entry that reports the result.
- Verify the
MONEI-Signatureheader to confirm the request came from MONEI. See Verify signatures. - Return
200 OKimmediately, or MONEI retries. - Map the MONEI
statusto a Stripe Payment Records call (see the mapping table). - Use the MONEI payment id as the idempotency key so retries and duplicate callbacks don't double-report.
// after verifying MONEI-Signature and parsing req.body as the payment object
const payment = req.body;
const reference = payment.id; // MONEI payment id == Stripe payment_reference
const now = Math.floor(Date.now() / 1000);
if (payment.status === 'SUCCEEDED') {
const record = await stripe.paymentRecords.reportPayment(
{
amount_requested: {value: payment.amount, currency: payment.currency.toLowerCase()},
initiated_at: now,
// the custom PaymentMethod (pm_…) you created in the CPM handler
payment_method_details: {payment_method: payment.metadata.stripe_payment_method_id},
outcome: 'guaranteed',
guaranteed: {guaranteed_at: now},
processor_details: {type: 'custom', custom: {payment_reference: reference}},
metadata: {monei_authorization_code: payment.authorizationCode ?? ''}
},
{idempotencyKey: `report_payment_${reference}`}
);
// Persist record.id (pr_…) keyed by `reference` — you need it to report refunds.
}
// FAILED / CANCELED: call report_payment with outcome 'failed'; refunds: see Section 6
report_payment returns a Payment Record (pr_…). Store its id against the MONEI payment id — report_refund and the report_payment_attempt_* calls all act on that record. The sample repository has the full handler (all statuses + refunds).
outcome is guaranteed only once funds are guaranteed (a captured SUCCEEDED payment). For an in-flight or pre-authorized payment, report it as initiated (call report_payment without a terminal outcome) and finalize when the callback reports the terminal status.
6. Refunds
Refund through MONEI, then report the refund to Stripe:
- cURL
- Node.js
- PHP
- Python
curl --request POST 'https://api.monei.com/v1/payments/PAYMENT_ID/refund' \
--header 'Authorization: YOUR_API_KEY' \
--header 'Content-Type: application/json' \
--data-raw '{"amount": 1000}'
import {Monei} from '@monei-js/node-sdk';
const monei = new Monei('YOUR_API_KEY');
await monei.payments.refund('PAYMENT_ID', {amount: 1000});
<?php
require_once 'vendor/autoload.php';
use Monei\Model\RefundPaymentRequest;
use Monei\MoneiClient;
$monei = new MoneiClient('YOUR_API_KEY');
$refund = $monei->payments->refund(
'PAYMENT_ID',
new RefundPaymentRequest(['amount' => 1000])
);
?>
import Monei
from Monei import RefundPaymentRequest
monei = Monei.MoneiClient(api_key="YOUR_API_KEY")
refund = monei.payments.refund("PAYMENT_ID", RefundPaymentRequest(amount=1000))
MONEI supports both full and partial Bizum refunds — for a partial refund, send a smaller amount. When the refund settles (via the callbackUrl callback, or by polling GET /v1/payments/{id}), call Stripe report_refund on the Payment Record you stored in Section 5:
// payment.status is REFUNDED or PARTIALLY_REFUNDED.
// recordId = the pr_… you stored when you first reported this payment.
await stripe.paymentRecords.reportRefund(recordId, {
amount: {value: payment.lastRefundAmount, currency: payment.currency.toLowerCase()},
outcome: 'refunded',
refunded: {refunded_at: Math.floor(Date.now() / 1000)},
processor_details: {
type: 'custom',
// refund_reference must be unique per refund — pair the payment id with the
// running refunded total so each partial refund reports once
custom: {refund_reference: `${payment.id}_${payment.refundedAmount}`}
}
});
If you don't receive the callback, poll GET /v1/payments/{id} and reconcile from the returned status — the same mapping applies.
Status mapping
Map every MONEI charge status to a Stripe Payment Records call. The idempotency key is the MONEI payment id (processor_details.custom.payment_reference).
MONEI status | Stripe call | Notes |
|---|---|---|
SUCCEEDED | report_payment with outcome: guaranteed | Funds guaranteed |
AUTHORIZED | report_payment initiated → guaranteed on capture | 30-day Bizum pre-authorization |
PENDING / PENDING_PROCESSING | report_payment initiated (no terminal outcome) | Async / redirect in flight |
FAILED | report_payment with outcome: failed | First/only attempt — see the note below |
CANCELED / EXPIRED | report_payment with outcome: failed | Expiry counts as non-completion |
REFUNDED | report_refund with outcome: refunded (full) | On the existing Payment Record id |
PARTIALLY_REFUNDED | report_refund with partial amount.value | Full and partial Bizum refunds are supported |
Stripe's report_payment outcome enum is failed or guaranteed only. report_payment creates a Payment Record; the report_payment_attempt_failed / report_payment_attempt_canceled endpoints update an existing record (a payment you previously reported as initiated). Because a Bizum payment delivers a single terminal callback, report the terminal state in one report_payment call (outcome: guaranteed or failed); use the attempt_* endpoints only to finalize a payment you first reported as initiated (the AUTHORIZED / PENDING rows).
The create examples in Section 3 make a direct sale. Bizum also supports pre-authorization — a hold you capture later (Bizum holds for 30 days); those payments report as AUTHORIZED until you capture, then SUCCEEDED.
Test your integration
- Use your test mode MONEI API key and Stripe test mode keys.
- Use the test Bizum phone numbers.
- Check the result in MONEI Dashboard → Payments and confirm the Stripe Payment Record shows Bizum — Succeeded with
payment_referenceequal to the MONEI payment id and your metadata intact.
Sample repository
A runnable Express reference — create the Stripe custom PaymentMethod and MONEI Bizum payment, handle the redirect return, and report to Stripe — is available at MONEI/stripe-bizum-cpm-example. It mirrors the contract on this page exactly.
Common questions
Do I need OAuth or a MONEI-hosted connector?
No. This integration is merchant-driven: you call the MONEI and Stripe APIs with your own keys from your own server. There is no OAuth handshake and no MONEI-hosted connector.
Why can't this be fully no-code?
Stripe requires a client-side CPM handler, and CPM has no native redirect-to-provider mode, so some code is always needed. For lower-effort options on supported e-commerce platforms, contact MONEI.
Which URL does a failed payment return to?
The same completeUrl as a successful one — MONEI puts the outcome in the status query parameter, so branch on it. Set an optional failUrl only to route failures to a different page. cancelUrl is unrelated — it's just the back-button / "return to merchant" target.