Skip to content
System nominalUTC --:--:--Articles 080
MODULE 05 Lesson 14 / 18 40 MIN

Payments and shipping

Payment Webhooks and Idempotency

A webhook is the only confirmation of payment that the customer cannot fake or lose. It also arrives twice, out of order, and with a body you must read raw.

The customer pays, the browser lands on the order-received page, and the order completes. Then the provider's webhook fires, the extension marks the order paid again, and the customer receives a second confirmation email. Or the reverse: the browser tab closes on the redirect, the webhook never arrives because the endpoint URL was mistyped, and an order that was actually paid sits at pending until somebody looks.

A webhook is the provider telling your store what it did, without the customer in the middle. That makes it the only trustworthy confirmation of payment, and it also makes it the only part of the flow that runs on infrastructure you do not control. It will be retried, delivered twice, delivered late, and delivered in an order that does not match the order it describes. The handler has to be built for that from the first line, because idempotency is not something you add after the duplicate emails start.

The signature is an HMAC over the bytes, and $_POST has none of them

The first version everyone writes reads $_POST, finds it empty for a provider that posts JSON, and concludes that the endpoint is not being reached. PHP populates $_POST only for application/x-www-form-urlencoded and multipart/form-data bodies. A JSON webhook leaves $_POST empty and puts the body in the raw input stream.

Raw input is also the only version of the body you can verify. A signature covers exact bytes, and any decode-and-re-encode step changes them: key order, unicode escapes, float formatting, trailing newlines. If you verify wp_json_encode( $_POST ) you are verifying a document you assembled, not the one the provider signed, and the check fails on a payload that is perfectly valid.

<?php
// Wrong: the body is JSON, so $_POST is empty and the re-encoded bytes are not the signed ones.
$raw = wp_json_encode( $_POST );

// Right: the exact bytes that arrived, before any decoding.
$raw = (string) file_get_contents( 'php://input' );

In a REST route the same value is already available on the request object as $request->get_body(). That is the version to use when you register the endpoint with register_rest_route(), because WordPress has read the stream once and cached it, while a second read of php://input returns an empty string on some SAPIs.

The comparison itself has one rule: hash_equals(), never == and never ===. A plain string comparison stops at the first differing byte, and the time it takes to stop leaks how many leading bytes of the signature were correct. That is enough to reconstruct a signature byte by byte, one request at a time. hash_equals() compares the whole string in constant time.

<?php
$sent  = (string) $request->get_header( 'x-sifero-signature' );
$stamp = (int) $request->get_header( 'x-sifero-timestamp' );
$raw   = (string) $request->get_body();

// The timestamp is inside the signed string, so a captured body cannot be re-stamped.
$expected = hash_hmac( 'sha256', $stamp . '.' . $raw, $gateway->webhook_secret() );

if ( '' === $sent || ! hash_equals( $expected, $sent ) ) {
	return new WP_REST_Response( array( 'error' => 'bad_signature' ), 401 );
}

if ( abs( time() - $stamp ) > 300 ) {
	return new WP_REST_Response( array( 'error' => 'stale_timestamp' ), 401 );
}

Signing the timestamp together with the body is what makes the replay window mean anything. If the timestamp travels outside the signature, an attacker who captured one valid delivery can resend it for as long as the endpoint lives, and the window check reads a value they chose. Signing both, and rejecting anything older than five minutes, turns a captured request into a five-minute window instead of a permanent key.

Test and live secrets are different secrets

The endpoint in the previous lesson derives its signing secret from the same test_mode checkbox as the API URL. A store that switches to live mode keeps receiving sandbox webhooks for a while, and those must fail the signature check rather than confirm real orders. Deriving both from one field is what guarantees the mismatch.

The event id is the key, and a unique index is the lock

Every provider sends an identifier for the event, distinct from the charge id and from the order id. That identifier is the only value you can use to answer "have I already handled this", because a retry arrives with the same event id and different everything else: a new delivery attempt number, a new timestamp, sometimes a new signature.

Idempotency is often attempted with order meta: read a flag, check it, then handle the event and set the flag. That fails under concurrency. Two deliveries can arrive within the same second (providers do this when a delivery times out and is retried immediately), both read an unset flag, both proceed, and the store completes the same order twice and sends two emails. The window is small and it is exactly the window that matters.

The fix is to let the database enforce it. Create a table with a unique key on the event id, insert the event inside a transaction, and let a duplicate insert be the branch:

CREATE TABLE wp_sifero_gateway_events (
	id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
	event_id   VARCHAR(191) NOT NULL,
	event_type VARCHAR(64)  NOT NULL,
	order_id   BIGINT UNSIGNED NOT NULL DEFAULT 0,
	payload    LONGTEXT NOT NULL,
	created_at DATETIME NOT NULL,
	PRIMARY KEY  (id),
	UNIQUE KEY event_id (event_id)
);

