Skip to main content

dominaite-python

Server-side Python client for the Dominaite merchant API. One call from your backend opens a hosted checkout session; a two-line script tag renders the payment widget on your page. Card details go straight from your customer's browser into the payment widget - they never touch your server, which keeps your PCI scope minimal (SAQ A).

Python 3.9+, standard library only. No requests, no framework, nothing to vendor.

Install

The package name is dominaite on PyPI (verified free 2026-08-17; matches import dominaite, the same pattern Stripe uses). It is not published yet - until it is, install from a checkout:

pip install /path/to/dominaite-python-sdk
# or, while you are working on the SDK itself:
pip install -e /path/to/dominaite-python-sdk

Credentials

You get two values from the Dominaite dashboard, under Online payments -> Website integration, when you create an API key. The secret is shown once - store both like passwords:

  • dmk_... - your API key id. Identifies you; not secret by itself.
  • dms_... - your API secret. Server-side only: environment variable or a config file outside the web root. Never in a browser, never in git, never in logs.

Every request is signed with the secret (HMAC-SHA256) and timestamped. Keep your server clock on NTP - signatures older than 5 minutes are rejected.

Quickstart against dev

Everything you need to go from nothing to a live session on the dev environment.

1. Set your credentials. Both come from the dashboard's Website-integration tab (dev dashboard, dev key - a prod key will not authenticate against dev):

export DOMINAITE_KEY_ID='dmk_...'          # the key id shown on the tab
export DOMINAITE_SECRET='dms_...'          # the secret shown once at key creation
export DOMINAITE_BASE_URL='https://func-dom-gw-payments-dev-gwc-01.azurewebsites.net/api'

That base URL is the dev payments service. Production is https://api.dominaite.com/payments, which is the SDK's default when you pass no base_url.

base_url has to be https://. Every request carries your key id and a signature, and over plain http anyone on the path can read them and replay the request inside the server's 5 minute window, so the constructor raises ValueError rather than let that happen. The one exception is loopback - localhost, 127.0.0.1 and ::1 may use http://, so a local mock server works.

2. Check your signing before you call anything. This runs offline against the published test vector and authenticates nothing, so it can never fail for credential reasons:

python -m pytest tests/test_signing.py

3. Ping before your first mint. One signed GET that creates nothing, so a failure here is your credentials, your signing or your clock and nothing else:

import os

from dominaite import DominaiteClient

client = DominaiteClient(
    os.environ["DOMINAITE_KEY_ID"],
    os.environ["DOMINAITE_SECRET"],
    base_url=os.environ.get("DOMINAITE_BASE_URL", "https://api.dominaite.com/payments"),
)

print(client.ping())
# {'pong': True, 'merchantId': '...', 'serverTime': '...', 'clockSkewSeconds': 0}

Watch clockSkewSeconds: requests start failing once it passes 300, so a drifting number is your warning to fix NTP before payments break.

4. Mint a session (mint.py):

import os

from dominaite import (
    CheckoutRefusedError,
    DominaiteClient,
    TransportError,
    order_idempotency_key,
)

client = DominaiteClient(
    os.environ["DOMINAITE_KEY_ID"],
    os.environ["DOMINAITE_SECRET"],
    base_url=os.environ.get("DOMINAITE_BASE_URL", "https://api.dominaite.com/payments"),
)

try:
    session = client.create_checkout_session(
        amount=2500,                    # minor units: 2500 = 25.00 EUR
        currency="EUR",
        order_reference="order-1042",   # your own order id, shows up in your dashboard
        customer={
            # Pass everything you already know - prefilled fields are hidden from the
            # payer, so the checkout form stays short.
            "firstName": "Ana",
            "lastName": "Kirova",
            "email": "ana@example.com",
        },
        language="bg",                  # widget UI language
        theme="dark",
        # Required. Same order + same amount = same key, so a reload or a retry gets
        # the session the customer already has instead of a second one.
        idempotency_key=order_idempotency_key("checkout", "1042", 2500, "EUR"),
    )
except CheckoutRefusedError as refusal:
    # Machine-readable: refusal.error_code - see the exception docstring for the codes.
    raise SystemExit("Payment unavailable: " + refusal.error_code)
