Payment gateway integration¶
payment-gateway-integration
Integrate a payment provider with idempotent charges, signature-checked webhooks, and an explicit map from provider states to order states. Use when adding or changing checkout payment, capture, refund, or payment notification handling.
Version 0.3.2 · MIT · Source · Rule
Install this skill
Copy the skill and its rule into your project:
The skill goes to .agents/skills/ and the rule to .agents/rules/. See Command line for other directories.
Apply this skill to any provider: card acquirer, wallet, bank redirect, or buy-now-pay-later. The WooCommerce class layout is in the WooCommerce gateway notes. Read it when the store runs WooCommerce. The webhook inbox, signature check, and retries are in the order and webhook skill.
Procedure¶
- Keep card entry on the provider. Use hosted fields, a redirect, or a native wallet. Do not post the PAN or CVV to the merchant server and do not store them. Taking card data on the merchant server changes the store's PCI scope.
- Create the shop order before redirecting or confirming, with a status that means unpaid (
pendingor the platform's equivalent). - Send the charge with an idempotency key tied to that order and attempt. Reuse the key if the HTTP call times out. Generate a new key only when the shopper starts a new attempt.
- Treat the browser return URL as a hint. Mark the order paid only from a signed server notification or from a server-side retrieve of the provider payment.
- Compare amount and currency in minor units on the server. Reject a notification whose amount or currency does not match the order.
- Record the provider payment id once. A second notification for the same payment id must not capture, email, or decrement stock again.
- Map provider states through one table. Do not sprinkle status writes through the client and the webhook.
State map¶
Use one function from provider event to shop transition.
| Provider observation | Shop effect |
|---|---|
| Authorized, not captured | Leave the order unpaid or set the platform's on-hold equivalent. Store the authorization id. |
| Captured or paid | Mark paid through the platform's payment-complete path. Store the capture id. |
| Pending asynchronous payment | On hold. Do not empty the cart until the policy says the order is accepted. |
| Declined or failed | Failed. Keep the cart so the shopper can retry. |
| Canceled by the shopper | Cancel only if the order is still unpaid. |
| Refunded, full or partial | Create a refund record for the amount. Do not only change the status string. |
| Chargeback or dispute opened | Record a dispute flag. Do not automatically refund unless the shop's policy says so. |
Unknown states are logged and left unchanged. Do not default an unknown state to paid.
Idempotency¶
- The provider idempotency key is a stable string for one attempt, such as
order-{id}-attempt-{n}. - Persist
attempt -> provider payment idbefore you depend on the response, or immediately after the first success, inside a unique constraint when the database allows it. - The webhook uniqueness key is the provider event id when that id is stable across retries. A delivery attempt id that changes on every POST is not enough.
- If the notification arrives before your create call returns, the webhook must be able to find the order by the idempotency key or by metadata you sent to the provider.
Amounts¶
Convert the shop total to integer minor units with the currency's exponent (100 for a two-decimal currency, 1 for a zero-decimal currency). Read the exponent from the store or from the provider's currency table. Do not compare PHP floats.
Reject the notification when the paid minor units differ from the order total. A mismatch is an exception for a person, not an automatic partial capture, unless the product explicitly sells partial payments.
Browser return¶
The return URL may include a payment id. Use it only to look up the provider payment from your server. Ignore status=success query parameters. A shopper, or a third party, can send those.
Show a pending confirmation when the webhook has not arrived yet. Poll your own order record, or retrieve the payment once from the provider. Do not poll the provider in a tight loop from the browser.
Refunds¶
- Refund from the shop admin through the provider API. Do not ask staff to refund only in the provider dashboard unless the shop has a reconciliation job that pulls those refunds back.
- Send an idempotency key per refund attempt.
- Store the provider refund id on the shop refund record.
- A partial refund leaves the order paid for the remainder. A full refund uses the platform's refunded state after the provider confirms it.
Secrets and logs¶
- Webhook secrets and API keys stay in the server environment.
- Log the order id, provider payment id, event id, and your decision. Do not log the PAN, CVV, full card number, or the webhook secret.
- Verify signatures on the raw body of the request before parsing. The order and webhook skill includes a WooCommerce HMAC checker. For other providers, follow that provider's documented MAC and compare with a constant-time compare.
Gotchas¶
- Emptying the cart before the provider confirms payment loses the shopper's items when they abandon the redirect.
- Marking paid inside the browser return, and again in the webhook, sends two receipts and can reduce stock twice. The paid transition has to be idempotent.
- Trusting the amount in the client payload lets a modified request pay a different total than the order.
- A
200to the provider means you stored the event, not that you have already fulfilled the order. Fulfillment can run after the response if the event is durable. A4xxfor a signature failure is appropriate. A5xxasks the provider to retry, so the handler must tolerate a duplicate. - Test the failed, canceled, and duplicate paths. A sandbox success charge is not coverage.
Verification¶
- Two identical charge requests with the same idempotency key create one provider payment.
- A replayed signed webhook does not send a second receipt or reduce stock again.
- A signed webhook with the wrong amount does not mark the order paid.
- A forged browser return without a matching provider payment does not mark the order paid.
- A declined payment returns the shopper to checkout with the cart intact and the order not paid.
Evaluation¶
Worked examples and the checks they must satisfy are in evals/tasks.json.
References¶
WooCommerce payment gateway¶
Extend WC_Payment_Gateway and register it on woocommerce_payment_gateways.
class Example_Gateway extends WC_Payment_Gateway {
public function __construct() {
$this->id = 'example';
$this->method_title = __( 'Example', 'example-gateway' );
$this->method_description = __( 'Pay with Example.', 'example-gateway' );
$this->has_fields = false;
$this->supports = array( 'products' );
$this->init_form_fields();
$this->init_settings();
add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, array( $this, 'process_admin_options' ) );
}
public function process_payment( $order_id ) {
$order = wc_get_order( $order_id );
if ( ! $order ) {
wc_add_notice( __( 'Order not found.', 'example-gateway' ), 'error' );
return array( 'result' => 'failure' );
}
// Create or reuse the provider payment with an idempotency key.
// Do not mark the order paid here unless the provider response is a confirmed capture.
// A hosted payment page uses the provider URL. An offline method uses get_return_url().
$redirect_url = $this->get_return_url( $order );
return array(
'result' => 'success',
'redirect' => $redirect_url,
);
}
public function process_refund( $order_id, $amount = null, $reason = '' ) {
// Add 'refunds' to $supports only after this calls the provider and returns true on acceptance.
// On decline: return new WP_Error( 'example_refund_failed', __( 'The provider declined the refund.', 'example-gateway' ) );
return new WP_Error( 'example_refund_failed', __( 'Refunds are not implemented yet.', 'example-gateway' ) );
}
}
Paid, held, and failed¶
- Confirmed capture:
$order->payment_complete( $transaction_id ). That stores the transaction id, choosesprocessingorcompletedthroughwoocommerce_payment_complete_order_status, and fireswoocommerce_payment_complete. - Awaiting a bank transfer or webhook:
$order->update_status( 'on-hold', $note ). Do not callpayment_complete()for money you do not have. - Decline:
$order->update_status( 'failed', $note )andwc_add_notice(). - Do not call
update_status( 'processing' )for a successful capture. That skips the payment-complete path.
payment_complete() is not a substitute for an idempotency check. Guard it with the stored transaction id or $order->is_paid() so a replay does not treat the order as newly paid. is_paid() is true for statuses in woocommerce_order_is_paid_statuses, which defaults to processing and completed.
Empty the cart with WC()->cart->empty_cart() only after the shop accepts the attempt (redirect created, or payment confirmed), matching the policy in SKILL.md. Use $this->get_return_url( $order ) when you return the shopper to the site. Do not call wp_redirect() inside process_payment().
Blocks checkout¶
A classic gateway is not automatically offered in the Checkout block. Register a payment method integration that extends Automattic\WooCommerce\Blocks\Payments\Integrations\AbstractPaymentMethodType on woocommerce_blocks_payment_method_type_registration, and declare cart_checkout_blocks compatibility from the main plugin file. Until that exists, say so in the plugin and test classic checkout separately.
Webhooks¶
WooCommerce API callbacks under /?wc-api=example are not cookie-authenticated. Verify the provider signature there. The same rule applies to a custom REST route. Nonces do not apply.
Store the transaction id with payment_complete(). Do not call add_post_meta( $order_id, '_transaction_id', ... ). That misses HPOS.
Refunds¶
process_refund() must talk to the provider and return true only after the provider accepts the refund. Returning true without a provider call marks the WooCommerce refund as done while the money has not moved. Omit refunds from $supports until the method is real.
Evaluation¶
Fixtures from evals/tasks.json. See evaluation fixtures for how the checks work.
confirmed-capture¶
Mark a WooCommerce order paid only after the provider confirms the capture, and store the transaction id.
Must include: payment_complete, $transaction_id
Must not include: add_post_meta
Worked example
Confirm capture through the payment-complete path.
Do not treat the browser return query as proof that the capture succeeded.
replayed-webhook¶
Apply a signed payment webhook once when the provider retries the same captured payment.
Must include: idempotency, raw body
Must not include: status=success
Worked example
Verify the signature over the raw body, then ignore a duplicate.
Store the provider payment id. If that id is already on the order, return 200 without a second capture. Use the same idempotency key when the original charge request times out.