The insert is the guard. There is no read-then-write gap, because the unique index decides the race for you: the second insert fails, and the failure is the answer. The transaction is what keeps the event row and the order update from disagreeing, so a crash between the two leaves either both or neither, and the retry behaves predictably on both sides.

<?php
$wpdb->query( 'START TRANSACTION' );

$inserted = $wpdb->insert(
	$table,
	array(
		'event_id'   => (string) $event['id'],
		'event_type' => (string) $event['type'],
		'order_id'   => $order ? $order->get_id() : 0,
		'payload'    => $raw,
		'created_at' => current_time( 'mysql', true ),
	)
);

if ( ! $inserted ) {
	$wpdb->query( 'ROLLBACK' );
	return array( 'status' => 'duplicate', 'event_id' => $event['id'] );
}

$wpdb->query( 'COMMIT' );

Return 200 for the duplicate rather than an error. A provider that receives an error will retry, and retrying is what created the duplicate in the first place. "I have seen this event" is a successful outcome from the provider's point of view.

Out of order means the newest event does not always win

Providers retry with backoff, so a failure event from 20 minutes ago can land after the success event from 19 minutes ago. Timestamps on the events tell you the order they happened in, which is not the order they arrive in. Three rules cover most of it.

  • Never let a failure event rewrite a paid order. If the order has a paid date, record the note and stop. Someone will otherwise chase a customer whose order flipped to failed after their card was charged.
  • Match the amount, not just the order. A success event for 5.00 against a 25.00 order is not confirmation of that order. Hold it and let a human look instead of marking it paid.
  • Keep the strongest state you have seen. Log the weaker event, apply nothing. Storing it matters, because the stored payload is how you reconstruct what the provider sent when a merchant disputes the outcome.

Amount checking also catches the failure that is otherwise invisible: a partial capture, a currency mismatch, or a provider retry for a different order that arrived with the wrong order id in the payload. A handler that trusts the order id alone will happily mark order 214 paid because a webhook for order 213 had a stale custom field.

Why a second payment_complete() usually does nothing, and when it does not

The duplicate email is the symptom everyone reports, so it is worth knowing exactly what WC_Order::payment_complete() does on the second call. It reads OrderStatus::PAYMENT_COMPLETE_STATUSES, which in WooCommerce 11.2 is on-hold, pending, failed and cancelled, and only does work when the order is currently in one of them. After the first call the order is processing or completed, so the second call falls through, fires the status-specific action, and stops.

first  payment_complete( 'ch_a' )   -> status pending -> completed, customer email sent
second payment_complete( 'ch_a' )   -> status already completed, no status change, no email

I ran that pair against a real order and the email capture showed one customer message and an unchanged transaction id. That guard is convenient and it is not a substitute for idempotency, because it only holds while the order stays in a paid status. Move the order back to pending (a refund reversal, a manual correction, an admin who mis-clicked) and the replayed webhook becomes live again: the paid date is already set, so the customer gets a second "completed order" email for a payment they made once. I reproduced both: the pair with no status change sent nothing, and the same pair around an update_status( 'pending' ) sent a second email.

There is a second path to the duplicate that has nothing to do with WooCommerce. An extension that treats a webhook as "the payment is confirmed, so let me also set the order complete" adds its own transition on top of the one payment_complete() already made. WooCommerce fires the customer email on the transition into the paid status, so the extension's extra status change is a second transition and a second email, from one charge. If you need an order in completed rather than processing, change the status through the woocommerce_payment_complete_order_status filter instead of moving it afterwards.

Lab 50 minutes

Build the webhook endpoint and then try to break it.

  1. Register a REST route for the provider's webhook with register_rest_route().
  2. Verify an HMAC-SHA256 signature over timestamp . '.' . raw body with hash_equals(), and reject anything older than 300 seconds.
  3. Create the events table with a unique key on the event id, inserting it with dbDelta() so a second activation does not duplicate it.
  4. Handle payment.succeeded and payment.failed. Insert the event in a transaction before touching the order. On payment.succeeded, compare the amount with the order total, set the transaction id, then call payment_complete().
  5. Deliver the same event twice and print what the handler returned both times, plus the number of "Payment via" notes on the order.
  6. Deliver payment.failed after the success and print the order status. Move the order back to pending, replay the success event with a new event id, and print the status again.
  7. Send the first request with a signature computed from one byte of the body changed, and print the HTTP status.

Then answer in one sentence each: why the event id and not the charge id is the idempotency key, and why the duplicate delivery returns 200.

Show the solution and the reasoning

The handler below is the one I ran. The order of the checks is the part to copy: signature, then replay window, then the idempotency insert, then the business logic. Each check that fails returns early, so an unsigned request never reaches the database and a duplicate never reaches the order.

