Why a fake Tabby success URL cannot mark an Odoo order paid

How a forged return URL or a replayed Tabby webhook can mark an Odoo order paid, and how server side verification against the provider API stops it.

Every redirect payment flow has the same weak point, and it is not the gateway. It is the moment the customer comes back. Your shop sends the shopper off to Tabby or Tamara, something happens on the provider's side, and then a browser you do not control makes a request to a URL on your domain. Whatever that request claims, your module has to decide whether the order is paid.

If the module believes the request, anyone who can read a URL can shop for free. This article shows what that attack looks like against an Odoo store, then walks through how Tabby & Tamara for Odoo answers it. It is written for the developer who has to sign off the purchase, or who wants to audit the module already installed.

The one request you do not control

Odoo's payment framework handles a redirect provider through three inbound events, and they are not equally trustworthy.

  1. The return. The customer's browser lands back on a route on your domain, usually a GET with query parameters. This request is fully attacker controlled. It can be edited, bookmarked, replayed hours later, or shared with a friend.
  2. The webhook. The provider posts a notification to a public endpoint. It is public by definition, so anyone on the internet can post to it too.
  3. The outbound call. Your server asks the provider directly, over its own TLS connection, using your secret key. The shopper is not part of this conversation at all.

Only the third one is a source of truth. The first two are hints that something may have changed. A secure integration treats them that way, and an insecure one treats the first one as a bank statement.

The return URL also gets hit in perfectly innocent ways. A customer who abandons the plan on the Tabby page can still press back, or open the link from history the next day. A flow that reads "arrived at the return URL" as "paid" breaks on honest customers before it ever meets a dishonest one.

What a forged return actually looks like

Assume a module registers a route at /payment/tabby/return. A genuine return might look like this.

https://shop.example.com/payment/tabby/return?payment_id=9d7f1a2c&status=authorized

And a naive handler looks something like this.

@http.route('/payment/tabby/return', type='http', auth='public', csrf=False)
def tabby_return(self, **data):
    tx = request.env['payment.transaction'].sudo().search(
        [('reference', '=', data.get('order'))], limit=1)
    if data.get('status') == 'success':
        tx._set_done()          # trusts the query string
    return request.redirect('/payment/status')

Nothing in that code talks to Tabby. The state of a real financial transaction is decided by a string the customer typed. So the attack is not clever, it is typing.

https://shop.example.com/payment/tabby/return?order=SO0042&status=success&amount=999

Place a normal order, note the reference on the confirmation screen, abandon the payment, then visit that URL. The transaction goes to done, the sales order is confirmed, the invoice is posted, the confirmation email goes out, and in a store with automatic delivery the warehouse gets a picking for goods nobody paid for.

The variants are worse than the base case. Change amount and see whether the module records the number from the URL instead of the number from the gateway. Walk the reference sequence, SO0043, SO0044, and settle other people's baskets. Approve one small real payment, then replay that successful notification against a larger transaction.

A signature on the redirect helps, but it is not the answer

Some gateways sign redirect parameters, and verifying that signature is strictly better than not verifying it. It stops the hand typed URL above.

It still does not tell you the current state. A signature proves the payload was issued by the provider at some point. It does not prove the payment is authorised right now, that it was not cancelled two minutes later, that it was captured, or that this payload has not already been consumed by another transaction. Signature checks answer "who said this", and the question you actually need answered is "what is true now".

That is why the only robust pattern is to stop parsing claims and go ask.

How Tabby & Tamara for Odoo verifies a payment

The return URL supplies a reference and nothing else

The only value taken from the returning browser is the identifier needed to locate the transaction. No status, no amount, no currency, no provider decision is read from the query string.

This matters more than it sounds. It means there is nothing in that URL an attacker can set to a useful value. Appending &status=success&amount=999 changes nothing, because no code path reads those keys. The forged parameters are simply not part of the decision.

The status is read back from the provider's API

