Share:
Payment Gateway API Integration Guide 2026
Published: March 13, 2026 | Reading Time: 16 minutes
About the Author
Nirmalraj R is a Full-Stack Developer at AgileSoftLabs, specializing in MERN Stack and mobile development, focused on building dynamic, scalable web and mobile applications.
Key Takeaways
- Understand the complete payment lifecycle architecture from frontend to webhook processing
- Learn the frontend → backend → gateway → webhook → database flow that ensures payment integrity
- Implement secure server-side payment verification using cryptographic webhook signatures
- Prevent fraud and duplicate transactions with idempotency keys and event deduplication
- Design scalable, production-ready payment systems that handle subscriptions, refunds, and failures
- Follow real-world security and PCI DSS best practices for compliance
- Handle edge cases including orphaned orders, webhook retries, and payment reconciliation
Introduction: Why Most Payment Integrations Fail in Production
Payment gateway integration is one of the most underestimated engineering challenges in web development. After building payment processing architecture for over a dozen production systems — including high-volume SaaS platforms, multi-vendor marketplaces, and subscription billing engines — I have seen the same category of failures appear repeatedly, regardless of team experience level.
The surface-level assumption is understandable: redirect the user to Stripe or Razorpay, wait for the success callback, and mark the order paid. In reality, that approach fails under load, fails under network instability, and fails entirely against a motivated attacker who simply navigates directly to your /payment/success route.
A production-grade payment integration involves eight distinct concerns:
- Server-side order creation and price validation before any gateway interaction
- Secure payment session generation using gateway APIs
- Authenticated redirection to the hosted checkout
- Cryptographically verified webhook processing — not URL callbacks
- Idempotent database writes that survive duplicate webhook delivery
- Subscription lifecycle management across billing cycles
- Refund and chargeback workflows with audit trails
- Payment failure handling with meaningful recovery paths
This guide walks through each layer, with working code examples, production-tested patterns, and the specific decisions that distinguish a secure payment-processing architecture from a brittle prototype.
At AgileSoftLabs, we've implemented payment systems across e-commerce platforms, SaaS products, and marketplace applications. Our web application development services include secure payment architecture as a core competency.
Payment gateway integration is a distributed-system problem that involves order lifecycle management, cryptographic webhook verification, idempotency enforcement, and failure recovery—not just a redirect flow.
What Is a Payment Gateway? (And What It Actually Does)
A payment gateway is the intermediary system that authorizes, routes, and settles financial transactions between a customer's issuing bank and a merchant's acquiring bank. The gateway encrypts card data, performs fraud screening, communicates with card networks (Visa, Mastercard, RuPay), and returns authorization decisions — typically in under two seconds.
Payment Gateway Comparison
| Gateway | Best Fit |
|---|---|
| Stripe | International SaaS, complex subscriptions, marketplace payouts via Stripe Connect |
| Razorpay | Indian market, UPI, NEFT, domestic cards, GST invoicing |
| PayPal | Donation platforms, consumer-facing checkouts with existing PayPal wallets |
| Square | In-person + online hybrid commerce |
Established gateways handle card data tokenization, PCI DSS compliance at the card-data layer, currency conversion, fraud signals, and webhook delivery infrastructure. This offloads the most regulated portions of payment processing from your application — but it does not eliminate your architectural responsibility for the order lifecycle.
Teams building e-commerce platforms can leverage our AI for e-commerce solutions, which include pre-configured payment gateway integrations with advanced fraud detection.
How Online Payments Actually Work: The Full Entity Flow
Most tutorials show a two-party diagram. Production systems involve six entities, and understanding each one matters for debugging failures and designing resilient architecture.
Entities Involved in Every Card Transaction
- Customer — initiates payment via your frontend
- Your application — creates the order, generates the session, processes webhooks
- Payment Gateway (Stripe, Razorpay) — tokenizes card data, routes the authorization request
- Acquiring Bank — your merchant bank that receives settlement funds
- Card Network (Visa, Mastercard) — routes between acquiring and issuing banks
- Issuing Bank — the customer's bank that approves or declines the transaction
Authorization Flow, Step by Step
- Customer submits payment details on the gateway-hosted checkout page — card data never touches your servers
- Gateway tokenizes the card and sends an authorization request through the card network
- The issuing bank verifies available funds, checks fraud signals, and returns an approval or decline
- Gateway receives the authorization result and notifies your server via webhook
- Your backend processes the webhook, verifies the cryptographic signature, and updates the order record
- Frontend receives confirmation from your backend and renders the success state
The entire authorization typically completes in 1.5 to 2.5 seconds. The webhook delivery to your server usually follows within 5 to 30 seconds, which is why your success URL must never be the source of truth for payment status.
Complete End-to-End Payment Processing Architecture
The Architecture Rule
Never derive payment status from URL parameters, query strings, or frontend callbacks. The webhook event — verified with a cryptographic signature — is the only authoritative signal that a payment occurred.
For mission-critical applications requiring robust payment architecture, explore our custom software development services where we implement enterprise-grade payment systems with comprehensive audit trails.
Step 1: Creating the Order in Your Backend Before Touching the Gateway
This is the step most tutorials skip, and it prevents the largest category of production incidents.
Before your backend makes a single API call to Stripe or Razorpay, it must:
- Authenticate the requesting user
- Fetch the canonical price from your database — never from the request body
- Create a durable order record with
status: pending - Associate the order with the user and the product
Production Lesson: I learned this the hard way on an early SaaS project, where we were reading the amount field from the frontend POST body. A user noticed, sent a modified amount, and purchased a $199/month plan for $0.01. The gateway processed it without complaint because the amount we sent was technically valid — it was just wrong. Fetching prices server-side from your own database is non-negotiable.
// POST /create-payment
app.post('/create-payment', authenticate, async (req, res) => {
const { planId } = req.body;
const userId = req.user.id;
// Fetch canonical price from DB — never trust client-submitted amounts
const plan = await db.query(
'SELECT id, name, price_cents, currency FROM plans WHERE id = $1 AND active = true',
[planId]
);
if (!plan.rows.length) {
return res.status(400).json({ error: 'Invalid plan' });
}
// Create a durable order record before any gateway interaction
// This ensures we have an audit trail even if the gateway call fails
const order = await db.query(
`INSERT INTO orders (user_id, plan_id, amount, currency, status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING id`,
[userId, plan.rows[0].id, plan.rows[0].price_cents, plan.rows[0].currency]
);
const orderId = order.rows[0].id;
// Pass orderId as metadata so webhook can reference it later
const session = await createGatewaySession(plan.rows[0], orderId);
// Store the gateway session ID for reconciliation
await db.query(
'UPDATE orders SET session_id = $1 WHERE id = $2',
[session.id, orderId]
);
res.json({ sessionId: session.id });
});
Step 2: Creating the Payment Session with the Gateway
async function createGatewaySession(plan, orderId) {
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: plan.currency, // Sourced from DB
product_data: {
name: plan.name,
description: plan.description,
},
unit_amount: plan.price_cents, // Stripe expects cents, not dollars
},
quantity: 1,
},
],
mode: 'payment', // Use 'subscription' for recurring billing
client_reference_id: orderId, // Links session to our internal order
metadata: {
order_id: orderId, // Available in webhook payload
plan_id: plan.id,
},
success_url: `${process.env.APP_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/payment/cancel`,
expires_at: Math.floor(Date.now() / 1000) + 1800, // Session expires in 30 min
});
return session;
}
Critical Notes
unit_amountis always in the smallest currency unit (cents for USD, paise for INR). Sending dollars directly is a common mistake that undercharges customers 100x.client_reference_idandmetadata.order_idare your reconciliation bridge — the webhook payload carries these fields so you can look up your internal order.- Set
expires_atexplicitly to prevent late completions, your system may not handle correctly.
Step 3: Redirecting the User to Hosted Checkout
// Frontend — React / Next.js
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
async function initiateCheckout(planId) {
const stripe = await stripePromise;
const response = await fetch('/create-payment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ planId }),
credentials: 'include', // Send auth cookies
});
if (!response.ok) throw new Error('Failed to create payment session');
const { sessionId } = await response.json();
// Redirect to Stripe-hosted checkout — card data never touches your app
const { error } = await stripe.redirectToCheckout({ sessionId });
if (error) {
showErrorToUser(error.message);
}
}
Step 4: Why Success URL Redirects Cannot Confirm Payment
I have audited systems at three separate companies where the entire payment confirmation logic lived in a /payment/success route handler reading query parameters from the URL. In every case, it was trivially bypassable: navigate directly to the URL with a fabricated session ID and the system granted access.
Beyond the security failure, successful URL redirects are also unreliable under normal conditions:
- Browser tab closed before redirect completes
- Network interruption between the gateway and the browser
- Mobile device backgrounded during checkout
The Correct Model
The success URL is a UX signal only. It should display a "Payment processing — hang tight" state while your backend webhook updates the order status. Your frontend should poll your backend's order status endpoint to determine when to show confirmed access.
// GET /order-status/:orderId — frontend polls this after landing on success URL
app.get('/order-status/:orderId', authenticate, async (req, res) => {
const order = await db.query(
'SELECT status FROM orders WHERE id = $1 AND user_id = $2',
[req.params.orderId, req.user.id]
);
if (!order.rows.length) return res.status(404).json({ error: 'Order not found' });
// Frontend polls every 2 seconds until status is 'paid' or 'failed'
res.json({ status: order.rows[0].status });
});
Organizations building subscription-based SaaS platforms can benefit from our cloud development services, which include serverless payment architectures optimized for scale and reliability.
Step 5: Webhook Processing — The Security Core of Your Payment System
A webhook is a server-to-server HTTP POST the gateway sends to your registered endpoint after a payment event. The payload is signed with an HMAC signature using your webhook secret, which you must verify before acting on any event.
Why Signature Verification is Non-Negotiable
Without it, any actor on the internet can POST a fake checkout.session.completed event to your endpoint, triggering order fulfillment without an actual payment. This attack vector has been exploited in production systems.
// POST /webhook — must receive raw body, not parsed JSON
app.post(
'/webhook',
bodyParser.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['stripe-signature'];
let event;
try {
// Stripe computes HMAC-SHA256 over the raw body using your webhook secret
event = stripe.webhooks.constructEvent(
req.body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`);
return res.status(400).json({ error: 'Invalid signature' });
}
switch (event.type) {
case 'checkout.session.completed':
await handlePaymentSuccess(event.data.object);
break;
case 'checkout.session.expired':
await handleSessionExpired(event.data.object);
break;
case 'payment_intent.payment_failed':
await handlePaymentFailed(event.data.object);
break;
case 'invoice.paid':
await handleInvoicePaid(event.data.object);
break;
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCancelled(event.data.object);
break;
case 'charge.refunded':
await handleRefund(event.data.object);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
// Return 200 immediately — non-200 triggers Stripe to retry the webhook
res.json({ received: true });
}
);
Idempotent Event Handling
Gateways guarantee at-least-once webhook delivery. Under network instability or Stripe's retry logic, your endpoint may receive the same event multiple times. Your handler must be idempotent.
async function handlePaymentSuccess(session) {
const orderId = session.metadata.order_id;
// Only transition from 'pending' to 'paid' — subsequent deliveries are no-ops
const result = await db.query(
`UPDATE orders
SET status = 'paid',
payment_intent_id = $1,
paid_at = NOW()
WHERE id = $2
AND status = 'pending'
RETURNING id`,
[session.payment_intent, orderId]
);
if (result.rows.length === 0) {
console.log(`Order ${orderId} already processed, skipping`);
return;
}
// Trigger downstream effects only on first successful processing
await provisionUserAccess(orderId);
await sendConfirmationEmail(orderId);
await emitAnalyticsEvent('payment_completed', { orderId });
}
Database Design for Payment Systems
-- Core orders table — your financial ledger
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
plan_id UUID REFERENCES plans(id),
amount INTEGER NOT NULL, -- Always in smallest currency unit
currency CHAR(3) NOT NULL DEFAULT 'usd',
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'paid', 'failed', 'refunded', 'cancelled', 'disputed')),
session_id TEXT UNIQUE,
payment_intent_id TEXT UNIQUE,
refund_id TEXT,
failure_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
paid_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Webhook event log — idempotency and audit trail
CREATE TABLE webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id TEXT UNIQUE NOT NULL, -- Gateway's event ID (e.g., evt_xxx)
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
processed BOOLEAN DEFAULT FALSE,
processed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Subscriptions table
CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
gateway_subscription_id TEXT UNIQUE NOT NULL,
gateway_customer_id TEXT NOT NULL,
plan_id UUID REFERENCES plans(id),
status TEXT NOT NULL,
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_session_id ON orders(session_id);
CREATE INDEX idx_webhook_events_event_id ON webhook_events(event_id);
The webhook_events table is one I add to every production system. Before processing any webhook, check whether the event_id already exists. If it does, return 200 and exit — the event is a duplicate.
For scalable database architectures handling high-volume transactions, our products include enterprise financial management platforms with built-in payment reconciliation.
Handling Payment Failures: Architecture and Recovery
In production systems processing meaningful volume, I routinely see failure rates between 5% and 15%, depending on the market and payment method. Designing for failure is as important as designing for success.
Payment Failure Types and Responses
| Failure Type | Gateway Signal | Recommended Response |
|---|---|---|
| Insufficient funds | insufficient_funds | Show specific message, offer retry |
| Card expired | expired_card | Prompt card update |
| Bank declined | do_not_honor | Generic decline, suggest contacting bank |
| Network timeout | No webhook received | Background job checks for orphaned pending orders |
| Duplicate charge attempt | Idempotency key match | Return original response, no new charge |
Orphaned Order Recovery
The background job most systems are missing:
// Runs every 30 minutes via cron
async function recoverOrphanedOrders() {
const orphaned = await db.query(
`SELECT id, session_id FROM orders
WHERE status = 'pending'
AND created_at < NOW() - INTERVAL '1 hour'`
);
for (const order of orphaned.rows) {
const session = await stripe.checkout.sessions.retrieve(order.session_id);
if (session.payment_status === 'paid') {
// Webhook was missed — reconcile now
await handlePaymentSuccess(session);
} else if (session.status === 'expired') {
await db.query(
"UPDATE orders SET status = 'cancelled' WHERE id = $1",
[order.id]
);
}
}
}
Implementing Subscription Billing
Subscription payment processing adds meaningful complexity. The subscription has its own lifecycle, separate from individual invoice payments, and you must handle both.
async function createSubscription(userId, planId) {
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
let customerId = user.rows[0].stripe_customer_id;
if (!customerId) {
const customer = await stripe.customers.create({
email: user.rows[0].email,
metadata: { user_id: userId },
});
customerId = customer.id;
await db.query('UPDATE users SET stripe_customer_id = $1 WHERE id = $2', [customerId, userId]);
}
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: planId }],
trial_period_days: 14,
payment_behavior: 'default_incomplete',
expand: ['latest_invoice.payment_intent'],
metadata: { user_id: userId },
});
await db.query(
`INSERT INTO subscriptions
(user_id, gateway_subscription_id, gateway_customer_id, plan_id, status)
VALUES ($1, $2, $3, $4, $5)`,
[userId, subscription.id, customerId, planId, subscription.status]
);
return subscription;
}
Subscription Webhook Events You Must Handle
Every single one:
const SUBSCRIPTION_EVENTS = {
'invoice.paid': handleInvoicePaid,
'invoice.payment_failed': handleInvoicePaymentFailed,
'invoice.upcoming': handleUpcomingInvoice,
'customer.subscription.updated': handleSubscriptionUpdate,
'customer.subscription.deleted': handleSubscriptionDeleted,
'customer.subscription.trial_will_end': handleTrialEnding,
};
Production Warning: A billing system that only handles invoice.paid and customer.subscription.deleted will silently fail to deactivate accounts when payments fail and will not notify users before trials end. I have found this incomplete event handling in the majority of subscription systems I have reviewed.
Security Architecture: PCI DSS Compliance and Production Hardening
Using a hosted gateway checkout (Stripe Checkout, Razorpay Payment Page) qualifies your integration for SAQ A — the lowest PCI DSS compliance scope — because card data is collected and tokenized entirely on the gateway's infrastructure.
Security Controls Required
1. Webhook Signature Verification Covered in Step 5. Never skip it.
2. Environment Variables
STRIPE_SECRET_KEY=sk_live_... # Server-side only — never expose to frontend
STRIPE_PUBLISHABLE_KEY=pk_live_... # Safe for frontend
STRIPE_WEBHOOK_SECRET=whsec_... # Webhook HMAC secret
3. Idempotency Keys on Mutation Requests
// Prevents duplicate charges if your backend retries a timed-out request
const paymentIntent = await stripe.paymentIntents.create(
{ amount: order.amount, currency: order.currency },
{ idempotencyKey: `order_${order.id}_${order.created_at.getTime()}` }
);
4. Rate Limiting on Payment Endpoints
import rateLimit from 'express-rate-limit';
const paymentLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15-minute window
max: 10, // 10 requests per IP per window
message: { error: 'Too many payment requests, please try again later' },
});
app.post('/create-payment', paymentLimiter, authenticate, createPaymentHandler);
5. HTTPS Enforcement
Every payment endpoint must be served over TLS. Stripe refuses to deliver webhooks to HTTP endpoints in production. In 2026, there is no valid reason any production payment endpoint is not behind TLS.
Teams building secure financial applications can explore our case studies to see how we've implemented PCI-compliant payment systems for enterprise clients.
Testing Payment Systems Before Production Launch
Never launch a payment integration without covering all of these scenarios in test mode:
Critical Test Scenarios
- Successful one-time payment — full end-to-end including webhook receipt
- Payment failure — card declined, with failure reason displayed to user
- Duplicate webhook delivery — same event ID received twice, second is a no-op
- Session expiry — user abandons checkout, order moves to cancelled
- Subscription creation — includes trial period handling
- Subscription renewal —
invoice.paidreceived, access extended - Failed subscription renewal —
invoice.payment_failed, access suspended - Refund flow — API call, webhook confirmation, access revoked
- Orphaned order recovery — simulate missing webhook, recovery job reconciles
- Concurrent webhook delivery — same event delivered simultaneously
Stripe Test Card Numbers
| Card Number | Scenario |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 0002 | Card declined |
4000 0000 0000 9995 | Insufficient funds |
4000 0000 0000 0069 | Expired card |
4000 0025 0000 3155 | Requires 3D Secure authentication |
Pro Tip: Use the Stripe CLI to replay webhook events locally:
stripe trigger checkout.session.completed
What Developers Get Wrong: Production Failures I Have Audited
In every production payment system I have reviewed, the same patterns appear. These are not beginner mistakes — I have found them in systems built by experienced teams that simply had not operated a payment system at scale before.
Common Critical Mistakes
Trusting the frontend-submitted amount — the most exploitable vulnerability. Fix: fetch price from your database using the plan ID, ignore any amount in the request body.
Using the success URL as the payment confirmation signal — every system I have audited this way has at least one class of real incident: users who paid successfully but never got access because their browser closed before the redirect.
Not storing the order before calling the gateway — if the gateway API call times out after the session is created, you have an orphaned session and no way to associate the eventual webhook with a user.
Non-idempotent webhook handlers — I have personally observed the same
checkout.session.completedevent delivered three times within 60 seconds due to a Stripe infrastructure event. Without idempotency guards, this caused triple-provisioning in one system.Handling only happy-path subscription events — systems that respond to
invoice.paidbut notinvoice.payment_failedquietly keep users on paid tiers after card declines. At scale, this becomes significant revenue leakage.Storing payment secrets in version control — still occurs in 2026. Rotate any key that has been committed — assume it is compromised.
No reconciliation process — your database and gateway dashboard will occasionally diverge. A background reconciliation job comparing your
orderstable against gateway records for the past 24 hours will surface discrepancies before they become financial disputes.
For comprehensive payment system audits and implementation support, contact our team of payment architecture specialists.
Conclusion: Building Payment Systems That Survive Production
The difference between a payment integration that works in a demo and one that holds up in production comes down to a few precise decisions: creating orders before gateway interaction, verifying webhooks cryptographically, writing idempotent event handlers, and designing for the failure cases you will absolutely encounter.
A production-ready payment system is not significantly more code than a naive implementation. It is more deliberate code — with clear boundaries between what the frontend knows, what the backend controls, and what the gateway certifies.
A Professional Payment Integration Always Includes
- Backend-first order creation with server-validated pricing
- Secure gateway session generation
- Cryptographic webhook verification
- Idempotent database updates
- Subscription lifecycle event handling
- Refund workflow with webhook confirmation
- Orphaned order recovery
- Payment reconciliation as a background process
Build it right the first time. Payment bugs at scale are not just engineering problems — they are financial incidents, customer trust events, and in some cases regulatory exposure.
At AgileSoftLabs, we specialize in building production-grade payment systems that handle real-world complexity. Whether you need payment gateway integration, subscription billing architecture, or marketplace payout systems, our team has the experience to build it right. Visit our blog for more technical guides and best practices.
Frequently Asked Questions
1. What are idempotency keys in payment gateways?
2. How does webhook retry logic work in 2026 standards?
3. PCI-DSS 4.1 compliance checklist for payment APIs?
4. Razorpay production environment setup steps?
- Switch to live API keys (rzp_live_...)
- Verify webhook secret (RAZORPAY_WEBHOOK_SECRET)
- Enable test-mode failover
- Configure Singapore endpoint for India traffic
- Set webhook retry limits (max 3 attempts)
5. Common payment API error handling failures?
6. Secure payment gateway architecture 5 layers?
API Gateway → Auth Service → Payment Orchestrator →
Webhook Handler → Encrypted DB7. SCA 2.0 compliance requirements for 2026?
8. Stripe webhook timeout handling (Node.js)?
javascript
const retryDelay = Math.min(1000 * Math.pow(2, attempt), 10000);
setTimeout(() => {
if (!response.received) retryWebhook(payload, attempt + 1);
}, retryDelay);9. Production payment gateway database schema?
- transactions (idempotency_key, status, webhook_delivered)
- webhooks (event_type, retry_count, payload_hash)
- refunds (partial_amounts, reason_code)


.png)

.png)

%20(1).png)



