Skip to content

Build1 publisher3 min readPublished

Treating MTN's 409 as a failure is what double-charges the customer

Of the three mobile money APIs in one UEMOA developer's integration, only MTN ships a real idempotency key. The 409 it returns on a replay is what turned a timeout into a second payment prompt.

The Engineer · Build desk

Illustration accompanying Treating MTN's 409 as a failure is what double-charges the customer

What happened

  • The BCEAO pushed to 30 September the date by which banks, electronic money institutions and payment institutions must be connected to the PI-SPI platform.
  • Of the three mobile money providers in one developer's integration, only MTN supplies a real idempotency key, the X-Reference-Id header on its request-to-pay endpoint, which must be a UUID.
  • Wave ships no idempotency header, so a repeated call to its checkout sessions endpoint creates a second payment session outright.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • cost The compliance work buys interoperability between institutions and nothing inside the integrator's own job queue, so the duplicate-charge layer is separate engineering paid for by whoever builds the driver.
  • decision Every team mapping provider HTTP codes to a payment state machine now has to pick what 409 means; Succeeded and Failed are both wrong, and Pending commits you to building the polling loop.
  • constraint For a provider with no key, prevention is off the table and the retry path has to spend a lookup call before every replay, which changes the shape of the job rather than adding a header to it.
  • exposure Leave restrict_payer_mobile off a Wave session and a checkout URL that circulates can be settled by a stranger and credited against your order, landing the mess on reconciliation rather than on the payer.

Follow the retry path once. The HTTP client gives up after thirty seconds, the queue marks the job failed, and Laravel replays it [5]. The replay carries the same X-Reference-Id, so MTN declines to charge again and answers 409 Conflict with a body of RESOURCE_ALREADY_EXIST [7]. A driver that treats anything outside 2xx as a failure throws there, the caller concludes nothing was charged, and mints a new reference [8]. That new reference is a new payment request, the customer gets a second prompt, and if he approves it he pays twice [8]. The developer's write-up on dev.to says his own driver did exactly this, and that the key worked as specified: the double debit came out of reading the response [8].

So the status to write on a 409 is Pending [10]. A 409 tells you the first request was accepted, not what it reached, and the response does not carry that state [9]. The caller polls from there [10].

MTN also constrains the key itself. X-Reference-Id must be a UUID, and an order number is refused without an explanation [6]. That means the key is not derivable from the order, so it has to be persisted before the first attempt and read back on the replay [2]. A retry has to reuse the same UUID; generating a fresh one turns it into a new request. Learning the UUID rule from an unexplained rejection is a debugging experience you get to have exactly once.

Wave has no idempotency header at all, and a repeated POST to /v1/checkout/sessions simply creates a second checkout session [11]. The only handle is client_reference, capped at 255 characters and echoed back on the webhook [12]. It becomes usable because the read side supports lookup by it: Wave session ids carry a cos- prefix, and the driver routes anything without that prefix to /v1/checkout/sessions/search?client_reference= [13]. The second session will exist regardless; the only defense is finding it before the customer pays it, so the rule is to check before replaying [14].

On Orange Money the account goes as far as the shape of the flow, a web redirect returning a payment_url, with order_id as the only identifier you own [16]. Whether the Wave pattern of detect-then-decide transfers there is not established by this material. The incidence is likewise undocumented: three providers appear in this material, and no duplicate-charge rate is reported [3]. The BCEAO numbers around it are about connectivity, not correctness. At the end of June, 80 participants were connected and 74 institutions were still in live testing [3]; the source does not say whether the 74 sit inside the 80, and if they do, six were through (80 - 74 = 6) [1]. Senegal led the union with 20 authorised institutions on 2 April [4]. The claim that the deadline crunch is producing this class of bug is the author's, and he is explicit that it shows up only in production [2][15].

What transfers is narrow and cheap to adopt. If your provider set includes Wave, that 255-character client_reference is your only reconciliation handle, and it has to be populated on the first call, not discovered on the retry [12].

What to watch

  • Whether the BCEAO moves the 30 September date again, or publishes how many of the 74 institutions finished live testing.
  • Whether Wave adds an idempotency header, which would make the search-before-replay workaround dead code.
  • Whether Orange Money's order_id supports a read-side lookup, which decides if the Wave detect-first pattern transfers.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories