Retorna Gateway SDK for Python
Official Python SDK for Retorna Gateway (the Retorna B2B API): quotations, orders, wallets and delivery routes. Successor of retorna-python-sdk 1.x; an idiomatic port of the Retorna Gateway SDK for Java, speaking the same contract. The import name is retorna_sdk.
Built in: OAuth2 client_credentials authentication, automatic token refresh, RSA request signing, retries with exponential backoff, explicit idempotency keys, and full type hints.
One runtime dependency — cryptography, because RSA signing is not in the standard library. HTTP is urllib, models are dataclasses, JSON is json. Nothing else lands in your dependency tree.
Highlights vs v1
- Authentication is two independent proofs: an OAuth2 bearer token AND an RSA signature on every request. A valid token on its own is a
401. /me/*endpoints are token-scoped — one credential pair = one company. No moreowner_idparameter.- Monetary amounts are decimal strings (
"56.00","100.000001"), neverfloat. The SDK keeps them asstrend to end. - Wallet transactions moved from offset to cursor pagination. Sending
pageis now a400.
Requirements
- Python 3.9 or higher.
- Retorna-issued
client_id,client_secret, and an RSA private key in PKCS#8 PEM form.
Installation
pip install retorna-gateway-sdk
uv add retorna-gateway-sdk
poetry add retorna-gateway-sdk
Quickstart
The full example lives at examples/quickstart/quickstart.py.
import os
from retorna_sdk import (
RetornaClient, CreateQuotationRequest, QuotationSource, QuotationDestination,
QuotationPayoutMethod, QuotationQuote, PayoutMethodType, QuoteMode,
)
client = RetornaClient.create(
environment="DEVELOP",
client_id=os.environ["RETORNA_CLIENT_ID"],
client_secret=os.environ["RETORNA_CLIENT_SECRET"],
private_key=os.environ["RETORNA_PRIVATE_KEY"], # signs every request
)
# 1. Balance (token-scoped — no company id anywhere)
wallet = client.get_my_wallet("USDR")
print(wallet.amount, wallet.currency)
# 2. Available corridors
routes = client.get_routes("USDR")
# 3. Lock a rate
quote = client.create_quotation(CreateQuotationRequest(
source=QuotationSource("USDR"),
destination=QuotationDestination(
country="VE", currency="VES",
payout_method=QuotationPayoutMethod(PayoutMethodType.BANK_TRANSFER),
),
quote=QuotationQuote(QuoteMode.SEND_EXACT, "10.00"),
))
print(quote.exchange_rate.value, quote.target.amount)
print(quote.destination.country, quote.destination.payout_method.type)
quote.destination mirrors the request's destination. Check it against the order's payment instructions variant (bank_account ↔ BANK_TRANSFER, bank_phone_account ↔ P2P_PHONE_TRANSFER) before calling create_order — a mismatch is rejected as 422 B2B_QUOTATION_MISMATCH. Both country and payout_method.type come back None only when the payments gateway could not attach a rate to the quotation; do not create an order against such a quotation.
Two ways to build a client
RetornaClient.create(...) is the Python front door. The fluent builder mirrors the Java SDK and is there when you need to inspect or reuse a config:
from retorna_sdk import RetornaClientBuilder, RetornaEnvironment
client = (
RetornaClientBuilder()
.environment(RetornaEnvironment.DEVELOP)
.client_id(...).client_secret(...).private_key(...)
.retries(3).backoff_ms(200)
.logging_level("DEBUG")
.build_client()
)
Three things that will bite you
1. The private key is not optional
b2b-service runs a global signature guard. Every api-channel request carries signature and nonce headers, checked against your tenant's public key independently of the bearer token. A perfectly valid token with no signature gets a 401.
The key must be PKCS#8 (-----BEGIN PRIVATE KEY-----). If yours starts with BEGIN RSA PRIVATE KEY it is PKCS#1:
openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem
The SDK accepts the PEM with real newlines, with \n escapes, or flattened onto one line with spaces — which is the shape AWS Secrets Manager hands back.
2. Money is a string
wallet.amount # "1234.567890" <- str, always
float(wallet.amount) # DON'T. IEEE-754 cannot represent 0.1.
When you need arithmetic, go through Decimal:
from retorna_sdk.core import money
total = money.parse(wallet.amount) + money.parse("10.50")
amount_for_the_wire = money.format(total) # back to str
3. Every 401 looks the same
Bad credentials, an expired token, and a bad request signature all surface as a bare 401 B2B_UNAUTHORIZED. The guard never says which check failed. When you are debugging one, that ambiguity is the problem — start by confirming the key matches the client_id.
The SDK will not retry a 401 when it holds a token it believes is still fresh, precisely because that case means credentials or signature, not staleness.
Error handling
Three exception types, all subclasses of RetornaError:
from retorna_sdk import RetornaB2BError, RetornaAuthError, RetornaSdkError
try:
order = client.create_order(request, idempotency_key="invoice-42")
except RetornaB2BError as e:
# The API understood the request and refused it.
e.code # "B2B_INSUFFICIENT_BALANCE" (raw wire string, always preserved)
e.code_enum # B2BErrorCode.B2B_INSUFFICIENT_BALANCE
e.category # B2BErrorCategory.PERMANENT
e.http_status # 422
e.correlation_id # what support will ask you for
e.details # () except on B2B_PROVIDER_REJECTED: the network's field-level verdict
e.is_retryable # only TRANSIENT errors are
except RetornaAuthError as e:
# Token endpoint rejection, or the API rejecting the token/signature.
e.http_status
except RetornaSdkError as e:
# Transport, JSON, or an unstructured 5xx.
e.context # "OrdersClient.create_order"
e.cause
An error code the SDK does not know about still arrives as a RetornaB2BError with code intact and code_enum == B2B_UNKNOWN, so a new backend code never breaks a deployed integration.
details is only populated on B2B_PROVIDER_REJECTED: the payment network's own verdict, one string per rejected field, verbatim (e.g. "members.1.receiver.beneficiary documentType must be one of V, E, J, G"). It tells a human what to fix — log it, surface it to operators, never branch on it.
Bad arguments raise plain ValueError instead, because a malformed UUID is your bug, caught before anything reaches the network.
Idempotency
POST /orders requires an X-Idempotency-Key. It is free-form — the backend only checks that it is present, not that it is a UUID. It round-trips as the order's idempotency_key and is a list filter, so your own invoice number is the intended value:
order = client.create_order(request, idempotency_key="INV-2026-0042")
...
found = client.list_orders(ListOrdersParams.by_idempotency_key("INV-2026-0042"))
Reuse the same key when retrying and the server deduplicates instead of paying twice. Pass nothing and the SDK generates a UUID — safe, but you lose the ability to correlate a retry.
Pagination
Cursor-based, for both orders and wallet transactions:
from retorna_sdk import ListOrdersParams
cursor = None
while True:
page = client.list_orders(ListOrdersParams(cursor=cursor, limit=100))
for order in page.data or []:
...
if not page.pagination.has_more:
break
cursor = page.pagination.next_cursor
pagination.total is the count matching your filters, not the page size, and it may be None.
API surface
| Operation | Method | Endpoint |
|---|---|---|
| Create quotation | create_quotation(request) |
POST /quotations |
| Get quotation | get_quotation(id) |
GET /quotations/{id} |
| Create order | create_order(request, idempotency_key) |
POST /orders |
| Get order | get_order(id) |
GET /orders/{id} |
| List orders | list_orders(params) |
GET /orders |
| Get routes | get_routes(source_currency) |
GET /me/routes |
| Get company | get_my_client() |
GET /me/client |
| Get wallet | get_my_wallet(currency) |
GET /me/wallets/{currency} |
| Wallet transactions | get_my_wallet_transactions(currency, type, params) |
GET /me/wallets/{currency}/transactions/{type} |
Each is also reachable through its sub-client: client.orders.create_order(...), client.wallets.get_my_wallet(...), and so on.
Resource ids are opaque strings of 1..100 characters. Do not assume they are UUIDs — dev returns UUID-shaped ids today, the orders spec documents cpg_txn_a1b2c3d4e5f6, and both are within contract.
Configuration
| Option | Default | Notes |
|---|---|---|
environment |
PRODUCTION |
DEVELOP, SANDBOX, PRODUCTION |
retries |
3 |
Retried on 429 and 5xx only |
backoff_ms |
200 |
Exponential: backoff_ms * 2**attempt |
connect_timeout |
10.0 |
Seconds |
request_timeout |
10.0 |
Seconds |
logging_level |
ERROR |
NONE, ERROR, WARN, INFO, DEBUG — writes to stderr |
ssl_context |
None |
For mutual TLS; see retorna_sdk.core.tls |
base_url_override / auth_url_override |
None |
https only |
Environments resolve to:
| Environment | Base URL | Scope |
|---|---|---|
DEVELOP |
https://api.gateway.dev.retorna.app |
sandbox/full_access |
SANDBOX |
https://api.gateway.sandbox.retorna.app |
sandbox/full_access |
PRODUCTION |
https://api.gateway.retorna.app |
prod/full_access |
SANDBOX is the environment the public API documentation calls Sandbox; infra names the same stack stg, which is why its Cognito hosted domain says b2b-retorna-stg.
Identity headers
Every request carries two headers that tell the platform which SDK is calling, so adoption per version can be tracked and support can tell a Python integration from a Java one:
X-Retorna-Client: python-sdk/1.0.3
User-Agent: python-sdk/1.0.3 (python/3.12.4; Linux x86_64)
The python-sdk token is fixed and independent of the PyPI distribution name. Nothing sensitive is sent.
Every request also carries an explicit Accept: application/json (unless the caller supplies their own), since the gateway requires it alongside User-Agent.
Development
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
make test # unit tests
make lint # ruff
make typecheck # mypy --strict
make check # all three
make integration # live DEVELOP tests (needs credentials)
The integration suite is read-only by design — it never calls create_order, which moves real funds. Keep it that way.
Relationship to the other SDKs
This SDK is a port of the Retorna Gateway SDK for Java (retorna-java-sdk-v2), the reference implementation. The request-signing logic is verified byte-for-byte against it. All three SDKs (retorna-gateway-sdk on npm, Maven Central and PyPI) share the same contract, the same environment names and the same X-Retorna-Client identity header.
License
Proprietary. Use is limited to integrating your systems with Retorna Gateway under a commercial agreement with Retorna — see LICENSE.
Release files for retorna-gateway-sdk 1.0.3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| retorna_gateway_sdk-1.0.3.tar.gz | 73.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| retorna_gateway_sdk-1.0.3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 136.5 kB
Release files / retorna_gateway_sdk-1.0.3.tar.gz
| Download URL | retorna_gateway_sdk-1.0.3.tar.gz |
|---|---|
| Size | 73.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9586a8d185f7f981d6b6b03e445b166127c7a40684113d39493942f7b7811b0f
|
|
BLAKE2b-256 checksum How to use checksums |
696c2875005cfb71e409c8f3e5a781c2e3f01a48558cb8758fe861e91d4c2094
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / retorna_gateway_sdk-1.0.3-py3-none-any.whl
| Download URL | retorna_gateway_sdk-1.0.3-py3-none-any.whl |
|---|---|
| Size | 63.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
342212b28832c3ffa2fe8f74a3fc5140c51b7c4c2bfc5718866c1cd54f792108
|
|
BLAKE2b-256 checksum How to use checksums |
ee049b7053a6d50c0b8a0d4388a2e958d9c998a601734df2f79fe2b64fb14dff
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|