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>

That is the Tabby call. For Tamara the same read is GET /orders/{id}, authenticated with your API token.

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.

A forged return claiming the order is paid: Odoo keeps only the reference, asks the Tabby API, and marks the order paid only once Tabby confirms the payment.

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.

Who on your team can see the keys and move money

Server side verification settles what a stranger can do with a URL. The other half is what your own staff can do, and that comes down to three questions: who can read the keys, who can read the API log, and who can send money back.

The keys

On Odoo 15 and later, the Tabby secret key, the Tamara API token and the Tamara notification token are restricted at field level to Settings administrators. Odoo's payment app opens provider records only to that group, so a salesperson or an accountant never reaches the provider form in the first place. Where another Odoo app grants a role read access to the form, the key fields stay hidden from that role. On screen the keys are masked, which stops someone reading over a shoulder but not an administrator who wants the value.

The Point of Sale keys are entered under Settings and stored as system parameters, and only Settings administrators can read those either.

In practice, then, your list of Settings administrators is the list of people who hold your keys. Keep it short. Odoo 14 is the exception. There the module follows that version's own convention for provider keys, which keeps them from portal users and website visitors but not from employees, so on 14 assume any internal user can read them.

The API log

From Odoo 15, the API log records every call the website and the customer portal make to Tabby and Tamara. The counter's calls are not in it. Its value is that it keeps exactly what was sent and what came back.

Your keys are not in it. The authorisation header that carries them is never stored, and only the path of each address is kept. Your customers are in it, though. Each checkout request carries the customer's name, email, phone and shipping address along with the basket, and each reply is kept as the provider sent it, both cut at 6,000 characters.

The module's access rules let Settings administrators read and delete these rows, and Accounting administrators read them. Nothing deletes old rows on its own, so the log grows for as long as the store trades. If your privacy notice promises to keep customer data only for a set period, this log falls under that promise too, and a Settings administrator can select old rows in the list and delete them.

Refunds

There are two separate gates, and which one applies depends on where the sale happened.

A website or portal sale is refunded through Odoo's own Refund button on the payment record. Odoo shows that button to users with invoicing rights on every version from 15 to 19, and makes the provider call with system rights itself, so the person refunding never needs to see a key. The module's Tabby Refund right plays no part here. To limit who can refund an online sale, limit who holds invoicing rights.

A counter sale is refunded from the Refund on Tabby button on its payment record or invoice, and that button belongs to the module. It is gated by the Tabby Refund right, which nobody holds until you grant it, and the check runs inside the method rather than only hiding the button. A cashier's ordinary refund at the till still reaches Tabby without that right, because a cashier is already trusted to refund a sale there.

Every path, screen by screen, is in the guide to Tabby and Tamara refunds in Odoo.

The Tamara notification token

The Tamara provider has a Notification Token field, and nothing described on this page depends on it. A webhook carrying the right token, a wrong one or none at all reaches the same read from Tamara's API, so filling the field in or leaving it blank does not change whether a forged notification can mark an order paid.

A twenty minute audit you can run on any module

You do not have to take a vendor's word for this, including ours. If you have no sandbox yet, testing Tabby and Tamara in a sandbox before go-live covers what decides test or live for each provider and what a sandbox order can and cannot prove. Then, 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. Where those keys go, and how to check them before any customer sees the button, is covered in setting up Tabby in Odoo, from the keys to a first real order.

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 Settings administrator, invoicing and Tabby Refund rights with the few people who need them, as set out above. The API log keeps your keys out, but it holds your customers' details, so treat it as customer data.

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.

Who on my team can see the Tabby secret key?

On Odoo 15 and later, only Settings administrators. The Tabby secret key and both Tamara tokens are restricted to that group at field level, Odoo opens provider records to that group alone, and the Point of Sale keys sit in system parameters that only it can read. Staff who take or refund Tabby payments never need to see a key. Odoo 14 is the exception: there any internal user can read them.

One payment, for all your stores

$200
Buy on the Apps Store Talk to us first

Articles