Payment Gateway Callback Handling in Magento — The Idempotency Recipe
Three real production failures we shipped fixes for in 2026 on Magento 2.4.4 — 2.4.9: a Stripe webhook retry that double-captured an order, a double-click on Place Order that ran submitQuote twice, and a webhook signature check that compared HMACs with == and leaked the secret. Each one has a small, boring fix — a UNIQUE key on (gateway, event_id), a SELECT FOR UPDATE on the quote row, and hash_equals. Here is the exact PHP, SQL, the observer wiring, and the Stripe CLI replay command to prove it works.
Payment gateway callback handling is the engineering boundary between "the customer paid" and "Magento marked the order paid" in Magento 2.4.4 — 2.4.9 that fails when webhooks retry, customers double-click, or PHP compares HMACs with the wrong operator. The fix is idempotency at three layers — the webhook event, the order placement, and the signature check — implemented with one table, one row lock, and one PHP function. Here are the three failures we shipped fixes for in production, with the exact code.
Three failure modes show up in every payment integration we audit.
Across the Magento 2 stores we worked with between January and May 2026, the same three race conditions account for every duplicate capture, every double order, and every timing-attack vulnerability we found. They are independent — fixing one does not protect against the other two — and they all happen in the gap between the customer's browser, the PSP, and Magento's quote-to-order pipeline.[1]
A payment integration is not done when the happy path works. It is done when the same event arriving twice produces the same outcome as it arriving once.
The fixes below are small. They are also the difference between a clean reconciliation report and a Friday-night refund spreadsheet.
Failure 1: Stripe webhook fires twice and captures the order twice
Stripe's webhook delivery guarantees are at-least-once, not exactly-once.[2] If your endpoint returns anything other than a 2xx within 20 seconds — including a slow database write, a sluggish OpenSearch index, or a Cloudflare 524 — Stripe retries with the same event.id. Up to three retries by default.
On a stock Magento 2 install, the StripeIntegration\Payments\Controller\Webhook action processes whatever it receives. If your charge.captured handler creates an invoice and then re-captures via $payment->capture(), the second delivery does it again. We have seen merchants refund $4,200 in duplicate captures over a single weekend because the indexer fell behind and Stripe retried every event.
The idempotency table
Add a small log table with a unique constraint on (gateway, event_id). The INSERT IGNORE pattern guarantees that only the first delivery proceeds, regardless of how many retries arrive.
-- app/code/Panth/PaymentIdempotency/etc/db_schema.xml compiles to this:
CREATE TABLE `panth_payment_webhook_log` (
`entity_id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`gateway` VARCHAR(32) NOT NULL COMMENT 'stripe | razorpay | adyen | braintree',
`event_id` VARCHAR(128) NOT NULL COMMENT 'PSP event identifier',
`event_type` VARCHAR(64) NOT NULL,
`order_increment_id` VARCHAR(50) NULL,
`payload_hash` CHAR(64) NOT NULL COMMENT 'sha256 of raw body',
`processed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`processing_result` VARCHAR(16) NOT NULL DEFAULT 'pending',
PRIMARY KEY (`entity_id`),
UNIQUE KEY `UNQ_GATEWAY_EVENT` (`gateway`, `event_id`),
KEY `IDX_ORDER` (`order_increment_id`),
KEY `IDX_PROCESSED_AT` (`processed_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;The guard in the controller
The controller does the cheap thing first — try to insert. If the unique key blocks the insert, the event is a duplicate and we 200-OK Stripe so it stops retrying, without running the capture again.
<?php
declare(strict_types=1);
namespace Panth\PaymentIdempotency\Controller\Webhook;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\App\ResourceConnection;
use Psr\Log\LoggerInterface;
use Panth\PaymentIdempotency\Service\StripeSignatureVerifier;
use Panth\PaymentIdempotency\Service\StripeEventProcessor;
class Stripe implements HttpPostActionInterface, CsrfAwareActionInterface
{
public function __construct(
private readonly RequestInterface $request,
private readonly JsonFactory $jsonFactory,
private readonly ResourceConnection $resource,
private readonly StripeSignatureVerifier $verifier,
private readonly StripeEventProcessor $processor,
private readonly LoggerInterface $logger
) {}
public function execute()
{
$body = (string)$this->request->getContent();
$sigHeader = (string)$this->request->getHeader('Stripe-Signature');
$result = $this->jsonFactory->create();
if (!$this->verifier->verify($body, $sigHeader)) {
$this->logger->warning('panth.idempotency.stripe.bad_signature');
return $result->setHttpResponseCode(401)->setData(['status' => 'invalid_signature']);
}
$event = json_decode($body, true, 8, JSON_THROW_ON_ERROR);
$eventId = (string)($event['id'] ?? '');
$eventType = (string)($event['type'] ?? '');
$conn = $this->resource->getConnection();
$table = $this->resource->getTableName('panth_payment_webhook_log');
// Atomic claim. INSERT IGNORE returns 0 affected rows on duplicate key.
$rows = $conn->insertIgnore($table, [
'gateway' => 'stripe',
'event_id' => $eventId,
'event_type' => $eventType,
'payload_hash' => hash('sha256', $body),
'processing_result' => 'pending'
]);
if ($rows === 0) {
// Duplicate delivery. Ack so Stripe stops retrying.
$this->logger->info('panth.idempotency.stripe.duplicate', ['event_id' => $eventId]);
return $result->setData(['status' => 'duplicate_ignored', 'event_id' => $eventId]);
}
try {
$orderIncrementId = $this->processor->process($event);
$conn->update($table,
['processing_result' => 'ok', 'order_increment_id' => $orderIncrementId],
['gateway = ?' => 'stripe', 'event_id = ?' => $eventId]
);
} catch (\Throwable $e) {
$conn->update($table,
['processing_result' => 'error'],
['gateway = ?' => 'stripe', 'event_id = ?' => $eventId]
);
$this->logger->error('panth.idempotency.stripe.process_failed', [
'event_id' => $eventId,
'exception' => $e->getMessage()
]);
return $result->setHttpResponseCode(500)->setData(['status' => 'process_error']);
}
return $result->setData(['status' => 'processed', 'event_id' => $eventId]);
}
public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
{
return null;
}
public function validateForCsrf(RequestInterface $request): ?bool
{
return true; // Stripe signs the body; bypass CSRF.
}
}Three things to notice. First, insertIgnore is the cheapest possible duplicate check — one round-trip, one index lookup, no SELECT-then-INSERT race. Second, the duplicate path returns 200 because Stripe stops retrying only on 2xx. Third, the processing_result column lets you audit later: a row with pending means the worker crashed mid-flight.
Failure 2: Customer double-clicks Place Order and submitQuote runs twice
The customer fills out checkout, hits Place Order, the spinner runs for 4 seconds because Magento is slow,[3] and they click again. Both requests reach \Magento\Quote\Model\QuoteManagement::submitQuote within ~50 ms of each other. Both pass the quote validation. Both create an order. The customer is charged twice.
The frontend mitigation — disabling the button on click — is correct but not sufficient. It does not protect against retried requests at the network layer, mobile back-button-then-resubmit, or attackers replaying the POST. The authoritative fix is server-side row locking on the quote mask.
The plugin on QuoteManagement
A before plugin on submitQuote opens a transaction and takes a SELECT ... FOR UPDATE on the quote row. The second concurrent call blocks until the first transaction commits — by then the first call has created the order, the quote is marked is_active = 0, and the second call sees the inactive quote and returns the existing order instead of placing a new one.
<?php
declare(strict_types=1);
namespace Panth\PaymentIdempotency\Plugin\Quote;
use Magento\Quote\Api\Data\CartInterface;
use Magento\Quote\Model\QuoteManagement;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Api\Data\OrderSearchResultInterface;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\Exception\AlreadyExistsException;
class SerializeSubmitQuote
{
public function __construct(
private readonly ResourceConnection $resource,
private readonly OrderRepositoryInterface $orderRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder
) {}
/**
* Serialize concurrent submitQuote calls on the same quote_id.
* The second caller will block on FOR UPDATE, then short-circuit if an order already exists.
*/
public function aroundSubmit(
QuoteManagement $subject,
callable $proceed,
CartInterface $quote,
$orderData = []
) {
$quoteId = (int)$quote->getId();
$conn = $this->resource->getConnection();
$quoteTable = $this->resource->getTableName('quote');
$conn->beginTransaction();
try {
// Lock the quote row. Blocks any sibling submitQuote on the same quote.
$locked = $conn->fetchRow(
$conn->select()
->from($quoteTable, ['entity_id', 'is_active', 'reserved_order_id'])
->where('entity_id = ?', $quoteId)
->forUpdate(true)
);
if (!$locked) {
$conn->rollBack();
throw new \RuntimeException('Quote not found for submit lock.');
}
// If the first caller already deactivated this quote, fetch its order and return.
if ((int)$locked['is_active'] === 0 && !empty($locked['reserved_order_id'])) {
$existing = $this->findOrderByIncrementId($locked['reserved_order_id']);
if ($existing) {
$conn->commit();
return $existing;
}
}
$order = $proceed($quote, $orderData);
$conn->commit();
return $order;
} catch (\Throwable $e) {
if ($conn->getTransactionLevel() > 0) {
$conn->rollBack();
}
throw $e;
}
}
private function findOrderByIncrementId(string $incrementId)
{
$criteria = $this->searchCriteriaBuilder
->addFilter('increment_id', $incrementId)
->setPageSize(1)
->create();
/** @var OrderSearchResultInterface $result */
$result = $this->orderRepository->getList($criteria);
$items = $result->getItems();
return $items ? reset($items) : null;
}
}The di.xml wiring:
<!-- app/code/Panth/PaymentIdempotency/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Quote\Model\QuoteManagement">
<plugin name="panth_serialize_submit_quote"
type="Panth\PaymentIdempotency\Plugin\Quote\SerializeSubmitQuote"
sortOrder="10"/>
</type>
</config>Why the quote table and not quote_id_mask
The GraphQL placeOrder mutation receives a cart_id that is a masked hash from quote_id_mask. You can lock either table — locking quote_id_mask serializes only GraphQL requests, locking quote serializes both GraphQL and REST. For mixed-stack stores (Hyvä storefront + Luma admin reorders + headless mobile app), lock quote.
A double-click is the cheap version of this attack. The expensive version is a malicious user who replays the same placeOrder POST 50 times with a stolen session cookie. The same lock blocks both.Failure 3: Webhook signature compared with == instead of hash_equals
Every PSP signs its webhook body with HMAC-SHA256. Stripe uses Stripe-Signature, Razorpay uses X-Razorpay-Signature, Adyen uses HMAC keys per merchant account. The receiving endpoint computes the same HMAC over the raw body and compares the result to the header.
If you write $expected == $received, PHP compares the strings byte by byte and returns false the moment it finds a mismatch. That micro-difference is measurable from the network. An attacker can recover the secret one byte at a time over thousands of probes. This is a textbook timing attack.[4]
hash_equals() performs constant-time comparison — it always reads every byte of both strings before returning. The attack is mathematically impossible. The function has existed since PHP 5.6.
The signature verifier
<?php
declare(strict_types=1);
namespace Panth\PaymentIdempotency\Service;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Encryption\EncryptorInterface;
use Magento\Store\Model\ScopeInterface;
use Psr\Log\LoggerInterface;
class StripeSignatureVerifier
{
private const TOLERANCE_SECONDS = 300; // 5 min window — matches Stripe SDK default.
public function __construct(
private readonly ScopeConfigInterface $scopeConfig,
private readonly EncryptorInterface $encryptor,
private readonly LoggerInterface $logger
) {}
public function verify(string $body, string $signatureHeader): bool
{
if ($signatureHeader === '' || $body === '') {
return false;
}
$secret = $this->encryptor->decrypt(
(string)$this->scopeConfig->getValue(
'payment/stripe_payments/webhook_secret',
ScopeInterface::SCOPE_STORE
)
);
if ($secret === '') {
$this->logger->warning('panth.idempotency.stripe.no_secret_configured');
return false;
}
// Header format: t=1716284701,v1=abcdef...
$parts = [];
foreach (explode(',', $signatureHeader) as $kv) {
[$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
$parts[$k][] = $v;
}
$timestamp = (int)($parts['t'][0] ?? 0);
$signatures = $parts['v1'] ?? [];
if ($timestamp === 0 || empty($signatures)) {
return false;
}
// Reject very old or future-dated signatures — replay protection.
$skew = abs(time() - $timestamp);
if ($skew > self::TOLERANCE_SECONDS) {
$this->logger->warning('panth.idempotency.stripe.signature_skew', ['skew' => $skew]);
return false;
}
$signedPayload = $timestamp . '.' . $body;
$expected = hash_hmac('sha256', $signedPayload, $secret);
// CONSTANT-TIME compare. Never use == here.
foreach ($signatures as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}
}The Razorpay version is shorter because the header is a single signature, not a structured tuple:
public function verifyRazorpay(string $body, string $signature, string $secret): bool
{
$expected = hash_hmac('sha256', $body, $secret);
return hash_equals($expected, $signature);
}Where merchants get this wrong
The most common mistake is not == directly. It is using a third-party SDK wrapper and assuming it does the right thing. The Stripe official PHP SDK (stripe/stripe-php) uses hash_equals correctly. Several community Magento payment modules implement their own verifier from copy-pasted blog code that uses ===. Audit the verifier in vendor/ on every project — grep -rE 'hash_hmac.*sha256' vendor/ | grep -v hash_equals is a 30-second check that finds them.
The observer that ties the order to the log entry
Once the webhook is idempotent, the placement is serialized, and the signature is constant-time, link the order to the webhook log entry so reconciliation is one JOIN away.
<?php
declare(strict_types=1);
namespace Panth\PaymentIdempotency\Observer;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Sales\Api\Data\OrderInterface;
class LinkOrderToWebhookLog implements ObserverInterface
{
public function __construct(
private readonly ResourceConnection $resource
) {}
public function execute(Observer $observer): void
{
/** @var OrderInterface $order */
$order = $observer->getEvent()->getOrder();
if (!$order || !$order->getIncrementId()) {
return;
}
$info = $order->getPayment() ? $order->getPayment()->getAdditionalInformation() : [];
$eventId = (string)($info['psp_event_id'] ?? '');
$gateway = (string)($info['psp_gateway'] ?? '');
if ($eventId === '' || $gateway === '') {
return;
}
$conn = $this->resource->getConnection();
$conn->update(
$this->resource->getTableName('panth_payment_webhook_log'),
['order_increment_id' => $order->getIncrementId()],
['gateway = ?' => $gateway, 'event_id = ?' => $eventId]
);
}
}And the wiring in etc/events.xml:
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="sales_order_place_after">
<observer name="panth_link_order_to_webhook_log"
instance="Panth\PaymentIdempotency\Observer\LinkOrderToWebhookLog"/>
</event>
</config>Failure → cause → fix → test
| Failure | Root cause | Fix | How to test |
|---|---|---|---|
| Duplicate webhook double-captures order | PSP retries on 5xx, slow ack, or network drop — same event.id arrives 2–4 times | UNIQUE(gateway, event_id) + INSERT IGNORE guard in webhook controller | stripe trigger charge.captured twice with the same idempotency key; Razorpay dashboard "Retry" button |
| Double-click on Place Order creates two orders | Two submitQuote calls race past the quote validation in < 50 ms | Plugin on QuoteManagement::submit opens transaction + SELECT FOR UPDATE on quote row | ab -n 2 -c 2 against placeOrder GraphQL with same masked cart id; assert exactly one order created |
| Timing attack recovers webhook secret | Signature compared with ==; mismatch byte revealed by response time | Replace == with hash_equals(); reject signatures older than 5 min | Send 10k requests with crafted signatures via burp; measure response-time variance — should be flat |
The testing matrix you actually run before going live
The fixes above only count if you can prove they work. Every payment integration we ship at kishansavaliya.com goes through this exact 7-check matrix before the merchant flips production traffic.
1. Stripe CLI webhook replay
stripe listen --forward-to https://store.example.com/panth_payment/webhook/stripe
# In a second terminal:
stripe trigger charge.captured
stripe trigger charge.captured # second time — should be ignored
# Check the log table:
mysql -e "SELECT event_id, processing_result, order_increment_id
FROM panth_payment_webhook_log
WHERE gateway='stripe' ORDER BY entity_id DESC LIMIT 5;"2. Razorpay manual retry
Razorpay Dashboard → Settings → Webhooks → click the recent event → "Resend Webhook". The second delivery has the same id. Confirm the log table shows it as duplicate_ignored.
3. Concurrent placeOrder via ab
ab -n 2 -c 2 -p place-order.json -T application/json \
-H "Cookie: PHPSESSID=$SESSION" \
https://store.example.com/graphql
mysql -e "SELECT increment_id, status, created_at
FROM sales_order
WHERE customer_email='qa@example.com'
ORDER BY entity_id DESC LIMIT 5;"
# Expect: exactly ONE row.4. Signature timing-attack probe
for i in $(seq 1 1000); do
/usr/bin/time -f "%e" curl -s -o /dev/null \
-H "Stripe-Signature: t=$(date +%s),v1=$(openssl rand -hex 32)" \
-d '{}' https://store.example.com/panth_payment/webhook/stripe
done | sort | uniq -c | head
# Distribution of timings should be near-flat. Spikes = timing leak.5. Replay window
Take a real signature, wait 6 minutes, replay. Expect 401. Confirms the 5-minute tolerance window rejects stale signatures — protects against captured-and-replayed deliveries.
6. Bad payload after good signature
Send a valid signature for body A, but POST body B. Expect 401. Confirms the verifier hashes the body, not just the timestamp.
7. Reconcile against the PSP dashboard
End of week, run: SELECT COUNT(*) FROM panth_payment_webhook_log WHERE gateway='stripe' GROUP BY processing_result. Cross-reference with Stripe's webhook delivery dashboard. Drift > 1% means an event class is silently failing.
What we did not cover
Out of scope on purpose: refund webhooks (same idempotency table works, different event types), 3DS-2 redirect resume handling (separate post coming), and the multi-store webhook routing problem (one endpoint, N store views). The three fixes above are the foundation everything else builds on.
FAQ
FAQ
Why use a separate panth_payment_webhook_log table instead of just checking sales_order?
Webhook events do not always have a 1:1 relationship to orders. Stripe sends customer.subscription.updated with no order context. Razorpay sends payment.authorized before the order exists. A dedicated log table records every delivery — including the ones that never resolve to an order — so reconciliation, debugging, and replay-attack forensics work even when no sales_order row was ever created.
Does SELECT FOR UPDATE slow down checkout under load?
The lock is held for the duration of submitQuote — typically 80–200 ms on a tuned store. It only blocks concurrent calls on the same quote row, which is the case we are trying to serialize. Different customers checking out simultaneously hold different row locks and do not contend. We measured no observable throughput loss on a store doing 12 orders per minute.
What if Stripe retries 24 hours later and the order is already shipped?
The INSERT IGNORE on (gateway, event_id) rejects the duplicate regardless of how much time has passed. We keep the log table around indefinitely (it is small — < 1 GB per million events). The retry is acknowledged with 200 and the order state is untouched.
Why not use Magento's built-in \Magento\Framework\Lock\LockManagerInterface instead of SELECT FOR UPDATE?
Magento's lock manager uses MySQL named locks (GET_LOCK) which work but have two problems on this code path. First, named locks are connection-scoped and Magento's connection pool can hand the second request a different connection, defeating the lock. Second, GET_LOCK does not participate in the transaction — if you crash between lock acquisition and order placement, the lock is released and the next request races. SELECT FOR UPDATE on the actual quote row is tied to the transaction lifecycle and is the safer primitive.
Does hash_equals really matter on a server behind Cloudflare?
Yes. Cloudflare adds jitter to response times but does not eliminate timing differences large enough to leak one byte at a time. The attack is slower behind a CDN — it might take 200,000 probes instead of 5,000 — but it is not blocked. Constant-time comparison is the only correct defense; CDN jitter is not.
How do I migrate this onto an existing store that already has duplicate orders in the database?
Step 1: ship the module, populate the log table going forward. Step 2: run a one-time SQL audit on sales_order grouped by customer_email and created_at within 60 seconds — flags historical doubles. Step 3: refund the duplicates and credit the customers. Step 4: backfill the log table from PSP API history if you want full audit trail. We ship a CLI command for step 4 on every engagement.
Will any of this interfere with Adobe Commerce's built-in payment retry observer?
No. The Adobe Commerce retry observer (sales_order_payment_retry_after) fires on the order-level retry, not the webhook level. The idempotency log sits earlier in the pipeline — before any order or payment object is touched. Confirmed compatible on Adobe Commerce 2.4.7 and 2.4.9.
References
- Production engagement traces from payment integration work, January — May 2026. Stripe, Razorpay, and Adyen integrations across 8 client stores on Magento 2.4.4 — 2.4.9.
- Stripe, Webhooks: best practices for handling duplicate events — stripe.com/docs/webhooks. At-least-once delivery, 20-second timeout, idempotency key recommendation.
- Magento Slow Checkout? The 3 Real Fixes That Move the Needle — why
submitQuotecan take 3–5 seconds and create the double-click window in the first place. - OWASP Foundation, Timing Attack — owasp.org/www-community/attacks/Timing_attack. Constant-time comparison guidance and reference exploit code.
Related reading
- Magento Slow Checkout? The 3 Real Fixes That Move the Needle
- Conditional checkout fields in Magento 2 — three rules from production
- Magento 2 development service
I am Kishan Savaliya, an Adobe-Certified Magento + Hyvä developer. I scope and ship the payment idempotency module — log table, plugin, signature verifier, observer, CLI replay tools, and the 7-check test matrix — on Magento 2.4.4 — 2.4.9. Fixed quote from $499 audit · $2,499 sprint · ~28h @ $25/hr. See Magento 2 development or hire me.