Skip to main content

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. declined is gone from the status values: a declined attempt returns the Payment to requires_customer_action and sets lastDecline (code, message, attemptedAt), which replaces decline and drops canTryAgain. Only succeeded, canceled, and expired end a Payment. Subscribe to payment.declined to hear about each declined attempt.
  • invoicing.deliveryEmail on creation is now invoicing.recipientEmail, and the Payment reports it back.
  • Creation accepts expiresAfterSeconds (300 to 86400) to set the checkout window.
  • A succeeded Payment carries refundable; read refundable.automaticAmount before 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.20.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for timbro-payments 0.20.0
File Size Uploaded
timbro_payments-0.20.0.tar.gz 87.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for timbro-payments 0.20.0
File Interpreter ABI Platform
timbro_payments-0.20.0-py3-none-any.whl Python 3 none any Details

Total release size: 161.6 kB

Release files / timbro_payments-0.20.0.tar.gz

Download URL timbro_payments-0.20.0.tar.gz
Size 87.5 kB
Tags Source
SHA-256 checksum
How to use checksums
4d59f82ff9e59883f04c1e734ffe6ccb77eb297785b6e72f5d10e537d8dd9acb
BLAKE2b-256 checksum
How to use checksums
2056b70e8fcd29399b05184f3616497bfc231305e0ff437c0ea638b9288b5646
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.20.0-py3-none-any.whl

Download URL timbro_payments-0.20.0-py3-none-any.whl
Size 74.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
87fd9a0cd50cf378b290dd2d80f4e0b337b029519758c7bb3ea19b443cb5c18c
BLAKE2b-256 checksum
How to use checksums
893a6a46fd02334da06d2877f855c084913cae875bd9d9ddf8bb606b92ef98c8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.14.4

Release history Release notifications | RSS feed

This release

0.20.0 This release

2 release files

0.19.1

2 release files

0.19.0

2 release files

0.18.0

2 release files

0.16.0

1 release file

0.14.0

2 release files

0.13.0

1 release file

0.12.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page