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.

Method HTTP Returns
payitgo.payments.create(…) POST /v1/payments Payment
payitgo.payments.retrieve(id) GET /v1/payments/{id} Payment
payitgo.payments.list(…) GET /v1/payments PaymentList
payitgo.payments.selectToken(id, token) POST /v1/payments/{id}/token Payment
payitgo.payments.simulate(id, outcome) POST /v1/payments/{id}/simulate Payment
payitgo.tokens.list(amountUsd: …) GET /v1/tokens List<TokenInfo>
payitgo.webhooks.get() GET /v1/webhook WebhookSettings
payitgo.webhooks.set(url) PUT /v1/webhook WebhookSettings
payitgo.webhooks.remove() DELETE /v1/webhook WebhookSettings
payitgo.webhooks.test() POST /v1/webhook/test WebhookDelivery
payitgo.webhooks.constructEvent(body, header, secret: …) none (local) WebhookEvent
Webhook.constructEvent(body, header, secret) none (local, no client) WebhookEvent
checkout.retrieve(checkoutToken) GET /checkout/{checkout_token} Payment
checkout.selectToken(checkoutToken, token) POST /checkout/{checkout_token}/token Payment
checkout.simulate(checkoutToken, outcome) POST /checkout/{checkout_token}/simulate Payment
checkout.watch(checkoutToken) GET /checkout/{checkout_token}, every 5 s Stream<Payment>
isLive() GET /v1/webhook, the first time only bool

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

The TokenInfo object.

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).