Once the transaction is located, the module makes a server to server call to the provider and reads the payment object back.

GET /api/v2/payments/{id}
Authorization: Bearer <your secret key>

The response body is the authoritative record: the current state, the amount and the currency as the provider holds them. Odoo then moves the transaction according to that response and nothing else. An authorised payment is captured and settled into the done state, a closed, rejected or expired payment is cancelled or flagged as an error, and a payment still in progress is left pending so the next notification or poll can resolve it.

The property to hold on to is this: the best possible outcome of a forged return is that it triggers a lookup which returns the truth. There is no input an attacker can craft that makes the API say authorised when the provider has not authorised anything.

Webhooks are re-verified the same way

A webhook is just an HTTP request from the internet. The endpoint has to be publicly reachable for the provider to use it, which means everyone else can reach it too. Writing a JSON body that claims a payment succeeded is not harder than editing a query string.

So the webhook is treated as a trigger, not as evidence. The payload is used to work out which transaction is being talked about, and then the same authenticated GET /api/v2/payments/{id} decides what actually happens. A hand crafted or replayed webhook reaches exactly the same verification step as a genuine one, and a genuine one gains nothing by being genuine.

Finalised transactions are never re-processed

The last piece is idempotency. A transaction that has already reached done, cancelled or error is not processed again. Later notifications for it are acknowledged and ignored.

This is not a theoretical nicety. It covers several very ordinary situations:

  • Providers retry webhooks on delivery failure, so at least one duplicate is normal operation.
  • The customer return and the webhook regularly arrive within the same second, and both point at the same record. Without a final state guard you can post the payment twice against one invoice.
  • A captured notification replayed after you have refunded the order would otherwise flip a refunded transaction back to paid.
  • A cancellation arriving late must not undo a payment that was already captured and reconciled.

Combined with the API read back, this closes the replay class of attacks. A recorded notification can be sent again as many times as anyone likes, and it will either hit a finalised transaction and do nothing, or trigger a fresh status read that reports the real state.

The same question at the counter

The Point of Sale version of this problem is not a forged URL, it is a phone screen. A customer holds up a success page at a busy till and the cashier has no way to tell a live approval from a screenshot of last week's.

The answer is the same shape. The cashier picks a provider, a QR popup opens, the customer scans and approves on their own device, and the server polls the gateway and captures on authorisation. The Validate button stays locked until capture actually succeeds, with no manual override. The cashier's screen is driven by the gateway response rather than by anything the customer shows. The full counter flow, and what to train a cashier on, is in Tabby and Tamara in the Odoo Point of Sale.

A twenty minute audit you can run on any module

You do not have to take a vendor's word for this, including ours. In sandbox, with a test order, run these.

  1. Complete a real sandbox payment and capture the exact return URL from the browser network tab.
  2. Start a second order, cancel it on the provider page, then paste the first return URL with the second order's reference. The order must not become paid.
  3. Append &status=success&amount=1 to a genuine return URL and reload. Nothing should change, and the recorded amount must not move.
  4. Replay a successful notification a second time. You must end with one payment and one invoice, not two.
  5. POST a hand written JSON body to the webhook endpoint with a valid reference and a success status. Nothing should change.
  6. Open the API log. For every one of those events you should see an outbound call to the provider. If an order flipped to paid with no outbound call recorded, the module trusted its input. The fix guide for a provider not showing at checkout has a section on reading that log by status code.
  7. Read the controller source and grep for request parameters flowing into _set_done, _set_authorized or the version equivalent. The state setters should be fed from a parsed API response, never from kwargs.

Point seven is a licensing question as much as a technical one. Tabby & Tamara for Odoo ships under LGPL-3 with readable source, so this review is something you can actually perform. A proprietary listing, or one licensed to a single domain with sharing forbidden, can only be accepted on faith.

What the competing listings say about this