except TransportError:
    # Network blip - safe to retry with the same idempotency_key.
    raise SystemExit("Payment temporarily unavailable")

print(session["transactionId"], session["cashierKey"], session["cashierToken"])
python mint.py

A transaction id, cashier key and cashier token on stdout means the whole chain works: your credentials, your clock, your signing, and the dev gateway.

If it fails, the error tells you which one:

What you see What is wrong
AuthenticationError + INVALID_API_KEY Wrong or revoked key id, or a prod key against dev.
AuthenticationError + INVALID_SIGNATURE Secret does not match the key id.
AuthenticationError + TIMESTAMP_OUT_OF_RANGE Your machine's clock is more than 5 minutes off.
AuthenticationError + IP_NOT_ALLOWED The key has an IP allowlist that does not include you.
CheckoutRefusedError You authenticated fine; the gateway declined to open a session.
StorefrontError + STOREFRONT_NOT_WHITELISTED Your website's domain is not approved with the payment provider yet. See Storefront errors.
TransportError Wrong base URL, or the service is down. Retry with the same key.

5. Render the widget. Store session["transactionId"] against your order, then hand the two cashier values to the page:

<div id="checkout"></div>
<script src="https://bp-checkout.dominaite.com/v2/launcher"
        data-cashier-key="{{ cashier_key }}"
        data-cashier-token="{{ cashier_token }}"></script>

