# 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`](#payitgo) | Your secret key | **Your server** (a Dart backend, Cloud Functions, a CLI) | Payments, tokens, your webhook (IPN) and its signature check |
| [`PayitgoCheckout`](#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](/docs/payments), [Webhooks](/docs/webhooks) and
[Checkout](/docs/checkout) pages describe everything this library returns.

| Method | HTTP | Returns |
|---|---|---|
| [`payitgo.payments.create(…)`](#paymentscreate) | `POST /v1/payments` | [`Payment`](#payment) |
| [`payitgo.payments.retrieve(id)`](#paymentsretrieve) | `GET /v1/payments/{id}` | [`Payment`](#payment) |
| [`payitgo.payments.list(…)`](#paymentslist) | `GET /v1/payments` | [`PaymentList`](#paymentlist) |
| [`payitgo.payments.selectToken(id, token)`](#paymentsselecttoken) | `POST /v1/payments/{id}/token` | [`Payment`](#payment) |
| [`payitgo.payments.simulate(id, outcome)`](#paymentssimulate) | `POST /v1/payments/{id}/simulate` | [`Payment`](#payment) |
| [`payitgo.tokens.list(amountUsd: …)`](#tokenslist) | `GET /v1/tokens` | [`List<TokenInfo>`](#tokeninfo) |
| [`payitgo.webhooks.get()`](#webhooksget) | `GET /v1/webhook` | [`WebhookSettings`](#webhooksettings) |
| [`payitgo.webhooks.set(url)`](#webhooksset) | `PUT /v1/webhook` | [`WebhookSettings`](#webhooksettings) |
| [`payitgo.webhooks.remove()`](#webhooksremove) | `DELETE /v1/webhook` | [`WebhookSettings`](#webhooksettings) |
| [`payitgo.webhooks.test()`](#webhookstest) | `POST /v1/webhook/test` | [`WebhookDelivery`](#webhookdelivery) |
| [`payitgo.webhooks.constructEvent(body, header, secret: …)`](#webhooksconstructevent) | none (local) | [`WebhookEvent`](#webhookevent) |
| [`Webhook.constructEvent(body, header, secret)`](#webhookconstructevent) | none (local, no client) | [`WebhookEvent`](#webhookevent) |
| [`checkout.retrieve(checkoutToken)`](#checkoutretrieve) | `GET /checkout/{checkout_token}` | [`Payment`](#payment) |
| [`checkout.selectToken(checkoutToken, token)`](#checkoutselecttoken) | `POST /checkout/{checkout_token}/token` | [`Payment`](#payment) |
| [`checkout.simulate(checkoutToken, outcome)`](#checkoutsimulate) | `POST /checkout/{checkout_token}/simulate` | [`Payment`](#payment) |
| [`checkout.watch(checkoutToken)`](#checkoutwatch) | `GET /checkout/{checkout_token}`, every 5 s | `Stream<Payment>` |
| [`isLive()`](#islive) | `GET /v1/webhook`, the first time only | `bool` |

`checkout` is a [`PayitgoCheckout`](#payitgocheckout), or `payitgo.checkout` on the
server client.

## Install

```bash
dart pub add payitgo      # Dart
flutter pub add payitgo   # Flutter
```

```dart
import 'package:payitgo/payitgo.dart';
```

| Requirement | |
|---|---|
| Dart | 3.0 or later (Flutter 3.10 or later) |
| Dependencies | [`http`](https://pub.dev/packages/http) 1.2+, [`crypto`](https://pub.dev/packages/crypto) 3.0+ |
| Platforms | Every Dart platform; the library does not import `dart:io` |

## Quickstart

On your server:

```dart
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](#webhooksset) and fulfil the order when
[`payment.confirmed`](#a-webhook-ipn-handler-with-shelf) arrives. In the app,
show the payment with [`PayitgoCheckout`](#a-flutter-payment-screen).

## Configuration

### Payitgo

```dart
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](/docs/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()`](#close) | The [`package:http`](https://pub.dev/packages/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](#retries) |
| `webhookSecret` | `String?` | no | none | Your mode's signing secret. [`webhooks.constructEvent`](#webhooksconstructevent) uses it when you pass none |
| `webhookUrl` | `String?` | no | none | Sent as `webhook_url` with every [`payments.create`](#paymentscreate) 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 |

```dart
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()`](#close) when you are done with it.

| Property | Type | Description |
|---|---|---|
| `payments` | `Payments` | [Payments](#payments) |
| `tokens` | `Tokens` | [Tokens](#tokens) |
| `webhooks` | `Webhooks` | [Webhooks](#webhooks) |
| `checkout` | [`PayitgoCheckout`](#payitgocheckout) | The key-less calls, with this client's `baseUrl`, `http.Client`, `maxRetries` and `timeout` |

### PayitgoCheckout

```dart
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()`](#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`](#checkoutretrieve),
[`selectToken`](#checkoutselecttoken), [`watch`](#checkoutwatch) and
[`close`](#close).

### close

```dart
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`.

```dart
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](/docs/webhooks#read-the-webhook)

```dart
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](#webhooksget); 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`](#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.

```dart
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](#paymentssimulate) 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()`](#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](#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.

```dart
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](/docs/errors)).

| `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](/docs/errors#rate-limits) | Yes |
| `500` and other `5xx` | Something failed on payitgo's side | Yes |

```dart
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`](#webhooksconstructevent) 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](/docs/payments#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](/docs/payments#create-a-payment)

```dart
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`](#paymentsselecttoken)) 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](/docs/payments#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`](#tokenslist)). 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](/docs/webhooks#per-payment-webhook-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](#paymentssimulate), 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`](#payment): the new one (`201`) or the order's open
one (`200`); both look the same. Fields: [the payment
object](/docs/payments#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](/docs/payments#errors). `503`
when no exchange rate can be had for `price_currency` (after the
[retries](#retries)): try again shortly. Also `401`, `403`, `429`,
`500` and `0` as for [every call](#payitgoexception).

```dart
// 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](/docs/payments#retrieve-a-payment)

```dart
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`](#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](#payitgoexception).

```dart
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](/docs/payments#list-payments)

```dart
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](/docs/payments#lifecycle), 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](/docs/account#stores) (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`](#paymentlist): `payments`, `total` (matches
across all pages), and the `limit` and `offset` applied.

**Errors** the [common statuses](#payitgoexception) only. An unknown status,
chain or token matches nothing.

```dart
// 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](/docs/payments#choose-the-coin-later)

```dart
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](#your-own-payment-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`](#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) |

```dart
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](/docs/testing#simulate-a-payment)

```dart
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](/docs/testing)). 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](/docs/testing#outcomes)) |

**Returns** the whole [`Payment`](#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'` |

```dart
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](/docs/payments#list-tokens)

```dart
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`](#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](#payitgoexception).

```dart
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](/docs/webhooks#the-webhook-api)). A test key manages the test
webhook, a live key the live one. A [storefront
key](/docs/authentication#storefront-keys) can `get` and `test` it, but not
`set` or `remove` it.

`Webhooks.events` lists the [event types](/docs/webhooks#events).

### webhooks.get

`GET /v1/webhook` · [API reference](/docs/webhooks#read-the-webhook)

```dart
Future<WebhookSettings> get()
```

**Returns** the [`WebhookSettings`](#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.

```dart
final webhook = await payitgo.webhooks.get();
print('${webhook.url} ${webhook.secret}');
```

### webhooks.set

`PUT /v1/webhook` · [API reference](/docs/webhooks#set-the-url)

```dart
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`](#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](#payitgoexception).

```dart
await payitgo.webhooks.set('https://acme.example/payitgo/ipn');
```

### webhooks.remove

`DELETE /v1/webhook` · [API reference](/docs/webhooks#remove-the-url)

```dart
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`](#webhooksettings) with `url` `null`.

**Errors** `403` for a storefront key, `500` (`could not remove the webhook`),
and the [common statuses](#payitgoexception).

### webhooks.test

`POST /v1/webhook/test` · [API reference](/docs/webhooks#send-a-test)

```dart
Future<WebhookDelivery> test()
```

Queues a signed `ping` notification to the mode's URL; it arrives within about
5 seconds.

**Returns** the [`WebhookDelivery`](#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](#payitgoexception).

```dart
final delivery = await payitgo.webhooks.test();
print('${delivery.id} ${delivery.eventType} ${delivery.url}'); // 6710c3a1f2b4e5d6a7b8c9d0 ping https://…
```

### webhooks.constructEvent

No request · [how verification works](/docs/webhooks#verifying-the-signature)

```dart
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](/docs/webhooks#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`](#webhookevent).

**Errors** [`SignatureVerificationException`](#signatureverificationexception).
Answer `400`.

```dart
final event = payitgo.webhooks.constructEvent(rawBody, request.headers['x-webhook-signature']);
if (event.type == 'payment.confirmed') print(Payment(event.data).orderId);
```

### Webhook.constructEvent

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

```dart
final event = Webhook.constructEvent(rawBody, signatureHeader, Platform.environment['PAYITGO_WEBHOOK_SECRET']!);
```

**Errors** [`SignatureVerificationException`](#signatureverificationexception),
except the "no webhook secret" one.

## Checkout

The key-less calls of the [public checkout API](/docs/checkout#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`](#payitgocheckout) in your app, or as
`payitgo.checkout` on the server.

### checkout.retrieve

`GET /checkout/{checkout_token}` · [API reference](/docs/checkout#read-the-payment)

```dart
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`](#payment).

**Errors** `PayitgoException` `404` (`payment not found`), `0`, `429` and `5xx`.

### checkout.selectToken

`POST /checkout/{checkout_token}/token` · [API reference](/docs/checkout#choosing-the-coin)

```dart
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`](#payment) with its coin,
address, amount and a 15-minute deadline.

**Errors** the same as [`payments.selectToken`](#paymentsselecttoken), and
`429` (`rate limit exceeded`) beyond 5 choices a second from one IP address.

```dart
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](/docs/testing#simulate-a-payment)

```dart
Future<Payment> simulate(String checkoutToken, String outcome)
```

[`payments.simulate`](#paymentssimulate) 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](/docs/checkout#follow-it-live) (`payment.simulation` events say when
it starts and ends), or with [`checkout.watch`](#checkoutwatch). 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`](#payment), with its
`simulation` started.

**Errors** the same as [`payments.simulate`](#paymentssimulate), and `429`
(`rate limit exceeded`) beyond 5 requests a second from one IP address.

```dart
if (!payment.livemode && payment.simulation?.state != 'running') {
  await checkout.simulate(checkoutToken, 'confirmed');
}
```

### checkout.watch

`GET /checkout/{checkout_token}`, repeated

```dart
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](/docs/checkout#follow-it-live) 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`](https://pub.dev/packages/socket_io_client) package:

```dart
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](/docs/payments#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](/docs/checkout#follow-it-live) sends every change, confirmation
counts included.

```dart
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](/docs/payments#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) | `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](/docs/payments#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](/docs/testing#simulate-a-payment).
`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`](#paymentslist) 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](/docs/payments#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](/docs/webhooks#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](/docs/webhooks#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](/docs/webhooks#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](#payment) for `payment.*` events (wrap it:
`Payment(event.data)`), the [settlement](/docs/settlement#following-settlements)
for `settlement.*`, the [payout](/docs/account#the-payout-object) 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](/docs/checkout#what-to-show)). With
[`shelf`](https://pub.dev/packages/shelf), and [`qr`](https://pub.dev/packages/qr)
for the QR code:

```dart
// 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`:

```dart
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](/docs/checkout#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`](#checkoutwatch): the same `Payment`, from each event's
`payment`). With [`qr_flutter`](https://pub.dev/packages/qr_flutter)
for the QR code:

```dart
// 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](/docs/webhooks#answering-and-retries)):

```dart
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](/docs/webhooks#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](/docs/testing)).

```dart
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](/docs/testing#never-send-real-coins-to-a-test-address)).

Check your verification against the example in
[the recipe](/docs/webhooks#the-recipe) (tolerance zero, because the example
is old):

```dart
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`:

```dart
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](/docs/testing#going-live)).
