Payment integrations are one of those areas where WordPress development gets interesting — and genuinely painful. Over the years I’ve built or maintained payment flows for several plugins and client projects, and the three gateways I keep coming back to are Stripe, Square, and Razorpay. Each one has its own personality, its own quirks, and its own way of making your life difficult at 11pm when a production bug surfaces.
This isn’t a “getting started with payments” post. There are plenty of those. This covers the things that actually matter once you move past the happy path and start dealing with real users, real failures, and real money.
Choosing a Gateway: It’s Not Just About Fees
The first question most clients ask is about transaction fees. That’s understandable, but from a developer’s perspective, the more important questions are about API design quality, documentation depth, webhook reliability, and SDK maturity.
Stripe wins on developer experience, full stop. Clean API, documentation that actually covers edge cases, a test mode that works like production, and a PHP SDK that gets regular updates. Unless there’s a specific reason to go elsewhere, I default to Stripe.
Square makes more sense when your client already uses Square hardware in a physical location and wants unified reporting. The API has improved a lot in recent years, but the developer experience still has rough patches — particularly around the Catalog API and handling location-specific inventory. For pure online payment processing on a WordPress plugin, Square adds complexity without a proportional benefit unless that brick-and-mortar use case is present.
Razorpay is essentially mandatory if you’re serving the Indian market. It handles the maze of local payment methods — UPI, Netbanking, NEFT, various EMI options — in a way no international gateway does. The API is solid, the webhook system works well, and their test mode has improved significantly. The main challenge is currency: everything in Razorpay is in paisa, not rupees, which I’ll get to in a moment.
PCI Compliance: What You Actually Need to Know
When people hear “PCI compliance” they sometimes panic and think they need to go through a full audit. In most WordPress plugin scenarios, you don’t — but you do need to be thoughtful.
The key principle is simple: never let raw card data touch your server. With Stripe’s PaymentIntents and Payment Element, card data is tokenized entirely on the client side via Stripe.js. Your server never sees the card number, CVV, or expiry. That puts you in SAQ A territory for PCI purposes, which is a self-assessment questionnaire rather than a full audit.
Where people get into trouble is when they try to be “helpful” and pass card details through their own backend — even briefly, even just to log something. Don’t. It’s not worth the liability and it’s not necessary.
Square uses a similar approach with their Web Payments SDK. Razorpay’s checkout.js handles tokenization on their side. The pattern is consistent across all three: your plugin collects a token or payment method ID from the frontend, sends that to your server, and your server talks to the gateway API with that token.
The Stripe PaymentIntent Flow in PHP
The PaymentIntents API replaced Charges for most use cases and it’s worth understanding why. The old Charges API was a single-step process. PaymentIntents are designed for the more complex reality of 3D Secure, bank redirects, and other multi-step authentication flows that are now legally required in many markets (SCA in Europe, for instance).
Here’s the core server-side flow I use in WordPress plugins:
<?php
use StripeStripe;
use StripePaymentIntent;
use StripeExceptionApiErrorException;
function cp_create_payment_intent( $amount_in_cents, $currency, $metadata = [] ) {
Stripe::setApiKey( defined( 'STRIPE_SECRET_KEY' ) ? STRIPE_SECRET_KEY : get_option( 'cp_stripe_secret_key' ) );
try {
$intent = PaymentIntent::create( [
'amount' => absint( $amount_in_cents ),
'currency' => strtolower( $currency ),
'automatic_payment_methods' => [ 'enabled' => true ],
'metadata' => array_merge( [
'wp_site' => get_bloginfo( 'url' ),
'wp_user' => get_current_user_id(),
], $metadata ),
'idempotency_key' => wp_generate_uuid4(),
] );
return [
'client_secret' => $intent->client_secret,
'intent_id' => $intent->id,
];
} catch ( ApiErrorException $e ) {
cp_log_payment_error( 'stripe_create_intent', $e->getMessage(), $e->getStripeCode() );
return new WP_Error( 'stripe_error', $e->getMessage() );
}
}
A few things here. The automatic_payment_methods flag tells Stripe to handle which payment methods are available based on the customer’s location and your Stripe dashboard configuration — you’re not hardcoding “card” and nothing else. That matters when you start serving EU customers who might want SEPA Direct Debit or iDEAL.
I also always pass metadata with at minimum the site URL and user ID. When something goes wrong three months from now and a client emails saying “a customer says they were charged but didn’t get access,” that metadata is what makes debugging actually possible.
Webhook Verification: Do Not Skip This
Webhooks are how gateways tell your plugin that something happened — a payment succeeded, a subscription renewed, a dispute was opened. The temptation, especially early on, is to just accept the incoming payload and process it. That’s a significant security hole.
Stripe sends a Stripe-Signature header with every webhook. You must verify it before doing anything with the payload:
<?php
function cp_handle_stripe_webhook() {
$payload = @file_get_contents( 'php://input' );
$sig_header = isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? $_SERVER['HTTP_STRIPE_SIGNATURE'] : '';
$endpoint_secret = get_option( 'cp_stripe_webhook_secret' );
try {
$event = StripeWebhook::constructEvent( $payload, $sig_header, $endpoint_secret );
} catch ( UnexpectedValueException $e ) {
// Invalid payload
status_header( 400 );
exit;
} catch ( StripeExceptionSignatureVerificationException $e ) {
// Invalid signature
status_header( 400 );
exit;
}
// Now it's safe to process
switch ( $event->type ) {
case 'payment_intent.succeeded':
cp_process_successful_payment( $event->data->object );
break;
case 'invoice.payment_failed':
cp_handle_failed_subscription_payment( $event->data->object );
break;
case 'customer.subscription.deleted':
cp_handle_subscription_cancellation( $event->data->object );
break;
}
status_header( 200 );
exit;
}
add_action( 'wp_ajax_nopriv_cp_stripe_webhook', 'cp_handle_stripe_webhook' );
One subtle thing: notice I’m reading from php://input directly rather than using $_POST. This is required. WordPress parses the incoming request body and $_POST won’t have the raw payload that Stripe’s signature verification needs. I’ve seen this catch developers who were testing locally with fine results, then deploying and having every webhook fail silently in production.
Idempotency: Your Safety Net
Network failures happen. A webhook might be delivered more than once. A user might double-click the submit button. Without idempotency handling, you can end up processing the same payment event multiple times — provisioning access twice, sending duplicate emails, or creating duplicate records.
For API calls, use Stripe’s built-in idempotency key support. For webhooks, the simplest approach is to track processed event IDs in a custom table or a transient with a long TTL:
<?php
function cp_is_webhook_processed( $event_id ) {
return (bool) get_transient( 'cp_webhook_' . sanitize_key( $event_id ) );
}
function cp_mark_webhook_processed( $event_id ) {
// Keep for 7 days — Stripe retries for up to 3 days
set_transient( 'cp_webhook_' . sanitize_key( $event_id ), 1, DAY_IN_SECONDS * 7 );
}
Check cp_is_webhook_processed() before doing any processing, then call cp_mark_webhook_processed() immediately after you’ve confirmed the action is complete. Not before — otherwise a crash mid-processing will mark it as done when it wasn’t.
Currency Handling: The Paisa Problem
Stripe and Square work in the smallest currency unit — cents for USD, pence for GBP. Most developers figure this out early. Where things get messy is with Razorpay, which operates in paisa (1 rupee = 100 paisa).
The dangerous scenario I’ve run into: a plugin stores amounts as decimals in the database (say, 499.00 for ₹499). When you pass that to Razorpay without converting, you’re charging ₹4.99. That’s a real bug that has cost real money in real production environments.
<?php
function cp_to_gateway_amount( $amount, $currency ) {
// Zero-decimal currencies (JPY, KRW, etc.) should NOT be multiplied
$zero_decimal = [ 'bif', 'clp', 'gnf', 'jpy', 'kmf', 'krw', 'mga', 'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf' ];
if ( in_array( strtolower( $currency ), $zero_decimal, true ) ) {
return absint( $amount );
}
return absint( round( $amount * 100 ) );
}
Keep this conversion logic in a single place. The number of bugs I’ve tracked down to duplicate, inconsistent conversion code scattered across a codebase is embarrassing in hindsight.
Subscription Billing Failures
Subscription billing failures are where payment integrations go from “working” to “actually production-ready.” A payment fails. What happens next?
Stripe’s smart retries handle some of this automatically. You configure the retry schedule in the Stripe dashboard and receive invoice.payment_failed webhooks on each attempt. But your plugin needs to decide what to do with that information — does the user lose access immediately? After the first failure? After the final retry?
My general recommendation: send a dunning email on the first failure, restrict access (but don’t delete data) on the second failure, and if the final retry fails, move the subscription to a grace period state rather than hard cancelling. Give users a week to update their payment method. Most churn from failed payments is recoverable if you handle it gracefully.
Razorpay subscriptions work differently — they use a separate Subscriptions API and the webhook event structure doesn’t map 1:1 to Stripe’s. If you’re building a plugin that needs to support both, don’t try to share the subscription handling logic. Write separate handlers and let them converge at the business logic layer (provisioning access, sending emails, updating the user’s status in your database).
Testing Strategies That Actually Work
All three gateways have test modes, but using them well requires some discipline.
For Stripe, the test card 4000 0025 6000 0051 triggers a 3D Secure flow, which is worth testing explicitly — a lot of plugins only test with 4242 4242 4242 4242 and then have 3DS failures in production from European users. Stripe also has test cards that simulate specific declines: 4000 0000 0000 9995 for insufficient funds, 4000 0000 0000 0002 for a generic decline.
For webhooks, use the Stripe CLI locally. Running stripe listen --forward-to localhost/wp-admin/admin-ajax.php?action=cp_stripe_webhook is far more reliable than trying to expose your local environment to the internet with ngrok, and it lets you replay events with stripe events resend when you’re iterating on your handler logic.
One thing I always do before shipping: write a test that fires a payment_intent.succeeded event with an event ID you’ve already marked as processed, and confirm your idempotency check correctly short-circuits. It’s a five-minute test that has saved hours of debugging.
A Few Hard-Won Lessons
- Log everything, but carefully. Log the event type, the object ID, and your processing result. Do not log the full payload — it can contain card details in some edge cases, and it’ll burn through your database storage fast with active subscriptions.
- Store the gateway’s customer ID. Whether it’s
cus_xxxxxfrom Stripe or the equivalent from Razorpay, store it against the WordPress user from day one. Retrofitting customer ID storage into an existing plugin is significantly more painful than building it in upfront. - Test your webhook endpoint before launch, not after. Use the gateway’s dashboard to send a test event and confirm you get a 200 response. It takes two minutes and has saved me from embarrassing post-launch scrambles more than once.
- Handle currency display separately from storage. Store amounts in the smallest unit (cents/paisa) in the database, and only convert to display format at render time. Mixing these up is a classic source of subtle bugs.
Payment integrations follow an 80/20 rule where the last 20% — webhook edge cases, failed subscription handling, currency conversion bugs — takes 80% of the effort. I still find new edge cases on projects I’ve been running for years. The gateways handle PCI compliance so you don’t have to, but everything around the transaction — the retry logic, the dunning emails, the idempotency checks — that’s on you.