Dart and Flutter library
The payitgo package wraps the payitgo API for Dart. It has two clients:
| Client | Key | Where it runs | What it does |
|---|---|---|---|
Payitgo |
Your secret key | Your server (a Dart backend, Cloud Functions, a CLI) | Payments, tokens, your webhook (IPN) and its signature check |
PayitgoCheckout |
None | Flutter apps (mobile, desktop, web) and anywhere else | Shows one payment by its checkout_token, lets the customer choose the coin, and follows the payment |
Never put a secret key in a Flutter app: anyone can extract it. Your backend
creates the payment and hands the app its checkout_token.
Models have typed getters (payment.orderId, payment.checkoutUrl, …) over
the API's fields, and raw for every field as the API sent it, so the
Payments, Webhooks and
Checkout pages describe everything this library returns.
checkout is a PayitgoCheckout, or payitgo.checkout on the
server client.
Install
dart pub add payitgo # Dart
flutter pub add payitgo # Flutter
import 'package:payitgo/payitgo.dart';
| Requirement | |
|---|---|
| Dart | 3.0 or later (Flutter 3.10 or later) |
| Dependencies | http 1.2+, crypto 3.0+ |
| Platforms | Every Dart platform; the library does not import dart:io |
Quickstart
On your server:
import 'dart:io';
import 'package:payitgo/payitgo.dart';
Future<void> main() async {
final payitgo = Payitgo(Platform.environment['PAYITGO_KEY']!, baseUrl: 'https://api.payitgo.co');
final payment = await payitgo.payments.create(
orderId: 'ORD-1001',
amountUsd: '49.90',
token: 'USDT_TRC20',
);
print('${payment.cryptoAmount} ${payment.token} to ${payment.depositAddress}');
}
Then set your webhook URL and fulfil the order when
payment.confirmed arrives. In the app,
show the payment with PayitgoCheckout.
Configuration
Payitgo
Payitgo(
String apiKey, {
String? baseUrl,
http.Client? httpClient,
int maxRetries = 2,
Duration timeout = const Duration(seconds: 30),
String? webhookSecret,
String? webhookUrl,
})
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
apiKey |
String |
yes | Your secret key, test or live (Authentication). An empty key throws | |
baseUrl |
String? |
no | The address the package was released with | The gateway's address, https://api.payitgo.co. Trailing slashes are removed. The library reads no environment variable: pass Platform.environment['PAYITGO_BASE_URL'] yourself |
httpClient |
http.Client? |
no | One http.Client() the library makes, shared with checkout and closed by close() |
The package:http client every request goes through: an IOClient with your own HttpClient, a MockClient in tests. You close one you pass in |
maxRetries |
int |
no | 2 |
How many times a request is retried after a network error, a 429 or a 5xx. 0 turns retries off |
timeout |
Duration |
no | 30 seconds | How long one attempt may wait for the answer to start. A timeout counts as a network error, so it is retried |
webhookSecret |
String? |
no | none | Your mode's signing secret. webhooks.constructEvent uses it when you pass none |
webhookUrl |
String? |
no | none | Sent as webhook_url with every payments.create that does not pass its own |
The constructor makes no request. It throws an ArgumentError when:
| Message | Cause |
|---|---|
payitgo: the API key is required |
apiKey is empty |
payitgo: baseUrl must be your gateway's address |
No baseUrl (or an empty one) and no address built into the package |
final payitgo = Payitgo(
Platform.environment['PAYITGO_KEY']!,
baseUrl: 'https://api.payitgo.co',
timeout: const Duration(seconds: 15),
maxRetries: 2,
webhookSecret: Platform.environment['PAYITGO_WEBHOOK_SECRET'], // the signing secret of the key's mode
webhookUrl: 'https://acme.example/payitgo/ipn', // optional default for payments.create
);
The client keeps no per-request state: create one and share it, and call
close() when you are done with it.
| Property | Type | Description |
|---|---|---|
payments |
Payments |
Payments |
tokens |
Tokens |
Tokens |
webhooks |
Webhooks |
Webhooks |
checkout |
PayitgoCheckout |
The key-less calls, with this client's baseUrl, http.Client, maxRetries and timeout |
PayitgoCheckout
PayitgoCheckout({String? baseUrl, http.Client? httpClient})
The key-less client for Flutter apps. It sends no key and only the headers a browser can send without a CORS preflight on reads, so it also works on Flutter web. It retries twice and waits up to 30 seconds per attempt.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
baseUrl |
String? |
no | The address the package was released with | The gateway's address, https://api.payitgo.co |
httpClient |
http.Client? |
no | One http.Client() the library makes, closed by close() |
The package:http client to use. You close one you pass in |
It throws ArgumentError('payitgo: baseUrl must be your gateway's address')
when it has no address. Methods: retrieve,
selectToken, watch and
close.
close
void close() // on Payitgo and on PayitgoCheckout
Closes the http.Client the library made for itself, when you created the
client without httpClient: a Payitgo makes one and shares it with its
checkout, a PayitgoCheckout makes its own. A client you passed in stays
open; it is yours to close. Closing a Payitgo's checkout does nothing, since
its client belongs to the Payitgo. Calls made after close() fail with a
PayitgoException of status 0.
final checkout = PayitgoCheckout(baseUrl: 'https://api.payitgo.co');
try {
print((await checkout.retrieve(checkoutToken)).status);
} finally {
checkout.close();
}
A server that keeps one Payitgo until it stops can skip it; an app should
close its PayitgoCheckout when no screen needs it any more.
isLive
GET /v1/webhook · API reference
Future<bool> isLive() // on Payitgo
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.
Throws a PayitgoException, 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 (!await payitgo.isLive()) print('test mode: payments are simulated');
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 payitgo.isLive() asks payitgo. Pair each key with the
webhook secret of the same mode. A PayitgoCheckout needs no mode: the
payment's livemode says which it is. See Test mode.
HTTP headers
The server client sends Authorization: Bearer <key>,
Accept: application/json, X-Payitgo-Client: payitgo-dart/0.1.0 and, with a
body, Content-Type: application/json. PayitgoCheckout sends only Accept
and, with a body, Content-Type.
Errors
PayitgoException
Every call that reaches the API throws a PayitgoException when the request
fails.
class PayitgoException implements Exception {
final String message;
final int status;
}
| Field | Type | Description |
|---|---|---|
status |
int |
The HTTP status, or 0 when no answer came (network error or timeout, after the retries) |
message |
String |
The API's error message, such as minimum payment in USDT_TRC20 is $6.44 right now (network fees). HTTP <status> when the answer had no message. request failed: <cause> for a network error |
toString() is PayitgoException(<status>): <message>, or
PayitgoException: <message> when status is 0. 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, or no payment with this checkout token | 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 {
await payitgo.payments.create(orderId: 'ORD-1', amountUsd: '1', token: 'BTC');
} on PayitgoException catch (e) {
if (e.status != 400) rethrow;
print('payitgo refused: ${e.message}'); // e.g. an amount under the coin's minimum
}
SignatureVerificationException
constructEvent throws a
SignatureVerificationException (with a message) when a notification cannot
be trusted. Answer 400.
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 |
timestamp outside the tolerance |
t is not a number, or is further than the tolerance (5 minutes) from your clock |
no webhook secret: pass one, or set the client's webhookSecret |
The client method got no secret and the client has no webhookSecret |
A body that is correctly signed but is not JSON throws a FormatException;
payitgo never sends one.
Retries
A request is retried, up to maxRetries 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 throws a
PayitgoException 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.selectToken, checkout.selectToken |
If the first attempt went through, the repeat throws 409: read the payment with retrieve, it has its coin |
payments.simulate |
If the first attempt went through, the repeat throws 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
Future<Payment> create({
required String orderId,
Object? amountUsd,
Object? priceAmount,
String? priceCurrency,
String? token,
List<String>? tokens,
String? description,
Map<String, String>? metadata,
String? customerEmail,
String? successUrl,
String? cancelUrl,
String? webhookUrl,
String? simulate,
})
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 tokenOptions and the customer (or your page,
with selectToken) chooses the coin. While the order
has an open payment, creating it again returns that payment.
| Parameter | API field | Type | Required | Description |
|---|---|---|---|---|
orderId |
order_id |
String |
yes | Your id for the order, 1 to 128 bytes. One open payment per order and mode |
amountUsd |
amount_usd |
String or num |
yes, unless priceAmount + priceCurrency |
The price in US dollars, such as '49.90'. Sent as amountUsd.toString(): prefer a String to a double |
priceAmount |
price_amount |
String or num |
with priceCurrency |
Instead of amountUsd: 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 priceAmount.toString() |
priceCurrency |
price_currency |
String? |
with priceAmount |
The ISO 4217 code of its currency, such as 'EUR', 'GBP' or 'NGN' (any case) |
token |
token |
String? |
no | The coin, such as 'USDT_TRC20' (from tokens.list). Leave it out to let the customer choose |
tokens |
tokens |
List<String>? |
no | Without token: the coins to offer, such as ['USDT_TRC20', 'USDT_BEP20']. Default: every coin you accept whose minimum the amount reaches |
description |
description |
String? |
no | What the customer pays for, up to 500 bytes |
metadata |
metadata |
Map<String, String>? |
no | Your own data: up to 20 keys of up to 40 bytes, values of up to 500 |
customerEmail |
customer_email |
String? |
no | The customer's email address |
successUrl |
success_url |
String? |
no | http(s) URL the hosted checkout links to once the payment is confirmed |
cancelUrl |
cancel_url |
String? |
no | http(s) URL the hosted checkout links back to if the payment closes unpaid |
webhookUrl |
webhook_url |
String? |
no | https:// URL that gets this payment's notifications instead of your account's webhook URL (per-payment URL). null: the client's webhookUrl. '': none for this payment, even with a client default |
simulate |
simulate |
String? |
no | Test keys only: the outcome to simulate, one of Payments.simulationOutcomes. With token it starts at once; without, once the coin is chosen |
Parameters left null are not sent.
Returns the Payment: the new one (201) or the order's open
one (200); both look the same. Fields: the payment
object.
Errors PayitgoException 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:
final payment = await payitgo.payments.create(
orderId: 'ORD-1001',
amountUsd: '49.90',
token: 'USDT_TRC20',
description: '2 x Acme T-shirt',
metadata: {'customer_id': 'cus_42'},
customerEmail: 'jane@example.com',
successUrl: 'https://acme.example/thanks',
cancelUrl: 'https://acme.example/cart',
);
// payment.depositAddress, payment.cryptoAmount, payment.qrPayload, payment.expiresAt
// The customer chooses the coin (on the hosted page, in your app, or on your page):
final open = await payitgo.payments.create(orderId: 'ORD-1002', amountUsd: '25');
for (final option in open.tokenOptions) {
print('${option.token} ${option.minUsd} ${option.amountDueUsd ?? ''}');
}
// Hand open.checkoutToken to your Flutter app, or show open.tokenOptions on your page.
// This payment's notifications go to its own URL:
await payitgo.payments.create(
orderId: 'ORD-1003',
amountUsd: '30',
token: 'USDT_TRC20',
webhookUrl: 'https://acme.example/orders/ORD-1003/ipn',
);
// Priced in euros: converted into dollars at the rate of the moment.
final priced = await payitgo.payments
.create(orderId: 'ORD-1004', priceAmount: '45.50', priceCurrency: 'EUR');
// priced.priceAmount '45.5', priced.priceCurrency 'EUR', priced.priceRate '1.082', priced.usdAmount '49.23'
payments.retrieve
GET /v1/payments/{id} · API reference
Future<Payment> retrieve(String id)
| Parameter | Type | Required | Description |
|---|---|---|---|
id |
String |
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 PayitgoException 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.
final byId = await payitgo.payments.retrieve('8f3a2c1e9b7d4a6f05e1c2d3');
final byOrder = await payitgo.payments.retrieve('ORD-1001'); // the order's open payment
if (byId.isConfirmed) print('paid at ${byId.confirmedAt}, order ${byOrder.orderId}');
payments.list
GET /v1/payments · API reference
Future<PaymentList> list({String? status, String? chain, String? token, String? storeId, int? limit, int? offset})
Payments of the key's mode, newest first.
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
status |
String? |
no | all | Only payments in this status, such as 'confirmed' |
chain |
String? |
no | all | Only payments on this network, such as 'tron' |
token |
String? |
no | all | Only payments in this coin, such as 'USDT_TRC20' |
storeId |
String? |
no | all | Only payments of this store (sent as store_id; ignored for a storefront key, which sees its own) |
limit |
int? |
no | 50 |
Page size, 1 to 200 (outside that range the API uses 50) |
offset |
int? |
no | 0 |
How many to skip |
Parameters left null are not sent.
Returns a PaymentList: 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:
final confirmed = <Payment>[];
for (var offset = 0;;) {
final page = await payitgo.payments.list(status: 'confirmed', limit: 100, offset: offset);
confirmed.addAll(page.payments);
offset += page.limit;
if (offset >= page.total) break;
}
payments.selectToken
POST /v1/payments/{id}/token · API reference
Future<Payment> selectToken(String id, String token)
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 |
|---|---|---|---|
id |
String |
yes | The payment's id or your order_id |
token |
String |
yes | One of the payment's tokenOptions[].token, such as 'USDT_TRC20' |
Returns the whole Payment, now with depositAddress,
cryptoAmount, rateLocked, qrPayload and an expiresAt 15 minutes away.
Errors PayitgoException:
status |
message |
Cause |
|---|---|---|
400 |
token is required |
Empty token |
400 |
token is not one of this payment's options |
A coin not in tokenOptions |
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, the app or the hosted page) |
final payment = await payitgo.payments.create(orderId: 'ORD-1002', amountUsd: '25');
// …your page lists payment.tokenOptions, the customer picks one…
final chosen = await payitgo.payments.selectToken(payment.id, 'USDT_BEP20');
print('${chosen.cryptoAmount} ${chosen.token} to ${chosen.depositAddress} before ${chosen.expiresAt}');
payments.simulate
POST /v1/payments/{id}/simulate · API reference
Future<Payment> simulate(String id, String outcome)
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 |
|---|---|---|---|
id |
String |
yes | The payment's id or your order_id |
outcome |
String |
yes | One of Payments.simulationOutcomes, such as 'confirmed' (what each does) |
Returns the whole Payment, with its simulation started.
Simulated deposits have simulated true; their txId names no transaction on any chain.
Errors PayitgoException:
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' |
final payment = await payitgo.payments.simulate('ORD-1002', 'partially_paid');
print('${payment.simulation?.outcome} ${payment.simulation?.state}'); // partially_paid running
// once it is done, pay the rest:
await payitgo.payments.simulate('ORD-1002', 'confirmed');
Tokens
tokens.list
GET /v1/tokens · API reference
Future<List<TokenInfo>> list({Object? amountUsd})
The coins your customers can pay with in the key's mode, with today's network
fee and minimum. With amountUsd, only the coins a payment of that amount
offers (its tokenOptions), each with amountDueUsd when you pass the fees
on.
| Parameter | Type | Required | Description |
|---|---|---|---|
amountUsd |
String or num |
no | The order's amount, such as '49.90'. Sent as amountUsd.toString(), as in payments.create: prefer a String to a double |
Returns a List<TokenInfo> (the library unwraps the
API's {"tokens": […]}).
Errors PayitgoException 400 (amount_usd must be > 0) for an amount
that is not a positive number, and the common statuses.
for (final t in await payitgo.tokens.list(amountUsd: '49.90')) {
print('${t.token} on ${t.chain}: pay \$${t.amountDueUsd ?? '49.90'}, ${t.feeNote}');
}
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.events lists the event types.
webhooks.get
GET /v1/webhook · API reference
Future<WebhookSettings> get()
Returns the WebhookSettings: url (null when none
is set) and secret, the signing secret to give the client as
webhookSecret. It always succeeds for a valid key.
final webhook = await payitgo.webhooks.get();
print('${webhook.url} ${webhook.secret}');
webhooks.set
PUT /v1/webhook · API reference
Future<WebhookSettings> set(String url)
| Parameter | Type | Required | Description |
|---|---|---|---|
url |
String |
yes | An absolute https:// URL on a public host that accepts POST |
Returns the WebhookSettings with its new url. The
secret does not change. Setting the URL it already has changes nothing.
Errors PayitgoException 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.
await payitgo.webhooks.set('https://acme.example/payitgo/ipn');
webhooks.remove
DELETE /v1/webhook · API reference
Future<WebhookSettings> remove()
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 WebhookSettings with url null.
Errors 403 for a storefront key, 500 (could not remove the webhook),
and the common statuses.
webhooks.test
POST /v1/webhook/test · API reference
Future<WebhookDelivery> test()
Queues a signed ping notification to the mode's URL; it arrives within about
5 seconds.
Returns the WebhookDelivery, with status
'pending'.
Errors PayitgoException 400 (no webhook url is set for this mode: set one first), 500 (could not queue the test), and the
common statuses.
final delivery = await payitgo.webhooks.test();
print('${delivery.id} ${delivery.eventType} ${delivery.url}'); // 6710c3a1f2b4e5d6a7b8c9d0 ping https://…
webhooks.constructEvent
No request · how verification works
WebhookEvent constructEvent(
Object payload,
String? signatureHeader, {
String? secret,
Duration tolerance = Webhook.defaultTolerance, // 5 minutes
})
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 |
String or List<int> |
yes | The raw request body, exactly as received (await request.readAsString(), or the bytes). A body decoded and encoded again will not verify (keep the raw body) |
signatureHeader |
String? |
yes | The X-Webhook-Signature header: request.headers['x-webhook-signature'] |
secret |
String? |
no | The signing secret, as payitgo gave it. Default: the client's webhookSecret. A non-empty secret passed here wins |
tolerance |
Duration |
no | How far t may be from your clock. Default 5 minutes. Duration.zero turns the timestamp check off (only for tests with captured bodies) |
Returns the WebhookEvent.
Errors SignatureVerificationException.
Answer 400.
final event = payitgo.webhooks.constructEvent(rawBody, request.headers['x-webhook-signature']);
if (event.type == 'payment.confirmed') print(Payment(event.data).orderId);
Webhook.constructEvent
static WebhookEvent constructEvent(
Object payload,
String? signatureHeader,
String secret, {
Duration tolerance = defaultTolerance, // 5 minutes
})
The same check without a client, for code that has the secret but no API key.
secret is required. Webhook.defaultTolerance is
Duration(minutes: 5).
final event = Webhook.constructEvent(rawBody, signatureHeader, Platform.environment['PAYITGO_WEBHOOK_SECRET']!);
Errors SignatureVerificationException,
except the "no webhook secret" one.
Checkout
The key-less calls of the public checkout API,
keyed by the payment's checkoutToken. They return the payer's view
of a payment, without your private fields: merchantId, metadata,
customerEmail, webhookUrl, checkoutToken and checkoutUrl are empty.
Use them from a PayitgoCheckout in your app, or as
payitgo.checkout on the server.
checkout.retrieve
GET /checkout/{checkout_token} · API reference
Future<Payment> retrieve(String checkoutToken)
| Parameter | Type | Required | Description |
|---|---|---|---|
checkoutToken |
String |
yes | The payment's checkoutToken, from your backend |
Returns the payer's view of the Payment.
Errors PayitgoException 404 (payment not found), 0, 429 and 5xx.
checkout.selectToken
POST /checkout/{checkout_token}/token · API reference
Future<Payment> selectToken(String checkoutToken, String token)
| Parameter | Type | Required | Description |
|---|---|---|---|
checkoutToken |
String |
yes | The payment's checkoutToken |
token |
String |
yes | One of the payment's tokenOptions[].token |
Returns the payer's view of the Payment with its coin,
address, amount and a 15-minute deadline.
Errors the same as payments.selectToken, and
429 (rate limit exceeded) beyond 5 choices a second from one IP address.
final checkout = PayitgoCheckout(baseUrl: 'https://api.payitgo.co');
var payment = await checkout.retrieve(checkoutToken);
if (payment.token.isEmpty) {
payment = await checkout.selectToken(checkoutToken, payment.tokenOptions.first.token);
}
print('${payment.cryptoAmount} ${payment.token} to ${payment.depositAddress}');
checkout.simulate
POST /checkout/{checkout_token}/simulate · API reference
Future<Payment> simulate(String checkoutToken, String outcome)
payments.simulate without a key, for a test payment's
screen: put a button per outcome under its "Test payment" banner
(payment.livemode false), and follow the result with the
socket (payment.simulation events say when
it starts and ends), or with checkout.watch. Never show
them for a live payment, which answers 400.
| Parameter | Type | Required | Description |
|---|---|---|---|
checkoutToken |
String |
yes | The payment's checkoutToken |
outcome |
String |
yes | One of Payments.simulationOutcomes |
Returns the payer's view of the Payment, with its
simulation started.
Errors the same as payments.simulate, and 429
(rate limit exceeded) beyond 5 requests a second from one IP address.
if (!payment.livemode && payment.simulation?.state != 'running') {
await checkout.simulate(checkoutToken, 'confirmed');
}
checkout.watch
GET /checkout/{checkout_token}, repeated
Stream<Payment> watch(String checkoutToken, {Duration every = const Duration(seconds: 5)})
watch polls: it reads the payment every every (5 seconds by
default). Where the app can use socket.io, follow the payment with the
socket instead: the default namespace (/),
subscribe with the checkout token and an acknowledgement, then a
payment:update with the whole payment on every change, as it happens.
watch is the fallback for apps that cannot use socket.io. With the
socket_io_client package:
import 'package:socket_io_client/socket_io_client.dart' as sio;
final socket = sio.io('https://api.payitgo.co', sio.OptionBuilder().setTransports(['websocket']).build());
socket.onConnect((_) => socket.emitWithAck('subscribe', checkoutToken, ack: (r) {
if (r['ok'] == true) show(Payment(Map<String, dynamic>.from(r['payment'])));
})); // again on every reconnect
socket.on('payment:update', (e) => show(Payment(Map<String, dynamic>.from(e['payment']))));
// socket.dispose() when the screen closes
watch emits the payment at once, then each time its status changes. It
keeps following a payment that is expired or underpaid, because money
arriving in the next 24 hours can still complete it
(late payments): the stream then emits
detecting and confirmed. The stream ends once the payment can change no
more:
| The payment | The stream |
|---|---|
confirmed or failed |
Ends after emitting it |
expired without a coin (token is '': it never had an address) |
Ends after emitting it |
expired or underpaid |
Keeps polling until 24 hours after expiresAt, then ends |
| Anything else | Keeps polling |
Cancel the subscription (or leave the await for loop) to stop sooner, for
example when the screen closes. A request that still fails after its retries
ends the stream with that PayitgoException.
| Parameter | Type | Required | Description |
|---|---|---|---|
checkoutToken |
String |
yes | The payment's checkoutToken |
every |
Duration |
no | How often to poll. Default 5 seconds |
Changes within a status (more of the amount arriving while
partially_paid) are not emitted: call retrieve for those. The
socket sends every change, confirmation
counts included.
await for (final p in checkout.watch(checkoutToken)) {
switch (p.status) {
case 'partially_paid':
print('received ${p.receivedAmount} of ${p.cryptoAmount}');
case 'detecting':
print('payment received, confirming…');
case 'confirmed':
print('paid');
case 'expired' || 'underpaid':
print('closed unpaid (${p.status}): still listening for a late payment');
case 'failed':
print('failed');
}
}
Types
All models are exported from package:payitgo/payitgo.dart. Amounts are decimal
strings ('49.9'): parse them with a decimal package, not double. Times are
DateTime in UTC. Models that wrap a JSON object keep it in raw, for any
field without a getter.
Payment
The payment object. Payment(Map<String, dynamic> raw).
| Getter | Type | API field | When absent |
|---|---|---|---|
id |
String |
id |
|
orderId |
String |
order_id |
|
merchantId |
String? |
merchant_id |
null (the checkout view) |
storeId |
String? |
store_id |
null |
status |
String |
status |
|
livemode |
bool |
livemode |
false |
simulation |
Simulation? |
simulation |
null (no simulated outcome) |
token |
String |
token |
('' until the coin is chosen) |
tokenOptions |
List<TokenInfo> |
token_options |
[] |
chain |
String |
chain |
('' until the coin is chosen) |
depositAddress |
String |
deposit_address |
('' until the coin is chosen) |
destinationTag |
int? |
destination_tag |
null (XRP only) |
cryptoAmount |
String |
crypto_amount |
|
receivedAmount |
String |
received_amount |
|
usdAmount |
String |
usd_amount |
|
priceAmount |
String? |
price_amount |
null (priced in dollars) |
priceCurrency |
String? |
price_currency |
null (priced in dollars) |
priceRate |
String? |
price_rate |
null (priced in dollars) |
amountDueUsd |
String |
amount_due_usd |
usdAmount |
feesPaidByCustomer |
bool |
fees_paid_by_customer |
false |
networkFee |
String |
network_fee |
'0' |
networkFeeUsd |
String |
network_fee_usd |
'0' |
settlement |
String |
settlement |
'custodial' |
receivedUsd |
String |
received_usd |
'0' |
rateLocked |
String |
rate_locked |
'0' |
txId |
String? |
tx_id |
null |
overpaid |
bool |
overpaid |
false |
paidLate |
bool |
paid_late |
false |
deposits |
List<Deposit> |
deposits |
[] |
expiresAt |
DateTime |
expires_at |
|
createdAt |
DateTime |
created_at |
|
confirmedAt |
DateTime? |
confirmed_at |
null |
networkFeeNote |
String |
network_fee_note |
'' |
qrPayload |
String |
qr_payload |
'' |
checkoutToken |
String? |
checkout_token |
null (webhooks, the checkout view) |
checkoutUrl |
String? |
checkout_url |
null |
description |
String? |
description |
null |
metadata |
Map<String, String> |
metadata |
{} |
customerEmail |
String? |
customer_email |
null |
successUrl |
String? |
success_url |
null |
cancelUrl |
String? |
cancel_url |
null |
webhookUrl |
String? |
webhook_url |
null |
isConfirmed |
bool |
status == 'confirmed': final, fulfil the order |
|
isClosed |
bool |
status is confirmed, underpaid, expired or failed |
|
raw |
Map<String, dynamic> |
The payment as the API sent it |
Deposit
The deposit object. Deposit(Map<String, dynamic> raw).
| Getter | Type | API field |
|---|---|---|
txId |
String |
tx_id |
token |
String |
token (the asset that arrived) |
chain |
String |
chain |
amount |
String |
amount |
usdValue |
String |
usd_value |
counted |
bool |
counted (false when absent) |
late |
bool |
late (false when absent) |
status |
String |
status: pending, confirmed or failed |
seenAt |
DateTime |
seen_at |
confirmedAt |
DateTime? |
confirmed_at |
from |
String? |
from: the sender, when the network reports it (where a refund would go) |
block |
int? |
block: the block (on XRP, the ledger) that holds it |
madeAt |
DateTime? |
made_at: that block's time |
failedAt |
DateTime? |
failed_at |
failReason |
String? |
fail_reason: transaction not found on chain or confirmation timeout |
simulated |
bool |
simulated (false when absent): played by a test payment's simulation, its txId on no chain |
raw |
Map<String, dynamic> |
The deposit as the API sent it |
Simulation
A test payment's simulated outcome.
Simulation(Map<String, dynamic> raw).
| Getter | Type | API field |
|---|---|---|
outcome |
String |
outcome: one of Payments.simulationOutcomes |
state |
String |
state: requested (asked for on create, waiting for the coin), running or done |
raw |
Map<String, dynamic> |
The simulation as the API sent it |
PaymentList
What payments.list returns.
| Field | Type |
|---|---|
payments |
List<Payment> |
total |
int (matches across all pages) |
limit |
int (the page size applied) |
offset |
int (the offset applied) |
TokenInfo
| Field | Type | API field |
|---|---|---|
token |
String |
token |
chain |
String |
chain |
minUsd |
String |
min_usd |
networkFeeUsd |
String |
network_fee_usd ('0' when absent) |
amountDueUsd |
String? |
amount_due_usd: with an amount, when you pass the fees on |
feeNote |
String |
fee_note ('' when absent) |
WebhookSettings
The webhook of the key's mode (the webhook object).
| Getter | Type | API field |
|---|---|---|
livemode |
bool |
livemode |
url |
String? |
url (null when none is set) |
secret |
String |
secret: signs the mode's notifications |
createdAt |
DateTime |
created_at |
updatedAt |
DateTime |
updated_at |
raw |
Map<String, dynamic> |
WebhookDelivery
One notification on its way to one URL (the delivery object).
| Getter | Type | API field |
|---|---|---|
id |
String |
id |
merchantId |
String |
merchant_id |
target |
String |
target: account or payment |
url |
String |
url |
eventId |
String |
event_id |
eventType |
String |
event_type |
payload |
String |
payload (the exact body posted) |
status |
String |
status: pending, sending, delivered, failed or resolved |
attempts |
int |
attempts |
maxAttempts |
int |
max_attempts |
nextAttemptAt |
DateTime |
next_attempt_at |
lastStatusCode |
int? |
last_status_code |
lastError |
String? |
last_error |
lastAttemptAt |
DateTime? |
last_attempt_at |
deliveredAt |
DateTime? |
delivered_at |
resolvedAt |
DateTime? |
resolved_at |
resolutionNote |
String? |
resolution_note |
createdAt |
DateTime |
created_at |
history |
List<WebhookDeliveryAttempt> |
history ([] when absent) |
raw |
Map<String, dynamic> |
WebhookDeliveryAttempt has at (DateTime), url, signed, error,
response, note (String?), statusCode, millis (int?) and raw.
WebhookEvent
A verified notification (the body).
| Getter | Type | API field |
|---|---|---|
id |
String |
id (the same on every retry: deduplicate on it) |
type |
String |
type: one of Webhooks.events |
livemode |
bool |
livemode |
createdAt |
DateTime |
created_at |
data |
Map<String, dynamic> |
data |
raw |
Map<String, dynamic> |
The whole event |
data is the payment for payment.* events (wrap it:
Payment(event.data)), the settlement
for settlement.*, the payout for
payout.*, and {'message': 'pong'} for ping. Webhooks.events holds
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
A Dart server that shows the coins, creates the payment in the coin the
customer picks, and renders the amount, address, QR code and deadline from the
payment itself (what to show). With
shelf, and qr
for the QR code:
// dart pub add payitgo shelf qr
import 'dart:convert';
import 'dart:io';
import 'package:payitgo/payitgo.dart';
import 'package:qr/qr.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
final payitgo = Payitgo(Platform.environment['PAYITGO_KEY']!, baseUrl: Platform.environment['PAYITGO_BASE_URL']);
const networks = {
'tron': 'Tron (TRC-20)', 'ethereum': 'Ethereum (ERC-20)', 'bsc': 'BNB Smart Chain (BEP-20)',
'bitcoin': 'Bitcoin', 'litecoin': 'Litecoin', 'dogecoin': 'Dogecoin', 'xrp': 'XRP Ledger',
};
String h(Object? s) => htmlEscape.convert('$s');
Response html(String body, [int status = 200]) =>
Response(status, body: body, headers: {'content-type': 'text/html; charset=utf-8'});
// GET /pay?order=ORD-1001 lists the coins; POST creates the payment in the chosen one.
Future<Response> pay(Request request) async {
final order = await findOrder(request.url.queryParameters['order'] ?? ''); // your code: id, totalUsd ('49.90')
if (request.method != 'POST') {
final tokens = await payitgo.tokens.list(amountUsd: order.totalUsd);
return html('<form method="post">${tokens.map((t) => '<button name="token" value="${h(t.token)}">'
'${h(t.token)} on ${h(networks[t.chain] ?? t.chain)}: \$${h(t.amountDueUsd ?? order.totalUsd)}</button>').join()}</form>');
}
final form = Uri.splitQueryString(await request.readAsString());
try {
final payment = await payitgo.payments.create(
orderId: order.id, // the same order again returns its open payment
amountUsd: order.totalUsd,
token: form['token'] ?? '',
);
return html(renderPayment(payment));
} on PayitgoException catch (e) {
print('payitgo ${e.status}: ${e.message}'); // log it; do not show it to the customer
return html('We could not start this payment. Please try again.', 502);
}
}
String renderPayment(Payment p) => '''
${p.livemode ? '' : '<p><b>Test payment:</b> do not send coins to this address.</p>'}
<p>Send exactly <b>${h(p.cryptoAmount)} ${h(p.token.split('_').first)}</b>
on <b>${h(networks[p.chain] ?? p.chain)}</b> to</p>
<p><code>${h(p.depositAddress)}</code></p>
${p.destinationTag == null ? '' : '<p>Destination tag (required): <b>${p.destinationTag}</b></p>'}
${qrSvg(p.qrPayload)}
<p>Pay before <time datetime="${p.expiresAt.toIso8601String()}">${h(p.expiresAt.toLocal())}</time></p>''';
String qrSvg(String data) {
final qr = QrImage(QrCode.fromData(data: data, errorCorrectLevel: QrErrorCorrectLevel.M));
final n = qr.moduleCount;
final svg = StringBuffer('<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 ${n + 4} ${n + 4}" '
'width="220" height="220" shape-rendering="crispEdges"><rect x="-2" y="-2" width="${n + 4}" height="${n + 4}" fill="#fff"/>');
for (var y = 0; y < n; y++) {
for (var x = 0; x < n; x++) {
if (qr.isDark(y, x)) svg.write('<rect x="$x" y="$y" width="1" height="1"/>');
}
}
return (svg..write('</svg>')).toString();
}
Future<void> main() async {
await io.serve((Request r) async => r.url.path == 'pay' ? await pay(r) : Response.notFound(''),
InternetAddress.anyIPv4, 8080);
}
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 selectToken:
final payment = await payitgo.payments.create(orderId: order.id, amountUsd: order.totalUsd);
// render payment.tokenOptions (each with token, chain, minUsd, amountDueUsd, feeNote)…
// …and when the customer picks one:
final chosen = await payitgo.payments.selectToken(payment.id, pickedToken);
final page = renderPayment(chosen);
To follow the payment live on the page, connect the page's script to the
socket's default namespace (/) and subscribe with payment.checkoutToken
(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 Flutter payment screen
In a Flutter app the page is a screen. Your backend creates the payment
without a token and returns its checkoutToken; the app shows the coins,
sends the customer's choice with selectToken, and follows the payment with
watch, which polls every 5 seconds. No key is involved. For updates the
moment they happen, use the socket instead of watch (the snippet under
checkout.watch: the same Payment, from each event's
payment). With qr_flutter
for the QR code:
// flutter pub add payitgo qr_flutter
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:payitgo/payitgo.dart';
import 'package:qr_flutter/qr_flutter.dart';
final checkout = PayitgoCheckout(baseUrl: 'https://api.payitgo.co');
class PaymentScreen extends StatefulWidget {
const PaymentScreen({super.key, required this.checkoutToken}); // from your backend
final String checkoutToken;
@override
State<PaymentScreen> createState() => _PaymentScreenState();
}
class _PaymentScreenState extends State<PaymentScreen> {
Payment? payment;
String? error;
StreamSubscription<Payment>? _watching;
@override
void initState() {
super.initState();
_watching = checkout.watch(widget.checkoutToken).listen(
(p) => setState(() => payment = p),
onError: (Object e) => setState(() => error = e is PayitgoException ? e.message : '$e'),
);
}
@override
void dispose() {
_watching?.cancel(); // stop polling when the screen goes away
super.dispose();
}
Future<void> _choose(String token) async {
try {
final p = await checkout.selectToken(widget.checkoutToken, token);
if (mounted) setState(() => payment = p);
} on PayitgoException catch (e) {
if (mounted) setState(() => error = e.message);
}
}
@override
Widget build(BuildContext context) {
final p = payment;
if (error != null) return Center(child: Text('Something went wrong: $error'));
if (p == null) return const Center(child: CircularProgressIndicator());
if (p.token.isEmpty) {
return ListView(children: [
for (final t in p.tokenOptions)
ListTile(
title: Text('${t.token} on ${t.chain}'),
subtitle: Text(t.feeNote),
trailing: Text('\$${t.amountDueUsd ?? p.amountDueUsd}'),
onTap: () => _choose(t.token),
),
]);
}
return ListView(padding: const EdgeInsets.all(16), children: [
if (!p.livemode) const Text('Test payment: do not send coins to this address.'),
Text('Send exactly ${p.cryptoAmount} ${p.token.split('_').first} on ${p.chain}'),
SelectableText(p.depositAddress),
TextButton(
onPressed: () => Clipboard.setData(ClipboardData(text: p.depositAddress)),
child: const Text('Copy address'),
),
if (p.destinationTag != null) Text('Destination tag (required): ${p.destinationTag}'),
Center(child: QrImageView(data: p.qrPayload, size: 220)),
Text('Pay before ${TimeOfDay.fromDateTime(p.expiresAt.toLocal()).format(context)}'),
Text(switch (p.status) {
'partially_paid' => 'Received ${p.receivedAmount}: send the rest to the same address.',
'detecting' => 'Payment received, confirming…',
'confirmed' => 'Paid. Thank you!',
'pending' => 'Waiting for your payment.',
'expired' || 'underpaid' => 'Time is up (${p.status}). Contact us if you have already paid.',
_ => 'This payment could not be completed.',
}),
]);
}
}
The app only shows progress; your backend still fulfils the order from the
payment.confirmed webhook.
A webhook (IPN) handler with shelf
Read the raw body, verify it, handle each event once, and answer 2xx within
10 seconds (answering and retries):
import 'dart:io';
import 'package:payitgo/payitgo.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
final payitgo = Payitgo(
Platform.environment['PAYITGO_KEY']!,
baseUrl: Platform.environment['PAYITGO_BASE_URL'],
webhookSecret: Platform.environment['PAYITGO_WEBHOOK_SECRET'], // the signing secret of the key's mode
);
Future<Response> ipn(Request request) async {
if (request.method != 'POST') return Response(405);
final WebhookEvent event;
try {
event = payitgo.webhooks.constructEvent(
await request.readAsString(), // the raw body, before any JSON parsing
request.headers['x-webhook-signature'],
);
} on SignatureVerificationException {
return Response(400); // not from payitgo, changed, or too old
} on FormatException {
return Response(400);
}
if (await isProcessed(event.id)) return Response.ok(''); // a retry or redelivery
if (event.livemode == await payitgo.isLive()) { // ignore the other mode's events
switch (event.type) {
case 'payment.confirmed': // final: fulfil
final p = Payment(event.data);
await markOrderPaid(p.orderId, p.id); // idempotent
case 'payment.expired': // nothing arrived in time
await releaseOrder(Payment(event.data).orderId);
}
}
await markProcessed(event.id);
return Response.ok('');
}
Future<void> main() async {
await io.serve((Request r) async => r.url.path == 'payitgo/ipn' ? await ipn(r) : Response.notFound(''),
InternetAddress.anyIPv4, 8080);
}
isProcessed, markProcessed, markOrderPaid and releaseOrder 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 throws, shelf answers 500 and
payitgo retries the notification. More in
handle events safely.
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).
final payitgo = Payitgo(Platform.environment['PAYITGO_TEST_KEY']!, baseUrl: 'https://api.payitgo.co'); // your test key
print(await payitgo.isLive()); // false
// Point the TEST webhook at your development server and read its secret.
final hook = await payitgo.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.
await payitgo.webhooks.test();
// A test payment that pays itself: payment.detecting, then payment.confirmed,
// arrive at the test webhook within half a minute.
final payment = await payitgo.payments.create(
orderId: 'TEST-1', amountUsd: '10', token: 'USDT_TRC20', simulate: 'confirmed');
print('${payment.livemode} ${payment.simulation?.state}'); // false running
// Another outcome on a payment you already have:
await payitgo.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 zero, because the example is old):
const body = '{"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}';
final event = Webhook.constructEvent(
body,
't=1791623565,v1=1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b',
'a3858862701d1ce11ab06cb5dd9bb941d4849283f81fc0c3f0db0d99a2b82d48',
tolerance: Duration.zero,
);
print(event.type); // ping
To test without the network, give the client a MockClient from
package:http/testing.dart, and sign bodies yourself with package:crypto:
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:payitgo/payitgo.dart';
String sign(String body, String secret, [DateTime? at]) {
final t = (at ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000;
final v1 = Hmac(sha256, utf8.encode(secret)).convert(utf8.encode('$t.$body'));
return 't=$t,v1=$v1';
}
final fake = Payitgo(
'4f9c2a7e1b8d3f6a0c5e9b2d7a4f1c8e3b6d9a2f5c8e1b4d7a0f3c6e9b2d5a8f',
baseUrl: 'https://payitgo.invalid',
httpClient: MockClient((request) async => http.Response(
jsonEncode({'webhook': {'livemode': false, 'url': null,
'secret': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
'created_at': '2026-10-10T09:00:00Z', 'updated_at': '2026-10-10T09:00:00Z'}}),
200)),
); // await fake.isLive() is false, from this answer
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).