Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Documentation

- Webhook docs and examples no longer recommend a timestamp tolerance (previously 300 s): Nomba doesn't document whether retries get a fresh `nomba-timestamp`, so a tolerance could reject legitimate retries. Deduplicate on `requestId` instead. The docs also note that the amount, `orderReference`, `merchantTxRef` and `tokenKey` aren't signed, so money decisions should be confirmed with `transactions.get`.

## [0.1.1] - 2026-11-03

First release on PyPI. Version 0.1.0 was never published: PyPI had already used that filename for an earlier, deleted `nomba` project, and filenames can't be reused.
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ app = Flask(__name__)
@app.post("/webhooks/nomba")
def nomba_webhook():
try:
event = verify_signature(request.get_data(), request.headers, SIGNING_KEY, tolerance_seconds=300)
event = verify_signature(request.get_data(), request.headers, SIGNING_KEY)
except WebhookVerificationError:
abort(401)
if event["event_type"] == WebhookEventType.PAYMENT_SUCCESS:
Expand All @@ -322,7 +322,9 @@ The signature is a base64 HMAC-SHA256 of fields from the body and the `nomba-tim

Because the signed fields are joined with `:` and the timestamp itself contains colons, the SDK always requires `nomba-timestamp` to be a strict RFC 3339 timestamp (for example `2025-09-29T10:51:44Z`) and rejects a webhook whose `data.transaction.responseCode` contains `:`. Otherwise part of the timestamp could be moved into the body without changing the signature.

`tolerance_seconds` (off by default) rejects webhooks whose timestamp is further than that from now, which limits replays. Nomba retries failed deliveries, so make your handler idempotent on `event["requestId"]` either way.
**Replay protection.** Deduplicating on the signed `event["requestId"]` is the main defence: a replayed or retried delivery of an event you've already processed changes nothing. `tolerance_seconds` rejects webhooks whose timestamp is further than that from now, but leave it off (the default). Nomba documents `nomba-timestamp` as the time the payload was sent, without saying whether automatic retries get a fresh one. If they keep the original, a short tolerance would reject legitimate retries after an outage. Only enable it after confirming in the sandbox that redeliveries carry a new timestamp, and then keep it generous (for example an hour) with server clocks synced.

**Confirm money decisions with the API.** The signature doesn't cover the amount, `orderReference`, `merchantTxRef` or the card `tokenKey`. Before you fulfil, refund or store a card token, requery with `transactions.get` (`order_reference=` for checkout payments, `merchant_tx_ref=` for payouts) and act on its answer.

From a client you can also call `nomba.webhooks.verify(raw_body, headers, signing_key)`. Event types are in `nomba.WebhookEventType`: `payment_success`, `payment_failed`, `payment_reversal`, `payout_success`, `payout_failed`, `payout_refund`. See [examples/webhook_flask.py](examples/webhook_flask.py) and [examples/webhook_fastapi.py](examples/webhook_fastapi.py).

Expand Down
12 changes: 9 additions & 3 deletions examples/webhook_fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,16 @@
async def nomba_webhook(request: Request) -> dict[str, bool]:
raw = await request.body() # the raw bytes, exactly as Nomba sent them
try:
event = verify_signature(raw, request.headers, SIGNING_KEY, tolerance_seconds=300)
event = verify_signature(raw, request.headers, SIGNING_KEY)
except WebhookVerificationError:
raise HTTPException(status_code=401) from None

if event["event_type"] in (WebhookEventType.PAYOUT_FAILED, WebhookEventType.PAYOUT_REFUND):
print("Payout needs attention:", event["data"]["transaction"].get("transactionId"))
ref = event["data"]["transaction"].get("merchantTxRef")
if event["event_type"] == WebhookEventType.PAYOUT_FAILED:
# Not final: Nomba may still retry the payout. Don't refund yet.
print("Payout failing, waiting:", ref)
elif event["event_type"] == WebhookEventType.PAYOUT_REFUND:
# Final: the money is back in your wallet. Refund your user once, after
# confirming with nomba.transactions.get(merchant_tx_ref=ref).
print("Payout refunded:", ref)
return {"ok": True}
4 changes: 3 additions & 1 deletion examples/webhook_flask.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@
def nomba_webhook() -> tuple[str, int]:
try:
# get_data() is the raw body. Don't use request.json and re-encode it.
event = verify_signature(request.get_data(), request.headers, SIGNING_KEY, tolerance_seconds=300)
event = verify_signature(request.get_data(), request.headers, SIGNING_KEY)
except WebhookVerificationError:
abort(401)

if event["event_type"] == WebhookEventType.PAYMENT_SUCCESS:
transaction = event["data"]["transaction"]
# The signature doesn't cover the amount or orderReference: before fulfilling,
# confirm with nomba.transactions.get(order_reference=...) and check the amount.
print("Paid:", transaction.get("transactionId"), transaction.get("transactionAmount"))
# Nomba retries webhooks that don't get a 2xx, so make handling idempotent
# on event["requestId"].
Expand Down
Loading