A merchant installs a gateway plugin, takes one test payment, goes live, and a few weeks later notices some orders sitting on "Pending payment". The plugin usually isn't the problem. What gets missed is the case where the customer pays but their browser never makes it back to the shop.
This is how I'd add Pay by Bank to a UK WooCommerce store. Setup takes about ten minutes. The webhook section is where the time actually goes.
Is it right for your store?
Pay by Bank sends the customer to their banking app to approve, then back to you. Compared with Apple Pay that's an extra hop, and on a £12 impulse buy it can cost you a sale. Fena's write-up on this suggests the fee difference starts to matter above roughly £100 and more so above £250 (source). That fits what we see: bigger baskets, trade buyers, categories that suffer chargebacks, and mostly UK customers.
I wouldn't replace cards. Offer both at checkout and let people choose. Bank payments cost about half of what cards do, and there's no chargeback route because the customer approved the payment inside their own bank's app.
Setup
You need an Atoa Business account that has finished onboarding and KYB.
- In the Atoa Business app go to Settings → API Access and generate a Sandbox key. Add your shop's homepage as the redirect URL.
- In WordPress, install the plugin from Plugins → Add New (search "Atoa Pay", slug
atoa-pay). It needs WordPress 6.0+ and PHP 8.0+. - Go to WooCommerce → Settings → Payments, click Manage next to "Atoa – Pay by Bank App", paste the key, choose your Store Location, turn on Sandbox Mode, enable the method and save.
Both block and classic checkout work. Card, Apple Pay and Google Pay can be switched on separately under the same account.
Testing
At the bank picker, choose Atoa Test Bank. You then get a screen where you pick the result yourself:
-
COMPLETED: payment succeeded, the order should move to processing. -
FAILED: the customer goes back to checkout to try again. -
PENDING: the bank hasn't confirmed yet.
Run all three. PENDING is the closest thing to a customer closing the tab halfway through, so it tells you the most.
The webhook setting people miss
The plugin registers its webhook URL for you and re-syncs it each time you save settings. It also checks payment status 5 minutes, 30 minutes, 1 hour, 4 hours and 24 hours after checkout, in case a webhook never arrives.
The problem I'd watch for: if you enable V2 webhook signing in the Atoa Dashboard (Settings → Webhooks), the signing secret has to be pasted into the plugin's Webhook Signing Key field. If it isn't, the docs say plugin webhooks will fail signature verification, and orders stay in Pending until one of those status checks catches up.
V2 signs the raw request body with HMAC-SHA256 and sends the result in an X-Atoa-Signature header. If you have your own endpoint (fulfilment, ERP sync), this is the check in PHP:
<?php
$secret = getenv('ATOA_WEBHOOK_SIGNING_SECRET');
$raw = file_get_contents('php://input'); // raw body, not decoded
$header = $_SERVER['HTTP_X_ATOA_SIGNATURE'] ?? '';
$given = preg_replace('/^v1=/', '', $header); // strip the v1= prefix
$expect = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($expect, $given)) { // timing-safe compare
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// handle PAYMENTS_STATUS / REFUND_STATUS idempotently, then:
http_response_code(200); // anything but 200 counts as a failure
Hash the raw body, because re-encoded JSON won't produce the same bytes. Use hash_equals for the comparison. And make the handler safe to run twice: Atoa retries with exponential back-off for up to 24 hours on any non-200 response, so duplicates will happen.
Going live
Swap in a Production key, turn off Sandbox Mode, and make one real £1 payment from your own bank account. Check the order status changes, then work through the go-live checklist.
The sandbox guide and API reference are at docs.paywithatoa.co.uk, and the plugin is on WordPress.org.
If you've had orders stick in odd states on your own store, I'd like to hear what caused it.
Top comments (8)
Testing PENDING on purpose is the right call; it's the state that teaches you the most.
Three things I'd want to know before going live:
WooCommerce's hold-stock setting cancels unpaid orders after 60 minutes by default. If the webhook or the 4 h / 24 h status check confirms the payment after that, which state wins? The money moved, but the order says cancelled.
If the webhook and a status check both report COMPLETED, is the update idempotent on the payment id, so the order isn't processed twice?
After the last check at 24 h, if the bank still says PENDING, does the order stay pending forever, or does someone get flagged to look at it?
Three good ones, and I only have a clean answer to the last.
On hold-stock. You are right that sixty minutes and a twenty-four hour status check disagree by design, and I did not say so in the post. WooCommerce cancels the pending order, the money has still moved, and a later COMPLETED arrives against an order that is no longer in the state the plugin expects. I have not tested that collision and I am not going to guess what happens. What I would do on a live store is raise or clear hold-stock where bank payment is enabled, because the shorter window is making a decision the gateway is going to contradict. And a payment confirming against a cancelled order should flag rather than silently un-cancel. Somebody needs to look at that one.
On idempotency. The post tells you to make your own endpoint idempotent because retries run for twenty-four hours on any non-200. I did not check whether the plugin itself dedupes the webhook against the scheduled status check on the payment id. That is the right question and worth testing before go-live rather than taking my word for it.
On the twenty-four hour case. The checks stop there. The order stays pending and nothing raises a hand. That is the gap, and it is your first question arriving from the other side: the plugin has a twenty-four hour horizon, the store has a sixty minute one, and neither ends with a human being told.
I will put the hold-stock result in this thread when I have it.
@mickyarun Thanks for the straight answer. "Neither ends with a human being told" is the line I'd add to the post. A daily reconciliation against the bank's settlement report is usually where those orphans finally surface: money in with no live order, or an order still pending after the money arrived. Looking forward to the hold-stock result.
Settlement reconciliation is the right backstop, and I think it works for the reason the status check doesn't. It's a record the bank writes for its own books. The order table and the 4h/24h poll are both downstream of the same call, so when that call is wrong they agree with each other. The settlement file disagrees because nobody wrote it with our orders in mind.
Two things I'd add to it.
Daily is right for the books and wrong for the customer. The orphan surfaces in accounting the next morning. The customer notices straight away and emails. So the reconciliation is where you prove the money is accounted for, not where you catch the order state in time to do anything about it.
And it only catches one direction. Money in with no live order shows up in the settlement file. Money the customer believes they sent that never reached the bank appears in neither record, because there is nothing to reconcile. That one only ever arrives as a support ticket, and it's the case I'd staff for rather than automate.
No hold-stock result yet. When I have one it'll be a follow-up rather than an edit, because if the collision does what I think it does it isn't a footnote.
@mickyarun "Nobody wrote it with our orders in mind" is the best argument for reconciliation I've read. Agreed on both limits. For the direction that shows up in neither record, what we did on top-ups was put the status check where the customer is about to act: before they retry, the screen asks for the state and says "in process, don't repeat it". It doesn't catch that case, but it keeps the customer from turning one unknown into two payments. Looking forward to the follow-up post.
That's a different kind of fix and I think it's better placed than mine. It doesn't make the unknown visible. It stops the unknown turning into a second payment, and the retry is the one moment where the customer and the system are both present.
What it depends on is the status check being allowed to answer "I don't know". A lot of them aren't. When the bank hasn't answered, the endpoint falls back to the last known state, which is not paid, and the screen then tells the customer to go ahead. An unknown rendered as a terminal state is worse than no screen at all, because now the retry has an authority behind it.
Where it stops: it only covers retries that come back through you. The customer who closed the tab and paid again from their banking app never sees it. But that is the cheap half and the cheap half is the one that happens the same evening.
@mickyarun "An unknown rendered as a terminal state" is exactly the failure, and it's why "in process" has to be a state of its own, with its own screen, not a fallback to the last known one. Good thread, thanks. I'll watch for the hold-stock post.
Its own screen is the bit I would underline. A state that only exists in the database gets rendered by whatever template is nearest, and that is how it ends up looking terminal without anyone deciding it should.
Short version of the hold-stock answer, so it is here and not only in a post: the order record and the payment record are two records, and only one of them is authoritative about money. If WooCommerce cancels at 60 minutes and the bank confirms at 90, the payment record wins and the order has to be reopened, not re-paid. Which means the cancel has to be reversible, and most stock-release logic is not written that way.
Thanks for the thread. The reconciliation point is the one I took away from it.