<?php
function sifero_handle_webhook( WP_REST_Request $request ) {
	$raw      = (string) $request->get_body();
	$gateways = WC()->payment_gateways()->payment_gateways();
	$gateway  = $gateways['sifero_stub'] ?? null;

	if ( ! $gateway ) {
		return new WP_REST_Response( array( 'error' => 'gateway_disabled' ), 503 );
	}

	$sent  = (string) $request->get_header( 'x-sifero-signature' );
	$stamp = (int) $request->get_header( 'x-sifero-timestamp' );
	$want  = hash_hmac( 'sha256', $stamp . '.' . $raw, $gateway->webhook_secret() );

	if ( '' === $sent || ! hash_equals( $want, $sent ) ) {
		return new WP_REST_Response( array( 'error' => 'bad_signature' ), 401 );
	}

	if ( abs( time() - $stamp ) > 300 ) {
		return new WP_REST_Response( array( 'error' => 'stale_timestamp' ), 401 );
	}

	$event = json_decode( $raw, true );

	if ( ! is_array( $event ) || empty( $event['id'] ) || empty( $event['type'] ) ) {
		return new WP_REST_Response( array( 'status' => 'ignored' ), 200 );
	}

	global $wpdb;
	$order = isset( $event['data']['order_id'] ) ? wc_get_order( (int) $event['data']['order_id'] ) : null;

	$wpdb->query( 'START TRANSACTION' );
	$inserted = $wpdb->insert(
		$wpdb->prefix . 'sifero_gateway_events',
		array(
			'event_id'   => (string) $event['id'],
			'event_type' => (string) $event['type'],
			'order_id'   => $order ? $order->get_id() : 0,
			'payload'    => $raw,
			'created_at' => current_time( 'mysql', true ),
		)
	);

	if ( ! $inserted ) {
		$wpdb->query( 'ROLLBACK' );
		return new WP_REST_Response( array( 'status' => 'duplicate' ), 200 );
	}
	$wpdb->query( 'COMMIT' );

	if ( ! $order ) {
		return new WP_REST_Response( array( 'status' => 'stored' ), 200 );
	}

	if ( 'payment.succeeded' === $event['type'] ) {
		$amount = (float) ( $event['data']['amount'] ?? 0 );

		if ( abs( $amount - (float) $order->get_total() ) > 0.01 ) {
			$order->update_status( 'on-hold', 'Webhook amount does not match the order total.' );
			return new WP_REST_Response( array( 'status' => 'held' ), 200 );
		}

		if ( (float) $order->get_meta( '_sifero_amount_captured' ) > 0 ) {
			return new WP_REST_Response( array( 'status' => 'already_captured' ), 200 );
		}

		$order->update_meta_data( '_sifero_amount_captured', $amount );
		$order->set_transaction_id( (string) $event['data']['charge_id'] );
		$order->save();
		$order->payment_complete();

		return new WP_REST_Response( array( 'status' => 'captured' ), 200 );
	}

	// A failure that arrives after a capture must not rewrite the order.
	if ( $order->get_date_paid() ) {
		return new WP_REST_Response( array( 'status' => 'ignored_after_capture' ), 200 );
	}

	$order->update_status( 'failed', 'Payment failed at the gateway.' );
	return new WP_REST_Response( array( 'status' => 'failed' ), 200 );
}

The replay test is the part that proves the design rather than the intent. My run looked like this.

1. tampered signature                 -> 401 {"error":"bad_signature"}
2. wrong secret                       -> 401 {"error":"bad_signature"}
3. timestamp an hour old              -> 401 {"error":"stale_timestamp"}
4. first valid delivery               -> 200 {"status":"captured"}
     order #25 status completed txn ch_9f8e
5. the same event again               -> 200 {"status":"duplicate"}
     payment notes on the order: 1
6. same charge, new event id,
   order moved back to pending        -> 200 {"status":"already_captured"}
     order status pending, captured meta 25
7. payment.failed after the capture   -> 200 {"status":"ignored_after_capture"}
8. amount 5.00 against a 25.00 order  -> 200 {"status":"held"}, order on hold

Line 5 is the whole point: one "Payment via" note after two deliveries. Line 6 is the case the payment_complete() status guard does not cover, and it is why the amount is stored on the order rather than inferred from the status. Line 7 is the out-of-order failure, and it changed nothing except the stored event row.

The reasoning behind the key choice is that a charge id identifies money and an event id identifies a message. Two events can legitimately describe the same charge (an authorization and a capture), and one event can arrive many times. Uniqueness belongs on the message. And the duplicate returns 200 because the provider's retry policy is the source of the duplication; telling it that the delivery failed only guarantees a third copy.

What this does not solve: a provider that resends the same logical event under a new id, which happens when their own retry layer regenerates ids. That case needs the charge id stored on the order as the second guard, which is what the already_captured branch is for. And nothing here reconciles a payment that never got a webhook at all, so a daily job that asks the provider about orders still pending after an hour is worth its weight.

Official reference

The WordPress developer reference for what this lesson uses.

Progress

Mark this lesson done and it stays marked in this browser.