Python library
The payitgo package wraps the payitgo API for Python: payments, tokens, your
webhook (IPN) and its signature check, and the public checkout calls. It uses
only the standard library. Use it on your server only: it holds your secret
key.
Responses are dicts with the API's own keys (order_id, checkout_url, …),
so the Payments, Webhooks and
Checkout pages describe every field this library returns.
The client is synchronous: each call blocks until the answer arrives.
Install
pip install payitgo
The package is published as payitgo and imported as payitgo:
import payitgo
| Requirement | |
|---|---|
| Python | 3.8 or later |
| Dependencies | None (urllib, json, hmac from the standard library) |
Quickstart
import os
import payitgo
client = payitgo.Client(os.environ["PAYITGO_KEY"], base_url="https://api.payitgo.co")
payment = client.payments.create(order_id="ORD-1001", amount_usd="49.90", token="USDT_TRC20")
print(payment.crypto_amount, payment.token, "to", payment.deposit_address)
Then set your webhook URL and fulfil the order when
payment.confirmed arrives.
Configuration
payitgo.Client
payitgo.Client(api_key, base_url=None, timeout=30, max_retries=2, webhook_secret=None, webhook_url=None)
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
api_key |
str | yes | Your secret key, test or live (Authentication). An empty key raises ValueError |
|
base_url |
str | no | PAYITGO_BASE_URL, then the address the package was released with |
The gateway's address, https://api.payitgo.co. Trailing slashes are removed |
timeout |
int or float | no | 30 |
Seconds one attempt may wait for the server (connecting, and each read). A timeout counts as a network error, so it is retried |
max_retries |
int | no | 2 |
How many times a request is retried after a network error, a 429 or a 5xx. 0 turns retries off |
webhook_secret |
str | no | None |
Your mode's signing secret. webhooks.construct_event uses it when you pass none |
webhook_url |
str | no | None |
Sent as webhook_url with every payments.create that does not pass its own |
The constructor makes no request. It raises ValueError when:
| Message | Cause |
|---|---|
payitgo: the API key is required |
api_key is missing, not a string, or empty |
payitgo: set base_url (or PAYITGO_BASE_URL) to your gateway's address |
No base_url, no PAYITGO_BASE_URL, and no address built into the package |
client = payitgo.Client(
os.environ["PAYITGO_KEY"],
base_url="https://api.payitgo.co", # or set PAYITGO_BASE_URL
timeout=15,
max_retries=2,
webhook_secret=os.environ["PAYITGO_WEBHOOK_SECRET"], # the signing secret of the key's mode
webhook_url="https://acme.example/payitgo/ipn", # optional default for payments.create
)
The client keeps no per-request state: create one and share it, across threads too.
Attributes
| Attribute | Type | Description |
|---|---|---|
payments |
object | Payments |
tokens |
object | Tokens |
webhooks |
object | Webhooks |
checkout |
object | Checkout |
The module also has payitgo.__version__ ("0.1.0", sent in the
User-Agent: payitgo-python/0.1.0 header), payitgo.WEBHOOK_EVENTS (the
event types), payitgo.SIMULATION_OUTCOMES (the outcomes
payments.simulate takes), and the classes Client, Webhook, PayitgoError,
SignatureVerificationError and PayitgoObject.
is_live
GET /v1/webhook · API reference
client.is_live() -> bool
Returns True for a live key, False for a test key. A key does not say
its mode, so the first call asks payitgo (the livemode of the key's
webhook; a read-only key may call it) and the answer is kept
for the client's life: later calls make no request.
Errors PayitgoError, as any call: 401 for a key payitgo
does not know, 403 for a live key while live mode is off. An error is not
kept: the next call asks again.
if not client.is_live():
print("test mode: payments are simulated")
Base URL
The gateway address is taken from, in order: the base_url argument, the
PAYITGO_BASE_URL environment variable, and the address the package was
released with:
export PAYITGO_BASE_URL=https://api.payitgo.co
Test and live mode
The key decides the mode. A test key creates and reads test payments
(no coins: you simulate what the customer sends) and
manages the test webhook; a live key does the same for live ones. The key
does not say which it is: the dashboard shows a Test or Live badge next to
it, and client.is_live() asks payitgo. Pair each key with the
webhook secret of the same mode. See Test mode.
HTTP client
Requests go through urllib.request from the standard library, with the
headers Authorization: Bearer <key>, Accept: application/json,
Content-Type: application/json (when there is a body) and
User-Agent: payitgo-python/0.1.0. The standard proxy variables
(HTTPS_PROXY, NO_PROXY) apply. There is no argument to pass your own HTTP
client or session.
Errors
PayitgoError
Every call that reaches the API raises payitgo.PayitgoError when the request
fails.
class PayitgoError(Exception):
message: str
status: int
| Attribute | Type | Description |
|---|---|---|
status |
int | The HTTP status, or 0 when no answer came (network error or timeout, after the retries) |
message |
str | The API's error message, such as minimum payment in USDT_TRC20 is $6.44 right now (network fees). The HTTP reason phrase (such as Bad Gateway) when the answer had no message. request failed: <cause> for a network error. str(e) is the same |
Branch on status; log message (its wording may change, see
Errors and limits).
status |
Meaning | Retried by the library |
|---|---|---|
0 |
No answer: DNS, connection, TLS or timeout | Yes |
400 |
The request is invalid (the message says which field) | No |
401 |
missing api key or invalid api key |
No |
403 |
Not allowed, for example a live key while live mode is off, or a storefront key changing the account's webhook | No |
404 |
No such payment in the key's mode | No |
409 |
Conflicts with the current state, such as choosing a payment's coin twice, or simulating a payment whose simulation is running | No |
429 |
Over the rate limit | Yes |
500 and other 5xx |
Something failed on payitgo's side | Yes |
try:
client.payments.create(order_id="ORD-1", amount_usd=1, token="BTC")
except payitgo.PayitgoError as e:
if e.status == 400:
log.warning("payitgo refused: %s", e.message) # e.g. an amount under the coin's minimum
else:
raise
SignatureVerificationError
construct_event raises
payitgo.SignatureVerificationError (a subclass of Exception) when a
notification cannot be trusted. Answer 400. str(e) says why:
| Message | Cause |
|---|---|
missing or malformed X-Webhook-Signature header |
No header, or no t or v1 in it |
signature does not match |
The body, the timestamp or the secret differ from what payitgo signed (a v1 that is not 64 hex characters included) |
timestamp outside the tolerance |
t is further than the tolerance (5 minutes) from your clock |
malformed timestamp |
t is not a number (checked unless the tolerance is 0) |
no webhook secret: pass one, or set the client's webhook_secret |
The client method got no secret and the client has no webhook_secret |
A body that is correctly signed but is not JSON raises ValueError
(json.JSONDecodeError); payitgo never sends one.
Retries
A request is retried, up to max_retries times (default 2, so at most 3
attempts), after a network error or timeout, a 429 or a 5xx. It waits
0.5 s before the first retry and 1 s before the second. Other statuses are
never retried. When the last attempt fails, the call raises a PayitgoError
with that attempt's status (0, 429 or 5xx).
Every call is retried the same way, and each is safe to repeat:
| Call | Repeating it |
|---|---|
payments.create |
Returns the order's open payment instead of a second one (safe retries) |
payments.select_token, checkout.select_token |
If the first attempt went through, the repeat raises 409: read the payment with retrieve, it has its coin |
payments.simulate |
If the first attempt went through, the repeat raises 409 while that simulation runs: read the payment with retrieve |
webhooks.set, webhooks.remove |
Changes nothing the second time |
webhooks.test |
Queues one more ping |
| Reads | Change nothing |
Payments
payments.create
POST /v1/payments · API reference
client.payments.create(order_id, amount_usd=None, token=None, webhook_url=None, *,
price_amount=None, price_currency=None, **optional) -> PayitgoObject
Creates a payment for one of your orders. With a token, the payment comes
back with its deposit address, amount, QR payload and a 15-minute deadline.
Without one, it comes back with token_options and the customer (or your
page, with select_token) chooses the coin. While the
order has an open payment, creating it again returns that payment.
| Parameter | Type | Required | Description |
|---|---|---|---|
order_id |
str | yes | Your id for the order, 1 to 128 bytes. One open payment per order and mode |
amount_usd |
str, Decimal or int |
yes, unless price_amount + price_currency |
The price in US dollars, such as "49.90". Sent as str(amount_usd): prefer a string or a Decimal to a float |
token |
str | no | The coin, such as "USDT_TRC20" (from tokens.list). Leave it out to let the customer choose |
webhook_url |
str | no | https:// URL that gets this payment's notifications instead of your account's webhook URL (per-payment URL). None: the client's webhook_url. "": none for this payment, even with a client default |
price_amount |
str, Decimal or int |
with price_currency |
Keyword only. Instead of amount_usd: the price in another currency, such as "45.50", which payitgo converts into dollars at the rate of the moment (prices in other currencies). Sent as str(price_amount) |
price_currency |
str | with price_amount |
Keyword only. The ISO 4217 code of its currency, such as "EUR", "GBP" or "NGN" (any case) |
tokens |
list of str | no | Keyword only. Without token: the coins to offer, such as ["USDT_TRC20", "USDT_BEP20"]. Default: every coin you accept whose minimum the amount reaches |
description |
str | no | Keyword only. What the customer pays for, up to 500 bytes |
metadata |
dict of str to str | no | Keyword only. Your own data: up to 20 keys of up to 40 bytes, values of up to 500 |
customer_email |
str | no | Keyword only. The customer's email address |
success_url |
str | no | Keyword only. http(s) URL the hosted checkout links to once the payment is confirmed |
cancel_url |
str | no | Keyword only. http(s) URL the hosted checkout links back to if the payment closes unpaid |
simulate |
str | no | Keyword only. Test keys only: the outcome to simulate, one of payitgo.SIMULATION_OUTCOMES. With token it starts at once; without, once the coin is chosen |
Keyword arguments whose value is None are not sent. Any other keyword is sent
to the API as it is (the API ignores fields it does not know).
Returns the Payment: the new one (201) or the order's open
one (200); both look the same. Fields: the payment
object.
Errors PayitgoError with status 400 for an invalid request: a missing
order_id, amount_usd must be > 0, a token you do not accept, an amount
under the token's minimum, a webhook_url that is not public https, a
simulate with a live key or an unknown outcome, a price in another
currency given with amount_usd, without its pair or in a code that is no
currency, and the rest of the create errors. 503
when no exchange rate can be had for price_currency (after the
retries): try again shortly. Also 401, 403, 429,
500 and 0 as for every call.
# The customer pays in a coin you know:
payment = client.payments.create(
order_id="ORD-1001",
amount_usd="49.90",
token="USDT_TRC20",
description="2 x Acme T-shirt",
metadata={"customer_id": "cus_42"},
customer_email="jane@example.com",
success_url="https://acme.example/thanks",
cancel_url="https://acme.example/cart",
)
# payment.deposit_address, payment.crypto_amount, payment.qr_payload, payment.expires_at
# The customer chooses the coin (on the hosted page, or yours):
open_payment = client.payments.create(order_id="ORD-1002", amount_usd=25)
for option in open_payment.get("token_options", []):
print(option.token, option.min_usd, option.get("amount_due_usd", ""))
# This payment's notifications go to its own URL:
client.payments.create(
order_id="ORD-1003",
amount_usd=30,
token="USDT_TRC20",
webhook_url="https://acme.example/orders/ORD-1003/ipn",
)
# Priced in euros: converted into dollars at the rate of the moment.
priced = client.payments.create(order_id="ORD-1004", price_amount="45.50", price_currency="EUR")
# priced.price_amount "45.5", priced.price_currency "EUR", priced.price_rate "1.082", priced.usd_amount "49.23"
payments.retrieve
GET /v1/payments/{id} · API reference
client.payments.retrieve(id) -> PayitgoObject
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
str | yes | The payment's id, or your order_id for the order's open payment (the payment id is tried first). Escaped for the URL by the library |
Returns the Payment.
Errors PayitgoError 404 (payment not found) for an unknown id, a payment
of the other mode, or an order with no open payment. Plus the
common statuses.
by_id = client.payments.retrieve("8f3a2c1e9b7d4a6f05e1c2d3")
by_order = client.payments.retrieve("ORD-1001") # the order's open payment
if by_id.status == "confirmed":
print("paid at", by_id.confirmed_at)
payments.list
GET /v1/payments · API reference
client.payments.list(status=None, chain=None, token=None, limit=None, offset=None, store_id=None) -> PayitgoObject
Payments of the key's mode, newest first.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
status |
str | no | all | Only payments in this status, such as "confirmed" |
chain |
str | no | all | Only payments on this network, such as "tron" |
token |
str | no | all | Only payments in this coin, such as "USDT_TRC20" |
limit |
int | no | 50 |
Page size, 1 to 200 (outside that range the API uses 50) |
offset |
int | no | 0 |
How many to skip |
store_id |
str | no | all | Only payments of this store (ignored for a storefront key, which sees its own) |
Arguments left at None are not sent.
Returns a Payment list: payments, total (matches
across all pages), and the limit and offset applied.
Errors the common statuses only. An unknown status, chain or token matches nothing.
# Every confirmed payment, page by page:
confirmed, offset = [], 0
while True:
page = client.payments.list(status="confirmed", limit=100, offset=offset)
confirmed.extend(page.payments)
offset += page.limit
if offset >= page.total:
break
payments.select_token
POST /v1/payments/{id}/token · API reference
client.payments.select_token(payment_id, token) -> PayitgoObject
Sets the coin of a payment created without one, once the customer picked it on your own page. The coin can be set once.
| Parameter | Type | Required | Description |
|---|---|---|---|
payment_id |
str | yes | The payment's id or your order_id |
token |
str | yes | One of the payment's token_options[].token, such as "USDT_TRC20" |
Returns the whole Payment, now with deposit_address,
crypto_amount, rate_locked, qr_payload and an expires_at 15 minutes
away.
Errors PayitgoError:
status |
message |
Cause |
|---|---|---|
400 |
token is required |
Empty token |
400 |
token is not one of this payment's options |
A coin not in token_options |
400 |
minimum payment in … is $… right now (it is converted on its way to the merchant, and the exchange takes no less) |
The coin needs a bigger payment than this one: it is converted through the exchange on its way to the merchant, which takes no less, and the merchant pays the fees or hides such coins (hide_below_minimum). Offer the other coins |
400 |
this payment can no longer be paid |
The payment is no longer pending, or its hour to choose has passed |
404 |
payment not found |
No such payment in the key's mode |
409 |
the coin for this payment has already been chosen |
The coin was set before (by you or on the hosted page) |
payment = client.payments.create(order_id="ORD-1002", amount_usd=25)
# …your page lists payment.token_options, the customer picks one…
chosen = client.payments.select_token(payment.id, "USDT_BEP20")
print(chosen.crypto_amount, chosen.token, chosen.deposit_address, chosen.expires_at)
payments.simulate
POST /v1/payments/{id}/simulate · API reference
client.payments.simulate(payment_id, outcome) -> PayitgoObject
Plays an outcome on a test payment: payitgo sends its deposits, status changes and webhooks about 5 seconds apart, as live coins would, with no coins (Test mode). Test keys only.
| Parameter | Type | Required | Description |
|---|---|---|---|
payment_id |
str | yes | The payment's id or your order_id |
outcome |
str | yes | One of payitgo.SIMULATION_OUTCOMES, such as "confirmed" (what each does) |
Returns the whole Payment, with its simulation started.
Simulated deposits have simulated True; their tx_id names no transaction on any chain.
Errors PayitgoError:
status |
message |
Cause |
|---|---|---|
400 |
outcome is required |
Empty outcome |
400 |
simulate must be one of: confirmed, overpaid, … |
An unknown outcome |
400 |
only test payments can be simulated: use a test key |
A live key |
400 |
choose the payment's coin first |
No coin yet, for anything but expired |
400 |
this payment is confirmed: only a pending or partially paid payment can be simulated |
The payment is closed or confirming |
400 |
a BTC address only receives BTC: wrong_asset needs a coin on Tron, Ethereum or BNB Smart Chain |
wrong_asset on a coin that has no other |
404 |
payment not found |
No such payment in the key's mode |
409 |
a simulation is already running for this payment |
Wait until its state is "done" |
payment = client.payments.simulate("ORD-1002", "partially_paid")
print(payment.simulation) # {'outcome': 'partially_paid', 'state': 'running'}
# once it is done, pay the rest:
client.payments.simulate("ORD-1002", "confirmed")
Tokens
tokens.list
GET /v1/tokens · API reference
client.tokens.list(amount_usd=None) -> list[PayitgoObject]
The coins your customers can pay with in the key's mode, with today's network
fee and minimum. With amount_usd, only the coins a payment of that amount
offers (its token_options), each with amount_due_usd when you pass the fees
on.
| Parameter | Type | Required | Description |
|---|---|---|---|
amount_usd |
str, Decimal or int |
no | The order's amount, such as "49.90" |
Returns a list of TokenInfo (the library unwraps the API's
{"tokens": […]}).
Errors PayitgoError 400 (amount_usd must be > 0) for an amount that is
not a positive number, and the common statuses.
for t in client.tokens.list(amount_usd="49.90"):
print(f"{t.token} on {t.chain}: pay ${t.get('amount_due_usd', '49.90')}, {t.fee_note}")
Webhooks
The webhook (IPN) of the key's mode: one URL that receives every notification
of that mode, and the secret that signs them
(Webhooks). A test key manages the test
webhook, a live key the live one. A storefront
key can get and test it, but not
set or remove it.
webhooks.get
GET /v1/webhook · API reference
client.webhooks.get() -> PayitgoObject
Returns the Webhook: url (None when none is set) and
secret, the signing secret to give the client as webhook_secret.
It always succeeds for a valid key.
webhook = client.webhooks.get()
print(webhook.url, webhook.secret)
webhooks.set
PUT /v1/webhook · API reference
client.webhooks.set(url) -> PayitgoObject
| Parameter | Type | Required | Description |
|---|---|---|---|
url |
str | yes | An absolute https:// URL on a public host that accepts POST |
Returns the Webhook with its new url. The secret does not
change. Setting the URL it already has changes nothing.
Errors PayitgoError 400 (url is required, url must use https,
url must be an absolute http(s) URL, url host does not resolve,
url must point at a public host), 403 for a storefront key, 500
(could not set the webhook), and the common statuses.
client.webhooks.set("https://acme.example/payitgo/ipn")
webhooks.remove
DELETE /v1/webhook · API reference
client.webhooks.remove() -> PayitgoObject
Stops posting the mode's notifications to its URL. Payments created with their
own webhook_url still get theirs, and the secret is kept.
Returns the Webhook with url None.
Errors 403 for a storefront key, 500 (could not remove the webhook),
and the common statuses.
webhooks.test
POST /v1/webhook/test · API reference
client.webhooks.test() -> PayitgoObject
Queues a signed ping notification to the mode's URL; it arrives within about
5 seconds.
Returns the Delivery, with status "pending".
Errors PayitgoError 400 (no webhook url is set for this mode: set one first), 500 (could not queue the test), and the
common statuses.
delivery = client.webhooks.test()
print(delivery.id, delivery.event_type, delivery.url) # 6710c3a1f2b4e5d6a7b8c9d0 ping https://…
webhooks.construct_event
No request · how verification works
client.webhooks.construct_event(payload, signature_header, secret=None, tolerance=None) -> PayitgoObject
Verifies a notification and returns its event. It computes the HMAC-SHA256 of
t + "." + payload with the secret, compares it with v1 in constant time,
checks that t is within the tolerance of your clock, and only then parses the
body.
| Parameter | Type | Required | Description |
|---|---|---|---|
payload |
bytes or str | yes | The raw request body, exactly as received (request.get_data() in Flask, await request.body() in FastAPI, request.body in Django). A body parsed and serialized again will not verify (keep the raw body). A str is encoded as UTF-8 |
signature_header |
str or None |
yes | The X-Webhook-Signature header |
secret |
str | no | The signing secret, as payitgo gave it. Default: the client's webhook_secret. A secret passed here wins |
tolerance |
int | no | How far t may be from your clock, in seconds. None (the default) means 300. 0 turns the timestamp check off (only for tests with captured bodies) |
Returns the Event.
Errors SignatureVerificationError. Answer
400.
event = client.webhooks.construct_event(request.get_data(), request.headers.get("X-Webhook-Signature"))
if event.type == "payment.confirmed":
print(event.data.order_id)
payitgo.Webhook.construct_event
payitgo.Webhook.construct_event(payload, signature_header, secret, tolerance=300) -> PayitgoObject
The same check without a client, for code that has the secret but no API key.
secret is required; tolerance is in seconds (payitgo.Webhook.DEFAULT_TOLERANCE
is 300), and 0 turns the timestamp check off.
event = payitgo.Webhook.construct_event(raw_body, signature_header, os.environ["PAYITGO_WEBHOOK_SECRET"])
Errors SignatureVerificationError, except
the "no webhook secret" one.
Checkout
These two calls use the public checkout API:
they are sent without your API key and are keyed by the payment's
checkout_token. They return the payer's view of a payment. On your
server you rarely need them: payments.retrieve and payments.select_token
return more. The client still needs a valid key to be created.
checkout.retrieve
GET /checkout/{checkout_token} · API reference
client.checkout.retrieve(checkout_token) -> PayitgoObject
| Parameter | Type | Required | Description |
|---|---|---|---|
checkout_token |
str | yes | The payment's checkout_token |
Returns a Payment without your private fields: merchant_id,
metadata, customer_email, webhook_url, checkout_token and
checkout_url are absent.
Errors PayitgoError 404 (payment not found), 0, 429 and 5xx.
checkout.select_token
POST /checkout/{checkout_token}/token · API reference
client.checkout.select_token(checkout_token, token) -> PayitgoObject
| Parameter | Type | Required | Description |
|---|---|---|---|
checkout_token |
str | yes | The payment's checkout_token |
token |
str | yes | One of the payment's token_options[].token |
Returns the payer's view of the Payment with its coin, address, amount and a 15-minute deadline.
Errors the same as payments.select_token, and
429 (rate limit exceeded) beyond 5 choices a second from one IP address.
view = client.checkout.retrieve("b28d4f61e9a03c75d2e8f1a6")
if view.token == "":
chosen = client.checkout.select_token("b28d4f61e9a03c75d2e8f1a6", "USDT_TRC20")
print(chosen.deposit_address)
Types
PayitgoObject
Every response is a payitgo.PayitgoObject: a dict whose keys can also be read as
attributes, at every level (nested dicts are PayitgoObject too, lists are plain
lists).
payment["status"] == payment.status # True
payment.deposits[0].amount # nested objects too
payment.get("checkout_url") # optional keys: use .get()
json.dumps(payment) # it is a dict
Optional keys are absent when they have no value: reading a missing one as an
attribute raises AttributeError, so use .get() for them. A key that is a
Python keyword (from in a deposit) can only be read with [].
Amounts are decimal strings ("49.9"): convert them with decimal.Decimal,
never float. Times are RFC 3339 strings ("2026-10-10T09:15:00.112Z"):
datetime.fromisoformat() reads them on Python 3.11 and later.
The tables list each object's keys and their Python types; the linked API pages say what each one means. "Optional" keys may be absent.
Payment
| Key | Type |
|---|---|
id, order_id, status |
str |
merchant_id |
str (absent from the checkout view) |
store_id |
str, optional |
token, chain, deposit_address |
str ("" until the coin is chosen) |
token_options |
list of TokenInfo, optional (only while token is "") |
destination_tag |
int, optional (XRP only) |
livemode, fees_paid_by_customer |
bool |
simulation |
PayitgoObject, optional (a test payment's simulated outcome): outcome (one of payitgo.SIMULATION_OUTCOMES) and state ("requested", "running" or "done") |
crypto_amount, received_amount, usd_amount, amount_due_usd, network_fee, network_fee_usd, received_usd, rate_locked |
str (decimal) |
price_amount, price_rate |
str (decimal), optional (only for a payment priced in another currency) |
price_currency |
str, optional (its ISO 4217 code, upper case: "EUR") |
settlement |
str: "direct" or "custodial" |
tx_id |
str, optional |
overpaid, paid_late |
bool, optional |
deposits |
list of Deposit |
expires_at, created_at |
str (RFC 3339) |
confirmed_at |
str, optional |
network_fee_note, qr_payload |
str ("" until the coin is chosen) |
checkout_token |
str (absent from webhooks and the checkout view) |
checkout_url |
str, optional |
description, customer_email, success_url, cancel_url, webhook_url |
str, optional |
metadata |
PayitgoObject of str, optional |
Deposit
| Key | Type |
|---|---|
tx_id, token, chain, status, seen_at |
str |
amount, usd_value |
str (decimal) |
counted |
bool |
late |
bool, optional |
simulated |
bool, optional (played by a test payment's simulation: its tx_id is on no chain) |
confirmed_at, made_at, failed_at, fail_reason |
str, optional |
from |
str, optional (read it as deposit["from"] or deposit.get("from")) |
block |
int, optional |
Payment list
What payments.list returns.
| Key | Type |
|---|---|
payments |
list of Payment |
total, limit, offset |
int |
TokenInfo
| Key | Type |
|---|---|
token, chain, fee_note |
str |
min_usd, network_fee_usd |
str (decimal) |
amount_due_usd |
str (decimal), optional: with an amount, when you pass the fees on |
Webhook
| Key | Type |
|---|---|
livemode |
bool |
url |
str, or None when none is set |
secret |
str (signs the mode's notifications) |
created_at, updated_at |
str |
Delivery
| Key | Type |
|---|---|
id, merchant_id, url, event_id, event_type, payload, next_attempt_at, created_at |
str |
target |
str: "account" or "payment" |
status |
str: "pending", "sending", "delivered", "failed" or "resolved" |
attempts, max_attempts |
int |
last_status_code |
int, optional |
last_error, last_attempt_at, delivered_at, resolved_at, resolution_note |
str, optional |
history |
list, optional; each entry has at (str) and, when they apply, url, signed, error, response, note (str), status_code, millis (int) |
Event
What construct_event returns (the body).
| Key | Type |
|---|---|
id |
str (the same on every retry: deduplicate on it) |
type |
str: one of payitgo.WEBHOOK_EVENTS |
livemode |
bool |
created_at |
str |
data |
PayitgoObject: the Payment for payment.*, the settlement for settlement.*, the payout for payout.*, {"message": "pong"} for ping |
payitgo.WEBHOOK_EVENTS is the tuple of event types:
payment.partially_paid, payment.detecting, payment.confirmed,
payment.underpaid, payment.expired, payment.failed,
payment.wrong_asset, payment.late_deposit, payout.approved,
payout.rejected, payout.sent, settlement.completed, settlement.failed
and ping.
Examples
Your own payment page
Show the coins on your own site, create the payment in the coin the customer
picks, and render the amount, address, QR code and deadline from the payment
itself (what to show). Flask, with
segno for the QR code:
# pip install payitgo flask segno
import logging
import os
import payitgo
import segno
from flask import Flask, render_template_string, request
client = payitgo.Client(os.environ["PAYITGO_KEY"], base_url=os.environ.get("PAYITGO_BASE_URL"))
app = Flask(__name__)
NETWORKS = {
"tron": "Tron (TRC-20)", "ethereum": "Ethereum (ERC-20)", "bsc": "BNB Smart Chain (BEP-20)",
"bitcoin": "Bitcoin", "litecoin": "Litecoin", "dogecoin": "Dogecoin", "xrp": "XRP Ledger",
}
CHOOSE = """
<form method="post">
{% for t in tokens %}
<button name="token" value="{{ t.token }}">
{{ t.token }} on {{ networks.get(t.chain, t.chain) }}: ${{ t.get("amount_due_usd", total) }}
</button>
{% endfor %}
</form>
"""
PAY = """
{% if not p.livemode %}<p><b>Test payment:</b> do not send coins to this address.</p>{% endif %}
<p>Send exactly <b>{{ p.crypto_amount }} {{ p.token.split("_")[0] }}</b>
on <b>{{ networks.get(p.chain, p.chain) }}</b> to</p>
<p><code>{{ p.deposit_address }}</code></p>
{% if p.get("destination_tag") is not none %}<p>Destination tag (required): <b>{{ p.destination_tag }}</b></p>{% endif %}
<img src="{{ qr }}" width="220" height="220" alt="QR code">
<p>Pay before <time datetime="{{ p.expires_at }}">{{ p.expires_at }}</time></p>
"""
# 1. The coins this order can be paid with, and what each costs the customer.
@app.get("/orders/<order_id>/pay")
def choose(order_id):
order = find_order(order_id) # your code: .id and .total_usd ("49.90")
tokens = client.tokens.list(amount_usd=order.total_usd)
return render_template_string(CHOOSE, tokens=tokens, total=order.total_usd, networks=NETWORKS)
# 2. The customer picked one: create the payment in it and show it.
@app.post("/orders/<order_id>/pay")
def pay(order_id):
order = find_order(order_id)
try:
payment = client.payments.create(
order_id=order.id, # the same order again returns its open payment
amount_usd=order.total_usd,
token=request.form["token"],
)
except payitgo.PayitgoError as e:
logging.error("payitgo %s: %s", e.status, e.message) # log it; do not show it to the customer
return "We could not start this payment. Please try again.", 502
return render_payment(payment)
def render_payment(p):
qr = segno.make(p.qr_payload).svg_data_uri(scale=5)
return render_template_string(PAY, p=p, qr=qr, networks=NETWORKS)
A payment keeps its coin while it is open: if the customer goes back and picks
another coin, payments.create for the same order returns the first payment
unchanged. To let the customer choose on your page after the payment
exists, create it without token and set the coin with select_token:
payment = client.payments.create(order_id=order.id, amount_usd=order.total_usd)
# render payment.token_options (each with token, chain, min_usd, amount_due_usd, fee_note)…
# …and when the customer picks one:
chosen = client.payments.select_token(payment.id, picked_token)
html = render_payment(chosen)
To follow the payment live on the page, connect the page's script to the
socket's default namespace (/) and subscribe with payment.checkout_token
(with an acknowledgement): every payment:update then carries the whole
payment (follow it live). No polling.
Fulfil the order from the webhook, never from the page.
A webhook (IPN) handler with Flask
Read the raw body, verify it, handle each event once, and answer 2xx within
10 seconds (answering and retries):
import os
import payitgo
from flask import Flask, request
client = payitgo.Client(
os.environ["PAYITGO_KEY"],
base_url=os.environ.get("PAYITGO_BASE_URL"),
webhook_secret=os.environ["PAYITGO_WEBHOOK_SECRET"], # the signing secret of the key's mode
)
app = Flask(__name__)
@app.post("/payitgo/ipn")
def ipn():
try:
event = client.webhooks.construct_event(
request.get_data(), # the raw bytes, before any JSON parsing
request.headers.get("X-Webhook-Signature"),
)
except payitgo.SignatureVerificationError:
return "", 400 # not from payitgo, changed, or too old
if is_processed(event.id):
return "", 200 # a retry or redelivery
if event.livemode == client.is_live(): # ignore the other mode's events (asks payitgo once)
if event.type == "payment.confirmed": # final: fulfil
mark_order_paid(event.data.order_id, event.data.id) # idempotent
elif event.type == "payment.expired": # nothing arrived in time
release_order(event.data.order_id)
mark_processed(event.id)
return "", 200
is_processed, mark_processed, mark_order_paid and release_order are
your own code: store processed event ids in a table with a unique key, and make
fulfilment safe to run twice. If your code raises, Flask answers 500 and
payitgo retries the notification. More in
handle events safely.
The same handler in FastAPI. The client is synchronous, so the route is a plain
def (FastAPI runs it in a worker thread); construct_event makes no
request, but your own database calls might block:
from fastapi import Depends, FastAPI, Request, Response
app = FastAPI() # client is made as in the Flask example
async def raw_body(request: Request) -> bytes:
return await request.body() # the raw bytes, before any JSON parsing
@app.post("/payitgo/ipn")
def ipn(request: Request, body: bytes = Depends(raw_body)):
try:
event = client.webhooks.construct_event(body, request.headers.get("X-Webhook-Signature"))
except payitgo.SignatureVerificationError:
return Response(status_code=400)
if not is_processed(event.id):
if event.livemode == client.is_live() and event.type == "payment.confirmed":
mark_order_paid(event.data.order_id, event.data.id)
mark_processed(event.id)
return Response(status_code=200)
In an async def route, call the client's API methods with
await asyncio.to_thread(client.payments.retrieve, payment_id) so they do not
block the event loop.
Test mode
Build everything with your test key: the same calls, simulated payments instead of coins, and the test webhook with its own secret (Test mode).
client = payitgo.Client(os.environ["PAYITGO_TEST_KEY"], base_url="https://api.payitgo.co") # your test key
print(client.is_live()) # False
# Point the TEST webhook at your development server and read its secret.
hook = client.webhooks.set("https://dev.acme.example/payitgo/ipn")
print(hook.secret) # test mode's signing secret: your dev PAYITGO_WEBHOOK_SECRET
# A signed ping arrives within seconds.
client.webhooks.test()
# A test payment that pays itself: payment.detecting, then payment.confirmed,
# arrive at the test webhook within half a minute.
payment = client.payments.create(order_id="TEST-1", amount_usd=10, token="USDT_TRC20", simulate="confirmed")
print(payment.livemode, payment.simulation.state) # False running
# Another outcome on a payment you already have:
client.payments.simulate("TEST-2", "underpaid")
Never send real coins to a test address: nothing watches it (why).
Check your verification against the example in
the recipe (tolerance 0, because the example is
old):
body = b'{"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}'
event = payitgo.Webhook.construct_event(
body,
"t=1791623565,v1=1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b",
"a3858862701d1ce11ab06cb5dd9bb941d4849283f81fc0c3f0db0d99a2b82d48",
tolerance=0,
)
print(event.type) # ping
To test your handler without the network, sign a body yourself:
import hashlib, hmac, time
def sign(body: bytes, secret: str, t: int = None) -> str:
t = int(time.time()) if t is None else t
v1 = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return f"t={t},v1={v1}"
# Flask: app.test_client().post("/payitgo/ipn", data=body, headers={"X-Webhook-Signature": sign(body, secret)})
Going live: put the live key and the live webhook secret in your
production environment, set the live webhook URL with the live key, and keep
the event.livemode check in your handler (going live).