Here is a fair observation rather than an accusation. Across the Tabby and Tamara listings checked on the Odoo Apps Store, none documents how it verifies payment status beyond mentioning webhook registration. Several are one provider per module at around $270, so a merchant who wants both buys twice. One covers both providers but is website only, one covers both but is Point of Sale only and requires Enterprise, and a cheaper option is licensed to a single domain and forbids sharing the source. The listings, their prices and what each covers are compared in full in which Tabby and Tamara module to buy for Odoo.

That is a statement about published descriptions, not about the code behind them. Those modules may verify server side and simply not say so. But you are being asked to buy on the description, so ask the vendor three questions before you do:

  • Does any part of the return URL influence the transaction state?
  • Is the final state read from the provider's API on a separate authenticated call?
  • What happens when a finalised transaction receives another notification?

Any vendor who has thought about this will answer in one paragraph.

What server side verification does not do

Being precise about the boundary is part of the trust argument.

It does not get you a merchant account. You need your own Tabby and Tamara accounts with their documents and their approval process. The module connects your keys and nothing more, and it is an independent integration by Kerneltics with no affiliation with or endorsement by either company.

It does not carry credit risk either. Whether a shopper keeps paying their instalments is between them and the provider under your agreement, and fee rates and instalment counts are set by those companies, not by any Odoo module.

And it does not replace your own hygiene. Keep secret keys out of version control, serve the return and webhook routes over HTTPS only, and keep refunds behind the dedicated access group the module provides rather than handing them to every back office user. The API log redacts secrets, which makes it safe to read, but it is still operational data worth restricting.

The bottom line

A BNPL integration is a system that hands out goods based on a message. The only question that matters is which message it believes. Believe the customer's browser and you have built a free shop. Believe an authenticated response from the provider, refuse to re-process what is already finished, and the forged return becomes a harmless page load.

Tabby & Tamara for Odoo is one module, one purchase at $200, covering both providers across website checkout, portal invoices, portal quotations and the Point of Sale, on Odoo 14.0 to 19.0, Community and Enterprise, under LGPL-3 so your developer can read every line of the verification path before you go live.

Frequently asked

Can a forged return URL mark an Odoo order as paid?

Not with an integration that verifies server side. In Tabby & Tamara for Odoo the return URL supplies only a reference, and the state is read back with an authenticated GET /api/v2/payments/{id} to the provider. Adding status=success or an amount to the URL does nothing, because no code path reads those keys. A module that trusts the redirect genuinely can be flipped that way, which is why the question is worth asking before you buy.

How are webhooks verified, and is a signature enough?

A webhook is treated as a trigger, not as evidence. It is used only to identify which transaction is involved, then the real state is read from the provider's API with the same authenticated call. A signature is useful and blocks hand written payloads, but it proves who sent the message, not what is true now: a validly signed payload can describe a payment that was cancelled or refunded afterwards. Reading the state from the source is what settles it.

What happens if the same notification arrives twice?

Nothing on the second arrival. A transaction that already reached a final state, whether done, cancelled or error, is not processed again, and later notifications for it are acknowledged and ignored. That covers ordinary provider retries, a customer return and a webhook landing in the same second, and an old success notification replayed after a refund. The result is one payment against the invoice, not two.

How can I verify for myself that a module checks server side?

Open the API log after each payment event. You should see an outbound call to the provider for every return and every webhook. If an order flipped to paid with no outbound call recorded, the module trusted its input. Because the module is LGPL-3 with readable source, you can also open the controller and confirm that state setters such as _set_done are fed from a parsed API response rather than from request parameters.

In the POS, what stops a cashier closing an unpaid sale?

The Validate button stays locked until capture actually succeeds at the provider, with no manual override. The cashier picks a provider, a QR popup opens, the customer approves on their own phone, and the server polls the gateway and captures on authorisation. A screenshot of an older approval unlocks nothing, because the cashier's screen is driven by the gateway response and not by the customer's phone.

One payment, for all your stores

$200
Buy on the Apps Store Talk to us first

Articles