# Accept Bizum in Stripe via Custom Payment Methods

Stripe's [Custom Payment Methods (CPM)](https://docs.stripe.com/payments/payment-methods/custom-payment-methods) 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](https://docs.stripe.com/payments/payment-records) 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.

What you build

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](#sample-repository).

## How it works[​](#how-it-works "Direct link to 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](#5-reconcile-with-stripe), not the redirect, as the authoritative outcome.

## Before you begin[​](#before-you-begin "Direct link to Before you begin")

* A **MONEI account** with **Bizum enabled** ([Dashboard → Settings → Payment Methods](https://dashboard.monei.com/settings/payment-methods)) and an [API key](https://dashboard.monei.com/settings/api-access). 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[​](#1-create-the-cpm-type "Direct link to 1. Create the Bizum custom payment method type in Stripe")

In the [Stripe Dashboard](https://dashboard.stripe.com/settings/custom_payment_methods), 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`[​](#2-cpm-handler "Direct link to 2-cpm-handler")

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.

info

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](https://docs.stripe.com/payments/payment-element/custom-payment-methods).

With the **static** display type, your submit handler calls [`elements.submit()`](https://docs.stripe.com/payments/payment-element/custom-payment-methods) and, when the customer picked your Bizum CPM, hands off to your server and redirects to the MONEI hosted page:

client.js — your Payment Element submit handler

```
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`[​](#3-create-the-monei-payment "Direct link to 3-create-the-monei-payment")

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`](https://docs.stripe.com/api/payment_methods/create) 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.

server.js

```
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](https://docs.monei.com/apis/rest/payments-create/.md) 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 the `status` query parameter to tell them apart (see the [redirect contract](#4-handle-the-redirect-return)).
* **`callbackUrl`** — the server-to-server endpoint MONEI POSTs the final [Payment object](https://docs.monei.com/apis/rest/schemas/payment/.md) 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

POST https\://api.monei.com/v1/payments

```
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"

  }

}'
```

server.js

```
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;
```

server.php

```
<?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();

?>
```

server.py

```
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`[​](#4-handle-the-redirect-return "Direct link to 4-handle-the-redirect-return")

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>&currency=<currency>&orderId=<orderId>&message=<message>
```

* The payment id parameter is **`id`** (not `payment_id`).
* `status` is the **uppercase** status enum: `SUCCEEDED`, `FAILED`, or `CANCELED`.
* `message` is a human-readable status description — useful to show the customer on failure.
* Success and failure both return to `completeUrl`, so **branch on `status`** — don't treat the redirect itself as success. (Set an optional `failUrl` only if you want failures on a separate page.)

Always branch on `status`

Because 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`[​](#5-reconcile-with-stripe "Direct link to 5-reconcile-with-stripe")

MONEI POSTs the **full [Payment object](https://docs.monei.com/apis/rest/schemas/payment/.md)** (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](https://docs.stripe.com/payments/payment-records) entry that reports the result.

1. **Verify the `MONEI-Signature` header** to confirm the request came from MONEI. See [Verify signatures](https://docs.monei.com/guides/verify-signature/.md).
2. **Return `200 OK`** immediately, or MONEI retries.
3. Map the MONEI `status` to a Stripe [Payment Records](https://docs.stripe.com/payments/payment-records) call (see the [mapping table](#status-mapping)).
4. Use the MONEI payment id as the **idempotency key** so retries and duplicate callbacks don't double-report.

server.js — callback handler

```
// 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`](https://docs.stripe.com/api/payment-record/report-refund/report) and the `report_payment_attempt_*` calls all act on that record. The [sample repository](#sample-repository) has the full handler (all statuses + refunds).

tip

`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[​](#refunds "Direct link to 6. Refunds")

Refund through MONEI, then report the refund to Stripe:

* cURL
* Node.js
* PHP
* Python

POST https\://api.monei.com/v1/payments/{id}/refund

```
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}'
```

server.js

```
import {Monei} from '@monei-js/node-sdk';



const monei = new Monei('YOUR_API_KEY');



await monei.payments.refund('PAYMENT_ID', {amount: 1000});
```

server.php

```
<?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])

);

?>
```

server.py

```
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`](https://docs.stripe.com/api/payment-record/report-refund/report) **on the Payment Record** you stored in Section 5:

server.js — callback handler (refund)

```
// 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[​](#status-mapping "Direct link to 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`](https://docs.stripe.com/api/payment-record/report-payment-attempt-failed/report) / [`report_payment_attempt_canceled`](https://docs.stripe.com/api/payment-record/report-payment-attempt-canceled/report) 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](https://docs.monei.com/apis/rest/payments-capture/.md) later (Bizum holds for 30 days); those payments report as `AUTHORIZED` until you capture, then `SUCCEEDED`.

## Test your integration[​](#testing "Direct link to Test your integration")

* Use your [test mode](https://docs.monei.com/testing/.md) MONEI API key and Stripe test mode keys.
* Use the test [Bizum phone numbers](https://docs.monei.com/testing/.md#test-bizum-phone-numbers).
* Check the result in [MONEI Dashboard → Payments](https://dashboard.monei.com/payments) and confirm the Stripe Payment Record shows **Bizum — Succeeded** with `payment_reference` equal to the MONEI payment id and your metadata intact.

## Sample repository[​](#sample-repository "Direct link to 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`](https://github.com/MONEI/stripe-bizum-cpm-example). It mirrors the contract on this page exactly.

## Common questions[​](#common-questions "Direct link to Common questions")

### Do I need OAuth or a MONEI-hosted connector?[​](#do-i-need-oauth "Direct link to 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?[​](#why-not-no-code "Direct link to 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](https://support.monei.com/hc/requests/new).

### Which URL does a failed payment return to?[​](#failed-payment-url "Direct link to 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.