HTML-escape both when templating (Jinja's autoescape does it for you). They are per-payment session values, not your credentials.

6. Learn the outcome from a webhook. The payer finishing on the widget is not your signal that you got paid - your backend does not see that at all. Point an endpoint at your server, verify the signature, and act on payment.succeeded. See Webhooks below; that is the step that closes the loop, not get_status in a loop.

That's the whole integration: the session call, the script tag, the webhook, and your domain bound to your checkout by Dominaite during onboarding.

Amounts are minor units

amount is always an integer in the currency's minor unit: 2500 is 25.00 EUR. A float or a string raises ValueError before anything is sent. The amount is locked server-side - what you pass here is what gets charged; nothing in the browser can change it.

Not every currency has two decimals. The minor unit is set by the gateway's currency registry, which mostly matches ISO 4217 but not always:

Decimals Currencies 2500 means
2 EUR, USD, GBP, CAD, AUD, CHF, BGN, RON, PLN, CZK, SEK, DKK, NOK 25.00
0 JPY, HUF 2500
3 BHD, KWD 2.500

HUF is whole forints. ISO 4217 gives HUF two decimals, the gateway uses none: 2500 HUF is 2500 Ft. Sending 250000 for 2500 Ft would charge 100 times too much.

ISK, KRW, OMR, JOD and TND are not supported: ISO and the gateway disagree on their decimals, so to_minor_units raises for them rather than produce an amount that is off by 10x or 100x.

to_minor_units converts a price for you, without float math:

from decimal import Decimal

from dominaite import to_minor_units

to_minor_units("25.00", "EUR")            # 2500
to_minor_units("2500", "JPY")             # 2500
to_minor_units("2500", "HUF")             # 2500, whole forints
to_minor_units("2.5", "BHD")              # 2500
to_minor_units(Decimal("0.30"), "EUR")    # 30, where 0.1 + 0.2 as floats is 0.30000000000000004

It takes a decimal string or a Decimal, never a float. It rounds nothing: more decimal places than the currency has ("25.001" EUR, "100.5" JPY, and also "25.000" EUR) raises ValueError, so quantize values from a wider column first. A currency it does not know raises too, instead of assuming two decimals; the known ones are in CURRENCY_EXPONENTS and the refused ones in UNSUPPORTED_CURRENCIES.

Retries and double-charges

Every create_checkout_session call needs an idempotency_key. There is no random default: leaving it out raises ValueError before anything is sent. Retrying with the same key never charges twice - on a timeout, retry with the same key rather than generating a new one.

Build the key from the order with order_idempotency_key:

from dominaite import order_idempotency_key

key = order_idempotency_key("checkout", order.id, order.total_minor, order.currency)
# "checkout-1042-2500-EUR"

The key is {scope}-{order_id}-{amount_minor}-{CURRENCY}. That shape is the point:

  • Same order, same amount, same key. A reload, a back button or a retry after a timeout rebuilds the identical key, so the gateway hands back the session the customer already has (or tells you the order is paid) instead of opening a second one.
  • Changed amount or currency, new key. A coupon or an edited basket produces a different key and a fresh session. Reusing the old key with a new amount would be refused with IDEMPOTENCY_KEY_REUSED.
  • Scope keeps different flows on the same order apart ("checkout", "deposit", a billing period for recurring charges).

The helper applies the same rules as any key (see Field lengths) and raises ValueError on a bad part rather than building a key the API would refuse.

charge_payment_method needs a key the same way; derive it from the billing period.

If the first attempt did land and its session is still open, the retry returns that same session: same transaction id, same cashier values. Otherwise it comes back as a CheckoutRefusedError with a replay code (ALREADY_PROCESSED once it is paid, PRIOR_ATTEMPT_FAILED, DUPLICATE_REQUEST, or IDEMPOTENCY_KEY_REUSED for a different amount). Use refusal.transaction_id with get_status() to find out what the first attempt did (see Recovering from a replay refusal).

There is a helper that retries with the same key for you:

session = client.create_checkout_session_with_retry(
    amount=2500,
    currency="EUR",
    order_reference="order-1042",
    idempotency_key=order_idempotency_key("checkout", "1042", 2500, "EUR"),
    max_attempts=3,
)

It retries TransportError (network failures, any 5xx, including a 503 carrying MERCHANT_API_UNAVAILABLE or PAYMENT_PROCESSING_UNAVAILABLE) and the PAYMENT_PROCESSING_UNAVAILABLE refusal (card payments briefly off, nothing charged). It sends your one key on every attempt and backs off between them. Every other refusal, storefront errors, authentication failures and rate limits are raised immediately. If processing stays unavailable past the last attempt you get the CheckoutRefusedError; retry later with the same key, and give up after about fifteen minutes.

Sessions expire

A session is valid for 2 hours. If the payer comes back later, re-POST with the same idempotency key: once the session is a few minutes past expiry, that returns a fresh session for the same order (see Recovering from a replay refusal).

Stored payment methods (recurring)

Pass save_card=True when you create a session and, once that payment is approved, the gateway keeps the card on file. You never see the card number or the provider token: get_status() returns a storedPaymentMethod with an opaque id (pm_ + 32 hex characters), the brand, the last4 and the expiry, and that id is what you charge and revoke with. Store it against your customer. (paymentMethod on the same status is something else: the gateway's string category of how the payer paid, card, wallet and so on.)

from dominaite import ChargeError, ChargeStatus, DeclineClass, RevokeError, StoredPaymentMethodStatus

session = client.create_checkout_session(
    amount=2500,
    currency="EUR",
    order_reference="sub-8817-first",
    save_card=True,
    idempotency_key=order_idempotency_key("sub-first", "8817", 2500, "EUR"),
)
# ... the payer completes the hosted checkout ...
status = client.get_status(session["transactionId"])
stored = status.get("storedPaymentMethod")
if status["status"] == "succeeded" and stored and stored["status"] == StoredPaymentMethodStatus.ACTIVE:
    db.save_card(customer_id, stored["id"])  # pm_...

# Later, off-session, no payer present:
try:
    charge = client.charge_payment_method(
        payment_method_id,
        amount=2500,
        currency="EUR",
        order_reference="sub-8817-2026-10",
        description="Monthly plan, October",
        idempotency_key="sub-8817-2026-10",  # derive it from the billing period, never random per attempt
    )
except ChargeError as error:
    if error.error_code == "CHARGE_OUTCOME_UNKNOWN":
        # 502: the provider gave no verdict, the charge MAY have happened. Never retry
        # under a new key: poll the transaction the gateway attached instead.
        poll_until_settled(error.transaction_id)
    elif error.error_code in ("DUPLICATE_REQUEST", "PAYMENT_METHOD_CHARGES_DISABLED", "PAYMENT_PROCESSING_UNAVAILABLE"):
        ...  # nothing was charged; retry later with the SAME idempotency key
    elif error.error_code == "PAYMENT_METHOD_NOT_ACTIVE":
        ...  # revoked, expired or retired: bring the customer back for a hosted session with save_card
    elif error.error_code == "CHARGE_FAILED":
        ...  # 502, nothing was charged; error.charge is set when a row exists
    elif error.error_code == "IDEMPOTENCY_KEY_REUSED":
        ...  # same key, different body or method: a bug on your side
    raise
else:
    if charge["status"] == ChargeStatus.SUCCEEDED:
        ...
    elif charge["status"] == ChargeStatus.PENDING:
        ...  # not terminal: poll get_status(charge["transactionId"]) or wait for the webhook
    elif charge["status"] == ChargeStatus.FAILED:
        # HTTP 402 from the gateway, but not an exception: branch on the class, log the code.
        # DeclineClass.HARD              - give up on this card, ask the customer for another one
        # DeclineClass.SOFT_FUNDS        - insufficient funds, retry later (not in a loop)
        # DeclineClass.SOFT_SCA_REQUIRED - the issuer wants the customer present: send them
        #                                  through a hosted session with save_card and charge the new method
        # DeclineClass.SOFT_OTHER        - transient, one retry later is reasonable
        handle_decline(charge["declineClass"], charge["declineCode"])
    elif charge["status"] == ChargeStatus.CANCELLED:
        ...  # an authorization voided before capture; no money moved

# When the customer removes the card:
try:
    client.revoke_payment_method(payment_method_id)  # 204, returns None; 204 again if already revoked
except RevokeError as error:
    if error.error_code == "MERCHANT_API_UNAVAILABLE":
        ...  # 503: nothing changed, retry later
    else:
        ...  # 502 UPSTREAM_CONTRACT_ERROR: the provider refused for good, nothing changed; contact support with the id

A charge is signed exactly like a session and carries an Idempotency-Key, so a retry after a timeout with the same key never charges the card twice: the gateway replays its first answer, HTTP status included. The HTTP status is the contract on this route: 201 (or 200 on a replay) returns the charge, 402 returns the charge too (status failed plus declineClass), and 409, 422, 502 and 503 raise ChargeError with error_code, http_status, the gateway's message and, when the gateway attached the charge row, charge and transaction_id. Only authentication (401/403), an id that is not yours (404, ApiError), validation (400, ApiError), rate limiting (429) and network failures keep their generic exceptions. declineClass and declineCode are None unless the charge was declined; the gateway omits them on the wire and the SDK reads absent as None.

Revoking signs an empty key and an empty body, like get_status(). A revoke that fails with RevokeError changed nothing: MERCHANT_API_UNAVAILABLE (503) is retryable, UPSTREAM_CONTRACT_ERROR (502) is not. After a revoke the status read keeps the storedPaymentMethod with status revoked, and a charge against it is refused with PAYMENT_METHOD_NOT_ACTIVE.

Webhooks

Webhooks are how you find out what happened to a payment. Create an endpoint in the Dominaite dashboard, pick the events you care about, and store the whsec_... secret it shows you - like the API secret, it is shown once.

Dominaite POSTs each event to your URL with an X-Webhook-Signature header:

X-Webhook-Signature: t=1755700000,v1=5305bcf1302fdaba8f8c19a20c899e916fb4d2a7d8d547c62529ff87c4697b72

verify_webhook checks it and hands you the decoded event:

import os

from flask import Flask, request

from dominaite import WebhookVerificationError, verify_webhook

app = Flask(__name__)
SECRET = os.environ["DOMINAITE_WEBHOOK_SECRET"]


@app.post("/webhooks/dominaite")
def dominaite_webhook():
    try:
        event = verify_webhook(
            request.get_data(),                          # RAW body, not request.json
            request.headers.get("X-Webhook-Signature", ""),
            SECRET,
        )
    except WebhookVerificationError:
        return "", 400

    if already_handled(event["id"]):                     # delivery is at-least-once
        return "", 200

    enqueue(event)                                       # do the work outside the request
    return "", 200

Pass the raw body. request.json (or any parse-then-re-serialize round trip) gives you different bytes than the ones that were signed, and verification will fail. This is the single most common webhook integration bug.

Verify before you read anything. Until verify_webhook returns, the body is just bytes a stranger POSTed at you. Do not branch on event["type"] or trust an amount from an unverified payload.

The events

payment.succeeded, payment.failed, payment.requires_capture, payment.cancelled, payment.abandoned, payment.refunded, payment.disputed.

payment.succeeded is the only one that means money in hand. requires_capture is an approved hold, not a payment. pending and processing are never webhooked - if you want to show an in-flight state to a customer, poll the session.

Each delivery is a flat JSON object - there is no success wrapper, so do not branch on one:

{
  "id": "7f9c24e5-1d1f-4c0a-9b6c-2f3a4d5e6f70",
  "type": "payment.succeeded",
  "createdAt": "2026-08-20T14:00:00Z",
  "data": {
    "transactionId": "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0",
    "status": "succeeded",
    "previousStatus": "pending",
    "kind": "sale",
    "amount": 8440,
    "grossAmount": 8701,
    "surchargeAmount": 261,
    "currency": "EUR",
    "originalTransactionId": null,
    "idempotencyKey": "order-123"
  }
}

Amounts are minor units. On payment.* events amount is what you get paid and grossAmount is what moved on the card; on payment.refunded amount is what went back to the customer.

Delivery, retries, and staying enabled

  • At-least-once. The same event can arrive twice. Dedupe on event["id"] and make your handler idempotent.
  • Answer fast. Return 2xx as soon as the signature checks out and queue the real work. Doing it inline is how endpoints end up timing out and getting retried.
  • Retries follow your endpoint's configured count (default 3, max 10), spaced 1m, 5m, 30m, 2h, 12h.
  • Circuit breaker. An endpoint whose first attempt and every retry fail, over and over, is disabled automatically. Any later successful delivery re-enables it. A disable you did yourself in the dashboard is never undone for you.

Reconcile anyway

Webhooks complement your reconciliation sweep, they do not replace it. There are real loss windows: a chain parked on an endpoint the breaker disabled, an event that never got published. The only thing that closes them is a periodic sweep of your own open orders against get_status. Keep the sweep. It is what catches the payment nobody told you about.

Tolerance and clocks

Deliveries more than 300 seconds away from your clock are rejected as replays. If you see TIMESTAMP_OUT_OF_RANGE on genuine traffic, your server clock has drifted - fix NTP rather than widening tolerance_seconds.

Pass now to keep your own tests deterministic, and sign_webhook to forge a delivery in them:

from dominaite import sign_webhook, verify_webhook

body = '{"id":"evt-1","type":"payment.succeeded","createdAt":"2026-08-20T14:00:00Z","data":{}}'
header = "t=1755700000,v1=" + sign_webhook("whsec_test", "1755700000", body)

event = verify_webhook(body, header, "whsec_test", now=1755700000)

Status polling (fallback)

Polling is the fallback and the reconciliation tool, not the primary path - use webhooks to learn that a payment completed, and use this to sweep your own open orders and to answer "what is this order doing right now".

status = client.get_status(session["transactionId"])
# {"transactionId": ..., "orderReference": "order-1042", "status": "succeeded",
#  "amount": 2500, "currency": "EUR", ...}

status is one of: pending, processing, succeeded, failed, refunded, partially_refunded, cancelled, disputed, requires_capture, abandoned. While the session is still payable the response also carries expiresAt; after that instant a pending session can only become abandoned. An unknown transaction id raises ApiError with http_status == 404.

Those values are also exported as the PaymentStatus enum (and PAYMENT_STATUSES), so you can match on a named member instead of a bare string literal. It subclasses str, so status["status"] == PaymentStatus.SUCCEEDED works directly against what get_status returns.

succeeded is the only value that means the payment is complete. Keep polling on pending, processing and requires_capture - none of them is terminal.

requires_capture is not "unpaid": the payer has already paid and the funds are held awaiting capture. Never treat it as an abandoned order.

Treat any status you do not recognise as still-open as well: a value the API adds later should make you keep polling, never silently close an order that is still live.

Two helpers encode those rules so you do not have to:

from dominaite import is_paid, is_terminal

status = client.get_status(transaction_id)["status"]
if is_paid(status):        # succeeded, and nothing else
    ship(order)
elif is_terminal(status):  # failed, cancelled, abandoned, refunded, partially_refunded
    close(order)
# else: pending, processing, requires_capture, disputed or unknown - keep the order open

is_terminal is also True for succeeded, so check is_paid first. The terminal set is exported as TERMINAL_PAYMENT_STATUSES.

Poll after the payer returns to you, or on your order timeout - not in a tight loop; the endpoint is rate limited per key.

Recovering from a replay refusal

When your idempotency key collides with an earlier attempt, the refusal names the transaction it collided with, so you can reconcile instead of minting a second payment:

try:
    session = client.create_checkout_session(...)
except CheckoutRefusedError as refusal:
    if refusal.transaction_id:
        status = client.get_status(refusal.transaction_id)
        # Now you know what the earlier attempt actually did.

refusal.transaction_id is None when the API did not name one (a concurrent-race DUPLICATE_REQUEST knows the key is taken but not yet by which row), so check it before use. The full refusal payload is on refusal.result.

DUPLICATE_REQUEST means a session for this key is open, or expired within the last few minutes. Either way the move is the same: re-POST the same key shortly, never a fresh one.

One replay is not a refusal at all. A session that expired unpaid is superseded: from a few minutes past its expiry, re-POSTing the same key returns an ordinary success with a fresh session (new transaction id, same key), so a customer who comes back late just pays. Keep the order-derived key for the life of the order to keep that path open. The band is not endless - once the platform has independently closed the attempt (about an hour past expiry), the replay answers PRIOR_ATTEMPT_FAILED and the key is spent; reconcile and use a fresh key.

Errors

Exception Means Retry?
AuthenticationError Bad credentials, bad signature, clock skew, IP not allowlisted No - fix config
CheckoutRefusedError The gateway refused to open the session (error_code) Depends on the code
StorefrontError The storefront (website) cannot take payments yet, or the key belongs to another one (error_code, http_status). Subclass of ApiError No - fix the setup
ChargeError The gateway answered a charge with a code instead of a charge (error_code, http_status, charge, transaction_id) Depends on the code; never with a new key
RevokeError The gateway refused to revoke a stored payment method; nothing changed (error_code, http_status) MERCHANT_API_UNAVAILABLE only
ApiError Unexpected response, or a 4xx like an unknown transaction id (http_status, error_code) No
RateLimitError HTTP 429; you are sending faster than the key is allowed (retry_after_seconds) Yes, after you wait
TransportError Network failure or 5xx; you don't know if it landed Yes, same idempotency key
WebhookVerificationError An incoming webhook is not authentic or not fresh (error_code) No - respond 400

All of them inherit from DominaiteError if you only care that the call failed.

Note the two different failure shapes on the create endpoint. A business refusal is HTTP 200 with success: false and raises CheckoutRefusedError; input validation is HTTP 400 and raises ApiError with the code on error_code (currently IDEMPOTENCY_KEY_REQUIRED, exported as VALIDATION_ERROR_CODES). Branch on the exception type, never on the HTTP status.

Every code in these tables is also a named constant on ErrorCode, a str enum, so error.error_code == ErrorCode.ALREADY_PROCESSED works against the plain string:

from dominaite import CheckoutRefusedError, ErrorCode

try:
    session = client.create_checkout_session(...)
except CheckoutRefusedError as refusal:
    if refusal.error_code == ErrorCode.ALREADY_PROCESSED:
        ...  # this order is paid; show the receipt

Storefront errors

If your merchant account has more than one website (storefront), each session is tied to one of them, and the gateway checks that storefront before it opens anything. These refusals are not the 200 success: false shape: they arrive as StorefrontError (a subclass of ApiError), with the code on error_code and the HTTP status on http_status. Nothing was created.

error_code HTTP Means What to do
STOREFRONT_NOT_WHITELISTED 409 The storefront's domain is not approved by the payment provider yet. Nothing in your code. Ask Dominaite support to finish the domain whitelisting, then try again.
STOREFRONT_INACTIVE 409 The storefront was deactivated or deleted. Use an API key for an active storefront, or ask support to reactivate it.
STOREFRONT_MISMATCH 400 The API key is bound to a different storefront than the request names. Use the key issued for this storefront.
from dominaite import ErrorCode, StorefrontError

try:
    session = client.create_checkout_session(...)
except StorefrontError as error:
    if error.error_code == ErrorCode.STOREFRONT_NOT_WHITELISTED:
        alert_ops("checkout blocked: domain not whitelisted yet")
    raise

An idempotency key first used on another storefront is also refused with STOREFRONT_MISMATCH, but in the 200 shape, so that one is a CheckoutRefusedError. Match on error_code if you want to handle both. The codes are exported as STOREFRONT_ERROR_CODES.

Webhook verification codes

WebhookVerificationError.error_code is one of MALFORMED_SIGNATURE (wrong header, or a proxy rewrote it), INVALID_SIGNATURE (wrong secret, modified body, or you passed a re-serialized body instead of the raw one), TIMESTAMP_OUT_OF_RANGE (replay, or your clock drifted), and INVALID_PAYLOAD (signed, but not a JSON object).

Rate limits

60 requests per minute per API key, and 120 per minute per IP address. Both are sliding windows, and the IP limit is shared by every key sending from that address, so a busy host can trip it while each key is well inside its own budget.

Going over gets you a RateLimitError. The SDK does not retry it for you, and neither does create_checkout_session_with_retry - answering "you are sending too much" with more traffic is how a short spike turns into a sustained lockout. Wait, then send again:

from dominaite import RateLimitError

try:
    session = client.create_checkout_session(...)
except RateLimitError as limit:
    time.sleep(limit.retry_after_seconds or 60)

retry_after_seconds is what the API asked you to wait, and honouring it is the shortest wait that will work. It is None when the API did not give a number of seconds - back off on your own schedule then.

The usual cause is polling get_status in a tight loop. Poll after the payer returns to you, or on your order timeout, and let webhooks do the rest.

Field lengths

order_reference and idempotency_key are capped at 100 characters each. For order_reference that is characters, not bytes: a 100-character Cyrillic or Greek reference is 200 UTF-8 bytes and the platform takes it.

idempotency_key travels as an HTTP header and is part of the signature, so it is limited to visible ASCII: letters, digits and punctuation, no spaces, no accented or non-Latin letters. Anything else raises ValueError before the request is sent. If your order ids are not ASCII, derive the key from something that is (your numeric order id, or a hash of the reference).

Running the tests

python -m venv .venv && .venv/bin/pip install pytest
.venv/bin/python -m pytest

tests/test_signing.py reproduces the signing test vector published on the dashboard's Website-integration tab. If it ever fails, the SDK cannot authenticate - fix the signing, never the expected value.

tests/test_webhooks.py pins the cross-SDK webhook vector: the same secret, timestamp and body bytes every Dominaite SDK verifies against. A failure there means the SDK is rejecting genuine deliveries or accepting forged ones. Same rule - fix the code, not the vector.

Release files for dominaite 0.3.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 dominaite 0.3.0
File Size Uploaded
dominaite-0.3.0.tar.gz 80.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for dominaite 0.3.0
File Interpreter ABI Platform
dominaite-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 121.8 kB

Release files / dominaite-0.3.0.tar.gz

Download URL dominaite-0.3.0.tar.gz
Size 80.0 kB
Tags Source
SHA-256 checksum
How to use checksums
5623b63e1da82314d0e3a25afb23d842542dc8bd04089f92f1ca6cef47fc63a7
BLAKE2b-256 checksum
How to use checksums
d8dff679cf9e691047c163f5247d1c2b9d499ee9fd6a9891bf0b33f21c8cf889
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.

Transparency log

Release files / dominaite-0.3.0-py3-none-any.whl

Download URL dominaite-0.3.0-py3-none-any.whl
Size 41.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8fc34af92a433135126ff6192b2326e1754a1d031fa4066d240dd65451021988
BLAKE2b-256 checksum
How to use checksums
0b039d67ea533da8426c6543c7291734164724cb22cd57cdb881f74a91bce907
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

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