If your checkout uses Stripe's Payment Element, you can offer BlinkPay in it as a Stripe custom payment method. Stripe shows BlinkPay beside your other payment methods, while the payment itself is a BlinkPay Quick Payment using the Gateway Flow, created by your backend. The outcome is then recorded in your Stripe account, so your Stripe reporting shows every payment. You set this up in your own Stripe account; no agreement with Stripe is needed.
High-level flow:
redirect_uricidStripe never processes a BlinkPay payment: it displays the option and receives the final record. Settlement stays with BlinkPay, and refunds are made from this portal.
Use this guide when your checkout is built on Stripe's Payment Element and you control the backend that creates your PaymentIntents. Stripe-hosted Checkout requires Stripe's approval to show custom payment methods, and Payment Links don't support them. If your store runs on a platform that embeds Stripe for you, such as Shopify, Squarespace or Wix, this approach isn't available; see Gateway Integration instead.
Bank support and payment limits are the same as for any Gateway Quick Payment. See Bank Coverage.
You need a Stripe account with a Payment Element checkout, a backend you control, and your BlinkPay client credentials for both sandbox and production (see Authentication). Contact support for the BlinkPay logo to display in your checkout.
Do this in a Stripe sandbox (or test mode) first, then repeat it in live mode when you go live. Sandbox settings do not carry over to live mode.
cpmt_pmc_ ID from your checkout page and backend| Value | Used by | Secret |
|---|---|---|
Custom payment method type ID (cpmt_...) | Checkout page and backend | No |
Stripe publishable key (pk_test_... / pk_live_...) | Checkout page | No |
Stripe secret key (sk_test_... / sk_live_...) | Backend only | Yes |
Payment method configuration ID (pmc_..., optional) | Checkout page and backend | No |
| BlinkPay client ID and client secret | Backend only | Yes (the secret) |
Keep Secrets on Your Backend
Never put your Stripe secret key or BlinkPay client secret in frontend code or frontend build variables. Anything your checkout page can read, your customers can read too.
All Stripe values must come from the same account and the same mode. Stripe test mode pairs with the BlinkPay sandbox (sandbox.debit.blinkpay.co.nz), and Stripe live mode pairs with BlinkPay production (debit.blinkpay.co.nz).
BlinkPay redirects the customer back to your return page after they authorise, so that URL must be whitelisted for your account. Add it in the portal under Settings > API (Developer and Administrator roles), once for sandbox and once for production. If you don't have either role, contact support@blinkpay.co.nz.
The scheme, host and port of the redirect_uri in your request must match a whitelisted entry, and its path must start with the entry's path. See Redirect URI Whitelisting for the matching rules.
Load Stripe.js from https://js.stripe.com/v3/ (Stripe does not allow it to be bundled or self-hosted), and add your cpmt_ ID under customPaymentMethods when you create Elements. On submit, elements.submit() validates the form and returns selectedPaymentMethod. When it is BlinkPay, ask your backend to start the payment and redirect the customer to the redirect_uri it returns; otherwise keep your normal Stripe path.
Generate one UUID per payment method per checkout attempt and send it as the Idempotency-Key header, reusing it on a retry of the same request, so a double click or a retried request returns the same payment rather than creating a second one. The key belongs to one attempt: generate a new one after a failed or cancelled attempt, and whenever the cart changes.
const BLINKPAY_CPM_TYPE_ID = 'cpmt_...';
const stripe = Stripe('pk_test_...');
const elements = stripe.elements({
mode: 'payment',
amount: 4999, // cents; display only, your backend sets the amount charged
currency: 'nzd',
customPaymentMethods: [
{
id: BLINKPAY_CPM_TYPE_ID,
options: { type: 'static', subtitle: 'Pay directly from your bank' }
}
]
});
elements.create('payment').mount('#payment-element');
const attemptKeys = { blinkpay: crypto.randomUUID(), stripe: crypto.randomUUID() };
document.querySelector('#payment-form').addEventListener('submit', async (event) => {
event.preventDefault();
const { error, selectedPaymentMethod } = await elements.submit();
if (error) return showMessage(error.message);
if (selectedPaymentMethod === BLINKPAY_CPM_TYPE_ID) {
const res = await fetch('/checkout/blinkpay', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': attemptKeys.blinkpay },
body: JSON.stringify({ cartId })
});
if (!res.ok) {
attemptKeys.blinkpay = crypto.randomUUID(); // the next attempt needs a new key
return showMessage('We could not start your payment. Please try again.');
}
const { redirect_uri } = await res.json();
window.location.assign(redirect_uri);
return;
}
// Cards and other Stripe-processed methods: your existing PaymentIntent flow
const res = await fetch('/checkout/stripe-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': attemptKeys.stripe },
body: JSON.stringify({ cartId })
});
const { client_secret } = await res.json();
const { error: confirmError } = await stripe.confirmPayment({
elements,
clientSecret: client_secret,
confirmParams: { return_url: `${location.origin}/checkout/return` }
});
if (confirmError) showMessage(confirmError.message);
});
The /checkout/... paths are examples; use your own. In React, keep the attempt keys in useMemo; in Vue or Nuxt, keep them in component state so they survive re-renders. Regenerate them when the cart changes.
Your backend makes every BlinkPay API call. Look up the browser's Idempotency-Key first: if it already maps to an order, return that order's stored redirect_uri without creating anything, so a double submit never stores two orders. Otherwise calculate the amount from your own cart rather than trusting the browser, save the order with the key, then create a Quick Payment with the Gateway flow, forwarding the key as idempotency-key. The SDKs obtain and refresh the access token for you.
POST /payments/v1/quick-payments HTTP/1.1
Host: sandbox.debit.blinkpay.co.nz
Authorization: Bearer {access_token}
idempotency-key: {browser's Idempotency-Key}
request-id: {new UUID}
x-customer-ip: {customer's IP address}
x-customer-user-agent: {customer's User-Agent}
Content-Type: application/json
{
"flow": {
"detail": {
"type": "gateway",
"redirect_uri": "https://www.yourwebsite.com/checkout/return"
}
},
"amount": {
"currency": "NZD",
"total": "49.99"
},
"pcr": {
"particulars": "Order1234",
"code": "Stripe",
"reference": "INV-5678"
}
}
Response:
{
"quick_payment_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"redirect_uri": "https://gateway.blinkpay.co.nz/redirect/a1b2c3d4-..."
}
Store the quick_payment_id on the order before returning the redirect_uri to your checkout page. A 409 means the key has already been used for a different or finished request; return an error so the checkout page starts a fresh attempt with a new key (see Error Codes).
After the customer completes or cancels the payment, they are redirected to your return page with these query parameters:
| Parameter | Description |
|---|---|
cid | Consent ID. For a Quick Payment this is the same value as quick_payment_id |
status | Present only when the outcome is not success: cancelled, rejected, timeout, error or pending. See Return Parameters |
error | With status, a customer-safe message you can display |
The return URL does not indicate the final result, it can be forged, and it can be replayed. Resolve the order from the quick_payment_id you stored in Step 4 and the customer's session, treating cid only as a prompt to look, and never disclose the outcome of a payment that does not belong to that session. Your return page then asks your backend for the outcome, and your backend retrieves the Quick Payment:
GET /payments/v1/quick-payments/{quick_payment_id} HTTP/1.1
Host: sandbox.debit.blinkpay.co.nz
Authorization: Bearer {access_token}
Make this call as soon as the customer returns. Creating the Quick Payment only obtained the customer's consent; this retrieval completes the payment. The gateway normally does this for you, and calling it again is safe. If it returns a 4xx or 5xx, retry the GET rather than treating the payment as failed. Then poll every second until the outcome is final, for a minute or two at most, showing the customer a processing message while you wait. See Polling. consent.payments lists the newest payment first, so read payments[0]:
| Status | Outcome |
|---|---|
consent.payments[0].status is AcceptedSettlementCompleted | Succeeded: the only status that means the payment succeeded |
consent.payments[0].status is Rejected | Failed |
consent.status is Revoked or GatewayTimeout | Failed. These consents carry no payment entry |
Anything else, including AcceptedSettlementInProcess | Still processing: keep polling |
Make the outcome handling idempotent: change the order only from pending, so a replayed return after a cancellation re-reads the payment and never reopens the order, and a reload after success does nothing. If your polling window ends with the payment still AcceptedSettlementInProcess, tell the customer the payment is being processed and that you will confirm it, and leave it to the job in Step 7. See Payment Lifecycle for every status.
Report each paid or failed payment to Stripe once, in its final state, with Stripe's Payment Records API. It then appears in your Stripe account beside your card payments. The parameters, as your Stripe library takes them:
{
"amount_requested": { "currency": "nzd", "value": 4999 },
"initiated_at": 1790000000,
"customer_presence": "on_session",
"payment_method_details": { "type": "custom", "custom": { "type": "cpmt_..." } },
"processor_details": { "type": "custom", "custom": { "payment_reference": "{quick_payment_id}" } },
"outcome": "guaranteed",
"guaranteed": { "guaranteed_at": 1790000060 },
"metadata": { "order_id": "Order1234" }
}
For a failed payment, send "outcome": "failed" with "failed": { "failed_at": ... } instead. Take initiated_at from the consent's creation_timestamp and the outcome time from the payment's status_updated_timestamp, in Unix seconds. A consent that ended Revoked or GatewayTimeout has no payment entry, so use the consent's status_updated_timestamp; whether to report an attempt abandoned before the bank as a failed payment at all is your call. Use the quick_payment_id as the Stripe idempotency key and store the returned record ID on the order, so the same payment is never reported twice. Payment Records need Stripe API version 2025-10-29.clover or later.
Recording is reconciliation, not part of taking the payment: if the Stripe call fails, still show the customer their outcome and retry the report later.
A customer who closes the browser at their bank never reaches your return page, and their consent ends Rejected or GatewayTimeout once the authorisation window elapses (see Authorisation Timeouts). A payment made late in the day may also still be AcceptedSettlementInProcess when your polling window ends. Run the periodic wash-up: a scheduled job that retrieves each Quick Payment for an order still pending in your records, resolves the order, and records the outcome in Stripe. If you run several servers, make sure only one runs the job at a time.
If your backend is on a different origin from your checkout page, answer OPTIONS requests on every checkout endpoint: the JSON Content-Type and the Idempotency-Key header make the browser send one before each POST. Allow your checkout page's origin by exact match, the methods GET, POST, OPTIONS, and the headers Content-Type, Idempotency-Key.
If your site sends a Content Security Policy, it must allow Stripe.js and your backend. BlinkPay needs no entries: the redirects to and from the Gateway are full-page navigations, and the BlinkPay logo is shown inside Stripe's frame.
| Directive | Add |
|---|---|
script-src | https://js.stripe.com https://*.js.stripe.com |
frame-src | https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com |
connect-src | https://api.stripe.com, plus your backend's origin if it differs from the page's |
These are the Stripe.js directives from Stripe's security guide, which also lists extra entries for Link and the Address Element.
For general issues, see the Troubleshooting Guide.
Quick checks:
cpmt_ ID and publishable key come from different Stripe accounts, sandboxes or modesOPTIONS requests are not answered, or CORS does not allow the Idempotency-Key header2025-10-29.clover, or the secret key is from a different modeEnvironments: Check at startup that your Stripe key's mode matches your BlinkPay host, and refuse to run a mismatch. A live Stripe key against the BlinkPay sandbox would record test payments in your live Stripe account.
Polling: If you use an SDK, poll with its get methods rather than its await helpers. The await helpers revoke the Quick Payment when they time out, which is wrong for a customer who is still authorising.
Refunds: The refund button in the Stripe Dashboard cannot refund a BlinkPay payment. For a bank payment no money moves through BlinkPay: open the payment on the Payments page of this portal and select Refund Information to get the customer's account number, then pay them back from your own bank. Report the refund against the Payment Record with Stripe's report_refund so both records agree. See Refunds for how the refund types work.
Test end to end with your Stripe sandbox and the BlinkPay sandbox, using the test credentials in the Testing Guide. Cover a successful payment, a payment declined at the bank, a customer who closes the browser at the bank step, and a retried start-payment request with the same Idempotency-Key. For each, confirm the outcome your customer sees and the Payment Record in your Stripe Dashboard.