🎓 CoursesAcceptance & card systemsIntermediate⏱ 60 min
🧩
Integrating a PSP from A to Z. 6 chapters and a final quiz.
The full integration project, from the merchant's side. Choose a payment provider, get to grips with the sandbox and API keys, create a payment, make webhooks reliable with idempotency, model the state machine, and handle capture and refunds. Then go live with a test checklist worthy of a card payments team.
Compare PSPs on objective criteria (pricing, coverage, API, exit terms) and avoid lock-in
Work with a sandbox, secure API keys, and tell apart the publishable key, the secret key and the webhook secret
Create a payment end to end: intent, amount in cents, 3-D Secure, authorization
Build a robust webhook consumer: signature, fast response, idempotency, reconciliation
Chapter 1. Scoping the project and choosing a PSP.
Integrating a payment service provider (PSP) is a project that commits a company's cash flow, compliance and customer experience for years, well beyond plugging into an API. Scoping comes before code. It covers the countries served, the payment methods expected, the projected volume, and the sales model (direct sales, subscriptions, marketplace). This scoping should drive the choice of provider far more than how polished its documentation looks.
What a PSP (really) does
Collects payment data in a PCI DSS–compliant environment, so the merchant does not have to
Routes transactions to one or more acquirers and to the card networks (France's domestic CB scheme, Visa, Mastercard…)
Orchestrates the 3-D Secure authentication that PSD2 requires in Europe
Aggregates local payment methods: wallets, bank transfers, BNPL, iDEAL, Pix, Wero…
Pays out funds to the merchant's account, net of fees
The criteria that matter
🌍
Coverage
Countries where you collect payments, settlement currencies, local methods. A PSP that excels in France can be average in Brazil or Asia.
💶
Pricing
Blended or interchange++, plus refund, dispute and FX fees. Insist on a simulation based on your actual transaction mix.
📈
Authorization rate
Gaining 1 point of authorization rate often brings in more than a fee that is 0.1% lower. Ask for figures by country and by card type.
🛠️
API and documentation quality
A complete sandbox, maintained SDKs, signed webhooks, a changelog, API versioning. Your teams will live with it for years.
🏛️
Compliance and licensing
Payment institution or e-money institution status (in France, licensed by the ACPR), safeguarding of funds, data location.
🔓
Reversibility
Portability of card tokens, export of mandates and subscribers, contractual notice period. The cost of leaving is negotiated on the way in.
Model
How it works
Who it suits
Watch out for
Blended
A single all-in rate, e.g., 1.4% + €0.25 per European transaction
Small businesses, modest volumes, need for simplicity
The PSP keeps the margin when interchange falls; not very transparent
Interchange++
Actual interchange + network fees + disclosed PSP margin
Large volumes, teams able to audit statements
Complex statements; compare the “++” line across offers
Flat fee / platform
Subscription + tiered per-transaction price, often bundled with software
Verticals (restaurants, SaaS) where payments are embedded
Keep the cost of payments clearly separate from the cost of the software
The three main PSP pricing models
0,2 % / 0,3 %
EU interchange caps (debit/credit), the basis of the interchange++ model
Regulation (EU) 2015/751
100+
payment methods offered by the major international PSPs
Stripe documentation, 2026
2 to 6 weeks
typical length of the merchant-side integration project, testing included
Integrator feedback, 2025
Some of the PSPs and networks you will meet in the RFPStripeAdyenPayPalVisaMastercardCACartes Bancaires (CB)
🔑
Plan your exit from day one
Require card token portability in the contract (migration to another PSP or to network tokens), along with the export of SEPA mandates. Without it, migrating forces every subscriber to re-enter their card, and the merchant typically loses 5% to 15% of its base along the way. That is exactly what lock-in costs.
🎯 Quick question
Under interchange++ pricing, what exactly does the merchant pay?
Chapter 2. Working with the sandbox, API keys, and secrets.
Every serious PSP provides a sandbox: an isolated test environment with no real money that mirrors the production API, and where your team will spend its first few weeks. Start by creating the test account and generating the API keys. Then deliberately trigger every scenario (success, decline, 3-D Secure, expiry) before you even think about the happy path.
Test cards: 4242 4242 4242 4242 (success at Stripe) and its variants that simulate a decline, insufficient funds, a stolen card, or a 3DS failure
Simulated 3-D Secure: mock challenge pages to test frictionless and challenge flows without a real bank
Test webhooks: replayable on demand from the dashboard, essential for chapter 4
Dummy data: test IBANs, magic amounts that trigger specific behavior (e.g., an amount that forces a decline)
Sandbox dashboard: the same screens as in production, to train support and accounting before go-live
First call: creating a payment in the sandbox (test key)
Initialize the payment component, tokenize a card; never read or charge
Rarely needed, low sensitivity
Secret (sk_…)
Server only, injected from a secrets vault
Create, capture, refund: full control over the account
Immediately if leaked; otherwise on a schedule (e.g., every six months)
Webhook secret (whsec_…)
Server, one secret per endpoint
Verify the HMAC signature of incoming notifications
Whenever the endpoint changes or a leak is suspected
The three types of keys and where each may be exposed
⚠️
The secret key NEVER leaves the server
An sk_live_… key left in a Git repository, a JavaScript bundle or an application log gives an attacker the power to refund your sales to other cards or to siphon off your customer data. Store it in a secrets manager (Vault, AWS Secrets Manager…) and use permission-restricted keys when the PSP offers them. Your CI pipeline should include secret scanning.
One last point of method: treat the sandbox as a full-fledged environment, with its own versioned configuration. Teams that get go-live right maintain strict parity between test and production: the same API versions, the same webhook subscriptions, the same currencies, with just one difference, the key prefix (sk_test_ versus sk_live_).
🎯 Quick question
Where is a secret API key (sk_…) allowed to appear?
Chapter 3. Creating a payment: from intent to authorization.
Modern payment APIs are built on the payment intent pattern. The merchant's server first declares the amount it wants to collect, and the PSP returns an ID and a client secret. The browser then confirms the payment with the card data, which never passes through the merchant's server. This two-step split absorbs 3-D Secure authentication and asynchronous payment methods.
Life cycle of an intent-based payment
Merchant server
Creates the payment intent
POST /payments, amount recalculated on the server, currency, idempotency key
➜
PSP
Returns the ID and the client_secret
Initial status: created / requires_payment_method
➜
Customer's browser
Confirms with tokenized card data
The PAN goes straight to the PSP through the front-end component
➜
Issuer (the customer's bank)
Authenticates through 3-D Secure if required
Frictionless or challenge (SMS, banking app), a PSD2 requirement
➜
PSP
Requests authorization from the network
Response in 1 to 2 seconds: approved or declined + reason code
➜
Merchant server
Receives the confirmation webhook
payment.authorized / payment.captured, the source of truth
⚠️
Amounts are in cents
Almost every payment API expects the amount in the smallest currency unit, so you write 4990 for €49.90. The factor-of-100 error, charging €4,990 instead of €49.90, is a classic of botched launches. Add an automated test that rejects any outlandish amount. Watch out for zero-decimal currencies such as the yen: ¥500 is written 500, not 50000.
Idempotency key on creation: send a unique Idempotency-Key header per order; after a network timeout, replaying the request with the same key will not create a second payment
Amount recalculated on the server: never use an amount from the browser, which a savvy customer can tamper with
Currency in ISO 4217 (eur, usd, jpy), consistent with the settlement account
`metadata.order_id` every time: it is the thread that ties together the payment, the order and accounting reconciliation
A clean `return_url`: the return page shows “payment being confirmed” and validates nothing itself
🎯 Quick question
Your PSP's API expects amounts in the smallest currency unit. What should you send to collect €49.90?
Chapter 4. Handling webhooks reliably, with mandatory idempotency.
A modern payment is asynchronous by nature: 3-D Secure can take two minutes, a bank transfer two days, a dispute two months. The PSP notifies you of every status change by webhook, an HTTP POST request to your server. This channel carries the entire integration, and it is also where most production incidents start: webhooks lost, processed twice, or received out of order.
Receiving a webhook the right way
PSP
POST /webhooks/psp
JSON body + HMAC signature in the header
➜
Merchant server
Verifies the signature
HMAC-SHA256 over the raw body, whsec_… secret
➜
Merchant server
Deduplicates by event.id
Database insert with a uniqueness constraint
➜
Merchant server
Responds 200 immediately
Within 5 seconds, before any heavy processing
➜
Processing queue
Processes the event
Order update, email, accounting, all asynchronous
The five golden rules of a webhook consumer
Verify the signature on the raw request body (before any JSON parsing): otherwise anyone can send you a fake payment.captured
Respond 2xx fast (< 5 s): PSPs treat any other code or a timeout as a failure and redeliver the notification
Process in an asynchronous queue: acknowledge first, work later, and never send emails or call the ERP inside the HTTP handler
Deduplicate by event ID: delivery is guaranteed at least once, so duplicates are normal, not exceptional
Tolerate out-of-order events and reconcile: a payment.captured can arrive before the payment.authorized; when in doubt, query the API (GET /payments/{id}), which remains the source of truth
Idempotent webhook handler (Node.js, pseudocode)
app.post("/webhooks/psp", async (req, res) => {
// 1. Signature on the raw body — reject without processing if invalid
const signature = req.headers["psp-signature"];
if (!verifyHmacSha256(req.rawBody, signature, WEBHOOK_SECRET)) {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.rawBody);
// 2. Idempotency: event.id is UNIQUE in the database.
// If the insert fails, the event was already received.
const inserted = await db.webhookEvents.insertIfAbsent({
id: event.id,
type: event.type,
receivedAt: new Date(),
});
if (!inserted) {
return res.status(200).send("duplicate ignored"); // already processed: 200 anyway
}
// 3. Acknowledge fast, process asynchronously
await queue.enqueue(event);
return res.status(200).send("ok");
});
🔑
Idempotency: the definition to remember
A process is idempotent when receiving it once or ten times produces exactly the same result: one order shipped, one email sent, one accounting entry. On the outbound side, the Idempotency-Key protects your payment creation calls. On the inbound side, deduplication by event.id protects your webhook processing. Both are non-negotiable.
3 days
maximum period during which Stripe retries an unacknowledged webhook (exponential backoff)
Stripe documentation, 2026
< 5 s
response time beyond which most PSPs count the delivery as failed
Stripe / Adyen documentation, 2026
≥ 1 time
webhook delivery guarantee: at least once, never exactly once
Standard industry model
⚠️
The browser return proves nothing
Never trigger shipping or service activation just because the customer lands on the return_url: the user can close the tab first, or forge the URL. The order moves to “paid” only when the signed webhook arrives (or a GET /payments/{id} confirms the status). The browser return only serves to show a friendly waiting screen.
🎯 Quick question
Your server receives the same webhook event a second time (same event.id, already processed). What should it do?
Chapter 5. Modeling the state machine, capture, and refunds.
A payment moves through a state machine, whereas a simple “paid / not paid” boolean would distinguish only two positions. Modeling it explicitly in your code, with the list of allowed transitions, remains the best antidote to integration bugs. It then becomes impossible to refund a payment that was never captured, or to ship an order still waiting on 3DS.
A payment's state machine (TypeScript)
type PaymentStatus =
| "created" // intent created, waiting for the customer
| "requires_action" // 3-D Secure or redirect in progress
| "authorized" // funds held at the issuer, nothing collected yet
| "captured" // capture requested: collection has started
| "settled" // funds settled to the merchant (D+1 to D+3)
| "failed" // issuer decline, 3DS failure, expiry
| "canceled" // void before capture
| "refunded"; // refunded, fully or partially
const transitions: Record<PaymentStatus, PaymentStatus[]> = {
created: ["requires_action", "authorized", "failed"],
requires_action: ["authorized", "failed", "canceled"],
authorized: ["captured", "canceled"],
captured: ["settled", "refunded"],
settled: ["refunded"],
failed: [],
canceled: [],
refunded: [],
};
function assertTransition(from: PaymentStatus, to: PaymentStatus): void {
if (!transitions[from].includes(to)) {
throw new Error("Forbidden transition: " + from + " -> " + to);
}
}
Capture now or later?
Immediate capture
Delayed capture
How it works
Authorization and capture in the same call
Authorization only, capture triggered later (shipping, hotel checkout)
Use cases
Digital goods, instant services, standard e-commerce
Delayed shipping, rentals, hotels, uncertain final amount
Constraint
A refund is required if the order is canceled
The authorization expires: 10 days at Visa for a customer-initiated card-not-present payment, 5 days for a merchant-initiated one (longer in some sectors)
Flexibility
None: the amount captured is the amount authorized
Partial capture possible (orders shipped in several parcels), depending on the PSP
Immediate capture vs. delayed capture
Voiding is not refunding
As long as the payment is only authorized, you can void it, a free and near-instant operation that lifts the hold at the issuer before any money has moved. Once it is captured, you have to refund it. A reverse money flow goes back to the customer's card and takes 3 to 10 days to show up on their statement, and the PSP generally does not return the original fees.
ℹ️
Void first, refund second
When an order is canceled, check the payment status first. authorized → void (free, instant); captured or settled → refund (charged, delayed). A refund is itself asynchronous: it goes through a pending status and can fail (expired card), so you also need to handle a refund.failed webhook.
Partial refund: several successive refunds are possible, up to the amount captured, never beyond
Dispute after a refund: a customer can open a chargeback even after a refund has been issued; keep proof of the refund for representment
Traceability: each refund has its own ID, to be linked to the order_id for accounting reconciliation
🎯 Quick question
An order is canceled while the payment is in “authorized” status (not captured). What is the right operation?
Chapter 6. Going live with a test checklist.
Prepare a payment integration's move to production like a house move: anything that has not been tested will break in transit. Go-live is not an event, it is a process. It happens as a phased rollout, under close monitoring, with the ability to roll back at every step.
J-30
Full functional testing in the sandbox
Every scenario on the checklist run and documented, including failure cases.
J-15
End-to-end tests and security review
Webhooks under real conditions (replays, out-of-order, duplicates), secrets review, load tests on the webhook endpoint.
J-7
Production setup
Live keys in the vault, production webhook endpoints registered and verified, PCI self-assessment questionnaire (SAQ) signed.
J-0
Phased go-live
5% to 10% of traffic behind a feature flag, small real transactions checked end to end (all the way to the settlement report).
D+7
Full rollout and review
100% of traffic, authorization rates compared with forecasts, first full accounting reconciliation.
✅ Full and partial refunds, including a failed refund
✅ Amounts: cents verified, zero-decimal currency (JPY) if relevant, server amount ≠ client amount rejected
✅ State machine: every forbidden transition raises an error
✅ Monitoring: alerts on a drop in authorization rate, on webhook failures, on a growing processing queue
✅ Reconciliation: the PSP's settlement report is automatically matched against orders
✅
A phased go-live is your best insurance
Moving 5% of traffic behind a feature flag reveals within an hour what a big-bang launch would have turned into a crisis: the wrong settlement account, an unregistered production webhook, or an amount multiplied by 100. Keep the old flow live, with a rollback switch, until the first full reconciliation has been signed off.
5-10 %
recommended share of traffic for the first go-live wave
Common practice among payments teams, 2026
5 to 10 days
typical validity of a card authorization (Visa: 10 days online, 5 days in store or for a merchant-initiated payment): watch it if you capture later
Visa rules, 2025
D+1 to D+3
usual time for the PSP to settle funds after capture
Standard terms of European PSPs, 2026
🔑
Testing does not stop at go-live
A payment integration is judged over time, with automated daily reconciliation, a monthly review of authorization rates and decline reasons, and a watch on the PSP's API changes. Teams that treat payments as a living product, not a finished project, do not discover their cash discrepancies six months later.
🎯 Quick question
What event should trigger shipping for an order paid by card?