timbro-payments
The Python SDK for Timbro Payments: create payments and refunds, read merchant accounts, prepare business certification, manage webhook endpoints, and verify signed webhooks. It needs Python 3.11 or newer and depends only on httpx and timbro-protocol.
pip install timbro-payments
Take a payment
from timbro_payments import TimbroPayments, payment_reference_for_source
with TimbroPayments(api_key=secret_key, application="my-erp/1.0") as timbro:
payment = timbro.payments.create({
"paymentReference": payment_reference_for_source({"id": "order-123", "version": "1"}),
"amount": {"currency": "DOP", "value": "1250.00"},
"description": "Servicio",
"tax": {"includedAmount": "190.68"},
})
redirect_to(payment["checkout"]["url"])
payment = timbro.payments.retrieve(payment["id"])
for refund in timbro.refunds.list_all(payment_id=payment["id"]):
...
Requests and responses are plain dictionaries with the API's camelCase keys, typed as TypedDicts in timbro_payments.types. Money is a decimal string. Fields and enum values added by a later API version pass through unchanged. A declined attempt is not an error and does not end the Payment: it stays requires_customer_action with lastDecline, and each declined attempt sends payment.declined.
AsyncTimbroPayments has the same namespaces: await each call and use async for with list_all.
timbro.mode is "test" for an sk_test_ key, "live" for an sk_live_ key, and None for any other key form, so an integration can show which mode it is configured for without a call.
| Namespace | Methods |
|---|---|
payments |
create, retrieve, list, list_all, cancel, create_invoice, attach_invoice, update_invoice_choice, update_invoice_recipient |
subscriptions |
create, retrieve, list, list_all, payments, payments_all, resend_recovery_notice, cancel, setup, management |
refunds |
create, retrieve, list, list_all, attach_invoice |
merchant_accounts |
retrieve, list, list_all |
webhook_endpoints |
create, retrieve, list, list_all, delete, suspend, reactivate, rotate_secret, list_deliveries, list_all_deliveries, replay |
business |
retrieve, update, configure_invoicing, import_signing_certificate, record_sequence, issue_test_invoice, verify_rnc, checkout_policy.retrieve, checkout_policy.update, certification.retrieve, certification.start, certification.submit_evidence, certification.upload_test_set, certification.retrieve_test_set, certification.sign_portal_document |
api_keys |
rotate |
Path identifiers are positional, the request body is the positional request, and filters and options are keyword-only.
Connect as an integration
An integration such as an Odoo company passes its registered client and private key instead of an API key, as the timbro-invoices README shows. Install timbro-payments[integration]. Its token carries every scope the business approved in the credential's mode, and a TimbroInvoices client built from an equal credential shares it. The same credential manages the webhook endpoints it creates, for the events of the scopes it holds.
from timbro_payments import IntegrationCredential, TimbroPayments
timbro = TimbroPayments(
credential=IntegrationCredential(
client_id=client_id, private_key=private_key_pem, key_id="odoo-company-1", mode="test"
)
)
Set up a monthly subscription
Use a recurring-capable Merchant Account. Create the customer agreement and fixed DOP sale, then send the customer to Checkout to accept its terms and approve the first Payment.
subscription = timbro.subscriptions.create({
"subscriptionReference": "membership-881",
"customer": {"customerReference": "881", "name": "Ana Pérez", "email": "ana@example.com"},
"currency": "DOP",
"items": [{
"description": "Monthly membership", "quantity": "1", "unit": "unit", "kind": "service",
"unitPrice": "1500.00", "tax": {"kind": "itbis_18", "included": True},
}],
"interval": {"unit": "month"},
"invoicing": {"mode": "automatic", "type": "E32", "recipientEmail": "ana@example.com"},
})
subscription_setup = subscription["setup"]
if subscription_setup is not None and subscription_setup["url"] is not None:
redirect_to(subscription_setup["url"])
New setup Payments last 24 hours and expose setup["expiresAt"]. Refreshing their Checkout capability does not reopen an expired agreement. Persist and reuse the subscriptionReference and request after a timeout. Equivalent inputs return the retained Subscription; changed intent conflicts. After terminal expiry, create a new agreement with a new reference and customer acceptance.
subscriptions.retrieve(id) reads the agreement. Filter subscriptions.list with status, subscription_reference, or customer_reference, plus limit and cursor. subscriptions.cancel(id) stops future collection while preserving submitted outcomes and fiscal work. subscriptions.management(id) issues a private customer link. Expired-link recovery sends a replacement only to the confirmed acceptance address; canceled agreements retain read-only access. The async client exposes the same methods.
The resource embeds at most five Payments. Continue from paymentsNextCursor with subscriptions.payments(id, cursor=...) when paymentsHasMore is true. Each page exposes hasMore and nextCursor; subscriptions.payments_all(id) iterates history lazily, and subscriptions.list_all(status="past_due") does the same for the list. Cursors bind to the agreement, business, mode, and filters.
Renewal Payments have expiresAt: None and checkout.expiresAt: None until recovery notice handoff establishes their deadline. Do not display a countdown for None. An established seven-day recovery deadline remains unchanged on resend. Ordinary Payments retain their existing expiry rules.
For a deliberate recovery email, call subscriptions.resend_recovery_notice(id, idempotency_key=saved_key). The customer may receive a notice twice when an earlier send's handoff was uncertain; the notice tells them that no further payment is needed if they already paid. Each Payment allows three resends in any 24 hours; a fourth raises the rate_limit_exceeded problem with Retry-After. Persist the key before sending and reuse it after a timeout; a new deliberate resend gets a new key. Without an explicit key, the SDK generates one per invocation and reuses it for automatic transport retries. This command sends no card charge and does not extend the deadline.
subscription.canceled and subscription.status_changed are signed thin events. Deduplicate their IDs and retrieve the current Subscription because deliveries can arrive out of order. These methods are part of the unpublished 0.17.0 candidate; source availability does not establish hosted or LIVE qualification.
Prepare a business for DGII certification
Certification is scoped to the business selected by the API credential. Read its available paths, start the recommended one, then resume the same process by reading it again. Persist each command's idempotency key before sending it and reuse that key only when retrying the same command.
current = timbro.business.certification.retrieve()
if current["status"] == "not_started":
current = timbro.business.certification.start(
{"path": "external_software"},
idempotency_key="merchant-onboarding-123-certification-start",
)
if current["status"] == "in_progress":
if current["requirements"]:
for requirement in current["requirements"]:
print(f"Complete the {requirement['owner']} requirement: {requirement['code']}")
else:
step = next(
(
step
for step in current["steps"]
if step["number"] == 1 and step["status"] == "action_required"
),
None,
)
if step is not None:
print(f"Next step {step['number']}: {step['title']} ({step['actor']})")
# The authorized person completes this action in DGII before reporting it to Timbro.
After the authorized person completes a DGII step, report its real result with
timbro.business.certification.submit_evidence(), using the current revision, the step number,
and the same persisted idempotency key if retrying. The result records the person's report; even an
approved outcome remains user-reported and does not qualify the business for DGII production.
Legal documents and DGII portal actions stay with the authorized person.
business.import_signing_certificate() takes the business's one certificate and password, names no
mode, and returns Business; Timbro uses the certificate in every environment the business
holds, including DGII certification. Timbro's protected Portal handoff is the recommended place for
a person to upload the certificate and password. Never send either through an agent conversation.
Upload the DGII certification workbook
After DGII provides the e-CF test workbook, upload its original bytes and filename. The file must be between 1 byte and 3 MiB. The API retains it for review; uploading does not submit fiscal documents, run workbook cases, or advance the certification process.
from pathlib import Path
from timbro_payments import UploadCertificationTestSetCommand
workbook_upload: UploadCertificationTestSetCommand = {
"kind": "ecf",
"file": {
"name": "DGII.xlsx",
"bytes": Path("DGII.xlsx").read_bytes(),
},
}
uploaded = timbro.business.certification.upload_test_set(
workbook_upload,
idempotency_key="merchant-onboarding-123-dgii-ecf-workbook-v1",
)
test_set_snapshot = timbro.business.certification.retrieve_test_set()
if test_set_snapshot["current"] is not None:
print(test_set_snapshot["current"]["review"]["status"])
The initial review is review_required; case_mapping_pending means the workbook still needs its
cases mapped. Keep the idempotency key with the uploaded bytes and filename. If the result is
unknown, retry with that same key and exactly the same filename and bytes. Use a new key when
uploading a corrected replacement. AsyncTimbroPayments exposes the same methods; await each call.
Record the e-NCF sequences DGII granted
Live invoicing numbers each e-CF from a sequence DGII granted the business in its Oficina Virtual. Test sequences are automatic. Record each live sequence with the numbers and expiry DGII shows:
business = timbro.business.record_sequence(
{
"documentType": "E31",
"firstNumber": 1,
"lastNumber": 500,
"expiresOn": "2027-12-31",
}
)
live = business["invoicing"]["live"]
if live["status"] == "available":
e31 = next(
(entry for entry in live["documentTypes"] or [] if entry["type"] == "E31"), None
)
nextNumber defaults to firstNumber; send it when another system already used numbers from the
sequence. expiresOn is required except for E32 and E34, which never expire and take no date.
Recording the same first and last number again, or a sequence DGII has not confirmed, replaces it;
a sequence DGII has confirmed cannot be overlapped. Timbro stores the sequence as recorded and DGII's
answers confirm it, so numbering.confirmation reads not_yet_confirmed until DGII accepts an e-CF
numbered from it. The answer is the business: documentTypes shows the type ready or the reason it
is not, and sequences lists what is recorded. Only an owner or administrator key records or reads
sequences. AsyncTimbroPayments exposes the same method; await it.
Retries and idempotency
The client retries at most twice (max_retries=2) after 408, 429, 500, 502, 503, 504, and connection failures. It waits 0.1 seconds, then 0.2, and honors a Retry-After of up to two seconds; a longer Retry-After ends the call with retry_after_seconds set. Reads and other operations the contract marks safe always retry. Other writes retry only with an idempotency key.
payments.create, payments.cancel, payments.create_invoice, payments.attach_invoice, refunds.create, and refunds.attach_invoice derive their idempotency key from the payment reference, the payment, the refund reference, or the attached Invoice and its payment or refund, so repeating a call is safe. Other writes that need a key take idempotency_key= and require it. Derive that key from your durable record of the command, such as the order line it serves, and send the same key on every retry and replay. Never generate a random key per attempt: an ERP that replays a transaction would then run the command twice. An explicit key always wins.
Timeouts
timeout (30 seconds by default) limits each phase of one attempt separately: waiting for a pooled connection, connecting, writing, and each wait for response bytes. When Timbro stops answering, an attempt ends after timeout, and a call makes at most max_retries + 1 attempts with up to two seconds between them. A stalled call therefore lasts about (max_retries + 1) * timeout plus four seconds: about 94 seconds with the defaults. Slow connects and writes can add their own phase limits on top. There is no overall deadline. A worker bound to a request, such as an Odoo worker with a 120-second limit, should pass smaller values, for example TimbroPayments(api_key=key, timeout=10, max_retries=1), which bounds a call near 22 seconds. A call can also pass timeout= to override the default for that call.
httpx logs each request URL, including the references in a list filter, at INFO on the httpx logger. Raise that logger's level to keep them out of your logs:
import logging
logging.getLogger("httpx").setLevel(logging.WARNING)
With your own http_client, that client's timeout applies unless a call passes timeout=. httpx finds CA certificates through SSL_CERT_FILE and SSL_CERT_DIR; it does not read REQUESTS_CA_BUNDLE. A host behind TLS inspection passes an http_client built with its CA bundle, such as httpx.Client(verify=ssl_context).
Errors
Every error is a TimbroError with code, message, request_id, operation, method, retryable, outcome_unknown, recovery, and recovery_method. operation and recovery are contract operation IDs, such as createPayment; method and recovery_method are the SDK methods that send them, such as payments.create and payments.list. The text never contains the API key, a request body, a header, or a Checkout URL. Errors can be pickled, so a job queue can carry them.
timbro-payments and timbro-invoices raise the same classes from timbro-protocol, so one except TimbroError handles both. To treat one package's calls differently, branch on method or operation:
from timbro_payments import TimbroError
try:
timbro.payments.cancel(payment["id"])
except TimbroError as error:
if error.method == "payments.cancel" and error.code == "payment_cancel_not_allowed":
payment = timbro.payments.retrieve(payment["id"])
else:
raise
| Error | When |
|---|---|
InvalidRequestError |
400 or 422; problems lists each field at fault |
AuthenticationError |
401 |
PermissionDeniedError |
403, such as a key without the needed grant |
NotFoundError |
404 |
ConflictError |
409 |
RateLimitError |
429; see retry_after_seconds |
ServerError |
5xx |
APIError |
Any other failure status, and the base of the classes above |
APIConnectionError, APITimeoutError |
No response arrived; code says what that means, below |
UnexpectedResponseError |
A response outside the published contract, including a redirect, or one that names another resource than the one a read or an action addressed (response_identity_mismatch) |
When no response arrives, code separates three cases:
code |
Meaning |
|---|---|
connection_failed |
The request never left, or a read broke off. Nothing changed at Timbro. |
request_timed_out |
A read timed out. Nothing changed at Timbro. |
outcome_unknown |
A write may have reached Timbro. outcome_unknown is true. |
@timbro/payments reports all three as transport_failure with status 0, and does not say whether a write may have run.
When outcome_unknown is true, a write may have taken effect even though the call failed: an earlier attempt or the last one reached Timbro and got no final answer. The error text says what to do next. Repeat the call with the same idempotency key, or find the result with the recovery method. payments.create recovers with payments.list(payment_reference=...) and refunds.create with refunds.list(payment_id=..., refund_reference=...), so a lost create is found without its ID.
Routes outside the published contract
Prefer the typed methods. For a route the published contract does not include yet, such as a Stamp installation's own readiness read, request sends one call through the same client, with the same API key, user agent, redirect refusal, 10 MiB limit, and errors:
readiness = timbro.request("GET", "/v1/stamp/readiness")
path must be a /v1/ path; an absolute URL, a query or fragment in the path, and an empty or dot segment raise ValueError, and query= carries the filters. The answer is the decoded JSON object, or None for an empty one, and nothing checks its shape. A GET is retried when safe; any other method is retried only when you pass idempotency_key=. An error it raises has method "request", operation set to the method and path, such as "GET /v1/stamp/readiness", and no recovery. AsyncTimbroPayments clients have the same method to await.
Webhooks
Verification needs no API key:
from timbro_payments import webhooks
event = webhooks.verify(body=raw_body, headers=request_headers, secret=webhook_secret)
inbox.store(event) # Deduplicate by event["id"], then acknowledge.
if webhooks.is_known_event(event) and event["type"] == "payment.succeeded":
payment = timbro.payments.retrieve(event["data"]["id"])
Pass the raw body bytes and every secret that is valid during a rotation. Events of a type this version does not know verify and return as UnknownWebhookEvent. A failure raises WebhookVerificationError, whose reason names the check.
Invoice webhooks with API version 2026-09-08 are authenticated over the original body, then their retained sourceConnectionId is normalized to integrationId. Current Invoice events use 2026-10-14. The returned apiVersion still identifies the original body; the signature authenticates those original bytes. Mixed identity fields, incompatible versions, and malformed Invoice shapes are rejected. Unrecognized events and unrelated event families retain their existing behavior.
Testing
timbro_payments.testing builds schema-valid resources and signs webhook fixtures. testing.payment(status) and testing.refund(status) fill the members each status carries: a succeeded Payment has capturedPayment allocations that sum to totalAmount and no nextAction, testing.payment(declined=True) carries the lastDecline of a declined attempt, and a declined Refund has its decline. A create must answer with the identity it sent, so a fake create passes it back as an override. testing.api_error(status, code, method="payments.cancel") builds the APIError subclass that method raises for that status. Fake the API by giving the client an httpx.MockTransport:
import json
import httpx
from timbro_payments import TimbroPayments, testing
def handler(request: httpx.Request) -> httpx.Response:
sent = json.loads(request.content)
payment = testing.payment("succeeded", paymentReference=sent["paymentReference"])
return httpx.Response(201, json=payment)
timbro = TimbroPayments(
api_key="sk_test_123",
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
body = b'{"id":"event_...","type":"payment.succeeded"}'
headers = testing.sign_webhook(body, "whsec_...", webhook_id="event_...")
Migrating from 0.10
0.11 is a breaking minor for every Timbro SDK, so upgrade timbro-payments, timbro-invoices, and timbro-protocol together.
- A decline no longer ends a Payment.
declinedis gone from the status values: a declined attempt returns the Payment torequires_customer_actionand setslastDecline(code,message,attemptedAt), which replacesdeclineand dropscanTryAgain. Onlysucceeded,canceled, andexpiredend a Payment. Subscribe topayment.declinedto hear about each declined attempt. invoicing.deliveryEmailon creation is nowinvoicing.recipientEmail, and the Payment reports it back.- Creation accepts
expiresAfterSeconds(300 to 86400) to set the checkout window. - A succeeded Payment carries
refundable; readrefundable.automaticAmountbefore creating a Refund to know whether it runs without an operator.
License
No license has been granted for this package yet. The license will be declared before the first public release.
Metadata
Release files for timbro-payments 0.19.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| timbro_payments-0.19.0.tar.gz | 86.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| timbro_payments-0.19.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 160.0 kB
Release files / timbro_payments-0.19.0.tar.gz
| Download URL | timbro_payments-0.19.0.tar.gz |
|---|---|
| Size | 86.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d75a2cf9f18051fa18bbdb31a67167919fb389c08f3de61e06f1a6cbc586161a
|
|
BLAKE2b-256 checksum How to use checksums |
594935432cb8e04fe49592fc4b901ac20e2a285d98b62392adb5facede612f3c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.4
|
Release files / timbro_payments-0.19.0-py3-none-any.whl
| Download URL | timbro_payments-0.19.0-py3-none-any.whl |
|---|---|
| Size | 73.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
8b39b72407cce9a3206feb04afcc3932ffc75648721cdb499b8e8726e46b2be5
|
|
BLAKE2b-256 checksum How to use checksums |
63aee5881a851151de81b09c39f783770c233be8acda4fd99908cd805b1bed15
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.14.4
|