diff --git a/CHANGELOG.md b/CHANGELOG.md index 06734b4..52e4ca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 9e09e33..d074f14 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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). diff --git a/examples/webhook_fastapi.py b/examples/webhook_fastapi.py index afa1caf..8834233 100644 --- a/examples/webhook_fastapi.py +++ b/examples/webhook_fastapi.py @@ -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} diff --git a/examples/webhook_flask.py b/examples/webhook_flask.py index 1285d30..b6894ed 100644 --- a/examples/webhook_flask.py +++ b/examples/webhook_flask.py @@ -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"].