payitgo 0.4.0
payitgo: ^0.4.0 copied to clipboard
Dart client for the payitgo crypto payment gateway, for Dart servers, with a key-less checkout client for Flutter apps.
payitgo for Dart and Flutter #
Accept crypto payments with payitgo from Dart 3.
Full reference, with every method, option, type and error: api.payitgo.co/docs/sdk-dart.
dart pub add payitgo # or: flutter pub add payitgo
The library has two clients:
Payitgouses your secret API key, for your server (a Dart backend, Cloud Functions, …). Never put a secret key in a Flutter app: anyone can extract it.PayitgoCheckoutneeds no key, for Flutter apps. Your backend creates the payment and hands the app itscheckout_token; the app shows the payment and follows it.
On your server: accept a payment in three steps #
import 'dart:io';
import 'package:payitgo/payitgo.dart';
final payitgo = Payitgo(
Platform.environment['PAYITGO_KEY']!,
baseUrl: Platform.environment['PAYITGO_BASE_URL']!,
webhookSecret: Platform.environment['PAYITGO_WEBHOOK_SECRET'], // the signing secret: see Webhooks (IPN) below
);
// 1. Create a payment and send the customer to the checkout page (or return the
// checkout_token to your Flutter app).
final payment = await payitgo.payments.create(
orderId: 'ORD-1001', // your order id: safe to retry
amountUsd: 19.99,
// token: 'USDT_TRC20', // optional: without it the customer picks the coin
successUrl: 'https://shop.example/thanks',
);
print(payment.checkoutUrl);
// 2. Receive the notification (shelf shown; the complete handler is under Webhooks (IPN) below).
// Use the raw body: the signature covers its exact bytes.
Future<Response> ipn(Request request) async {
final body = await request.readAsString();
final WebhookEvent event;
try {
event = payitgo.webhooks.constructEvent(body, request.headers['x-webhook-signature']);
} on SignatureVerificationException {
return Response(400);
}
// 3. Fulfil the order once the payment is final.
if (event.type == 'payment.confirmed') {
await markOrderPaid(event.data['order_id'] as String); // idempotent: events can arrive twice
}
return Response.ok('');
}
A key is a test key or a live key, and does not say which (the dashboard shows a Test or
Live badge next to it): await payitgo.isLive() asks payitgo, once.
On your server: prices in other currencies #
To price a payment in your own currency, give priceAmount and priceCurrency (an ISO
4217 code such as EUR, GBP or NGN) instead of amountUsd. payitgo converts the price
into dollars at the rate of the moment, and the payment keeps both:
final payment = await payitgo.payments.create(
orderId: 'ORD-1002',
priceAmount: '45.50', // sent as priceAmount.toString(): prefer a String to a double
priceCurrency: 'EUR', // any case
);
// payment.priceAmount '45.5', payment.priceCurrency 'EUR',
// payment.priceRate '1.082' (dollars per euro), payment.usdAmount '49.23'
Everything else (usdAmount, cryptoAmount, fees, minimums) stays in dollars; a payment
priced in dollars has null price getters. Give amountUsd or the pair, not both (400).
When no exchange rate can be had, create throws a PayitgoException with status 503
(exchange rate unavailable for price_currency: try again shortly) once the client's retries
are spent: try again shortly. PayitgoCheckout reads the same fields, so a Flutter app can
show the price in your currency next to the dollar amount.
On your server: your own payment page #
checkoutUrl is only for payitgo's hosted page. To show the payment on your own page,
render the payment your server gets back (depositAddress, cryptoAmount,
destinationTag for XRP, a QR code of qrPayload, expiresAt); no checkout calls are
needed.
- You know the coin: create the payment with
tokenand render the payment it returns. - The customer chooses it: create the payment without
token, showpayment.tokenOptionson your page, then callpayitgo.payments.selectToken(payment.id, chosen)from your server and render the payment it returns: it now has its coin, address, amount, locked rate and a deadline 15 minutes away.
final payment = await payitgo.payments.create(orderId: 'ORD-1', amountUsd: 10);
// your page shows payment.tokenOptions; the customer picks one
final chosen = await payitgo.payments.selectToken(payment.id, 'USDT_TRC20'); // or your order id
// render chosen.depositAddress, chosen.cryptoAmount, chosen.qrPayload, chosen.expiresAt
The customer can change the coin while the payment still has tokenOptions (nothing has
arrived yet): call payments.selectToken again with another. Once it can no longer change, it is a 409
(payments.retrieve then returns the payment with its coin). A coin outside tokenOptions, or a
payment that can no longer be paid, is a 400.
On your server: test mode #
A test payment gets an address like a live one, but no coins are involved: you choose an outcome and payitgo plays its deposits, status changes and webhooks a few seconds apart (Test mode).
// Choose the outcome when you create the payment (with token it starts at once,
// without it once the coin is chosen)…
await payitgo.payments.create(orderId: 'TEST-1', amountUsd: 10, token: 'USDT_TRC20', simulate: 'confirmed');
// …or later, on a payment you already have (its id or your order id).
final payment = await payitgo.payments.simulate('TEST-2', 'partially_paid');
// payment.simulation: outcome 'partially_paid', state 'running'
Outcomes (Payments.simulationOutcomes): confirmed, overpaid, partially_paid,
underpaid, expired, failed, wrong_asset, paid_late. Simulated deposits have
simulated true; their txId names no transaction on any chain. A live key gets 400; a
payment whose simulation is still running, 409.
On your server: Webhooks (IPN) #
payitgo POSTs a signed notification for every change of a payment (payment.confirmed,
payment.expired, …), of a direct settlement and of a payout. Each mode has one webhook:
a test key manages the test one, a live key the live one.
Where the notifications go. Set the URL once per mode (https:// on a public host, accepting POST requests), or give a payment its own when you create it:
// Once per mode (safe to repeat):
await payitgo.webhooks.set('https://shop.example/payitgo/ipn');
// Or per payment: its events go there instead of the webhook's URL.
await payitgo.payments.create(orderId: 'ORD-1', amountUsd: 10, webhookUrl: 'https://shop.example/orders/ipn');
// Or as the client's default for every payment it creates (a payment's own webhookUrl wins):
final shop = Payitgo(key, baseUrl: baseUrl, webhookUrl: 'https://shop.example/orders/ipn');
A storefront's key cannot set the account's URL (403): pass webhookUrl on its payments
instead. Payout events always go to the webhook's URL.
The signing secret. Every notification of a mode is signed with that mode's secret,
whichever URL it goes to. Copy it from the dashboard or read it with
(await payitgo.webhooks.get()).secret, and give it to the client once:
final payitgo = Payitgo(key, baseUrl: baseUrl, webhookSecret: Platform.environment['PAYITGO_WEBHOOK_SECRET']);
Receiving them (shelf). Verify the raw body, handle each event once, and answer 2xx
within 10 seconds; otherwise payitgo retries after 1 min, 5 min, 30 min, 2 h, 6 h and 24 h.
import 'package:shelf/shelf.dart';
Future<Response> ipn(Request request) async {
final WebhookEvent event;
try {
// Checks the signature with the client's webhookSecret and refuses anything older than 5 minutes.
event = payitgo.webhooks.constructEvent(
await request.read().expand((chunk) => chunk).toList(), // the raw body
request.headers['x-webhook-signature']);
} on SignatureVerificationException {
return Response(400);
}
// The same event can arrive more than once: handle each event id once,
// e.g. by inserting it into a table with a unique key.
if (!await recordEventOnce(event.id)) return Response.ok('');
switch (event.type) {
case 'payment.confirmed':
await jobs.enqueue('fulfil', event.data['order_id'] as String); // slow work off the request
case 'payment.expired':
await jobs.enqueue('release-stock', event.data['order_id'] as String);
}
return Response.ok('');
}
Testing it. await payitgo.webhooks.test() queues a signed ping event to the
webhook's URL and returns its delivery (400 when no URL is set).
In a Flutter app: show and follow a payment #
final checkout = PayitgoCheckout(baseUrl: 'https://api.example.com');
final payment = await checkout.retrieve(checkoutToken); // from your backend
// show payment.cryptoAmount, payment.token, payment.depositAddress,
// payment.destinationTag (XRP) and a QR code of payment.qrPayload
await for (final p in checkout.watch(checkoutToken)) { // polls every 5 s
if (p.isConfirmed) showPaid(); // ends once confirmed or failed
}
checkout.close(); // when the app no longer needs it
watch keeps following an expired or underpaid payment for the 24 hours in which a late
payment can still complete it (then the stream ends); cancel the subscription to stop sooner.
On a test payment (payment.livemode false), checkout.simulate(checkoutToken, 'confirmed')
plays an outcome with no coins, for "simulate" buttons on your test screens; watch shows it
(Test mode).
The app only shows status; your backend still fulfils orders from the
payment.confirmed webhook. Start with a test key, where you
simulate what the customer pays.
API #
final payitgo = Payitgo(apiKey,
baseUrl: 'https://api.example.com', // or the package's default address
maxRetries: 2, timeout: Duration(seconds: 30),
webhookSecret: secret, // for webhooks.constructEvent
webhookUrl: 'https://…'); // default webhookUrl of payments.create
await payitgo.isLive(); // false for a test key (asks payitgo once)
await payitgo.payments.create(orderId: …, amountUsd: …, token: …, description: …, metadata: {…},
customerEmail: …, successUrl: …, cancelUrl: …, webhookUrl: …, simulate: …);
await payitgo.payments.create(orderId: …, priceAmount: …, priceCurrency: …, …); // priced in another currency
await payitgo.payments.selectToken(paymentId, token); // or your order id: the payment with its coin
await payitgo.payments.simulate(paymentId, 'confirmed'); // test keys: plays the outcome
await payitgo.payments.retrieve(paymentId); // or your order id
await payitgo.payments.list(status: 'confirmed', storeId: storeId, limit: 50, offset: 0); // every filter optional
await payitgo.tokens.list(); // [TokenInfo(token, chain, minUsd, networkFeeUsd, feeNote)]
await payitgo.tokens.list(amountUsd: '49.90'); // the coins a $49.90 payment offers, with amountDueUsd (a num works too)
await payitgo.checkout.retrieve(checkoutToken);
await payitgo.webhooks.get(); // WebhookSettings(livemode, url, secret, createdAt, updatedAt)
await payitgo.webhooks.set(url); // WebhookSettings
await payitgo.webhooks.remove(); // WebhookSettings, url null (the secret stays)
await payitgo.webhooks.test(); // WebhookDelivery of a "ping" event
payitgo.webhooks.constructEvent(rawBody, signatureHeader, secret: …, tolerance: Duration(minutes: 5));
Webhook.constructEvent(rawBody, signatureHeader, secret, tolerance: Duration(minutes: 5)); // no client needed
Payment, Deposit, WebhookSettings and WebhookDelivery have typed getters (status,
checkoutUrl, webhookUrl, isConfirmed, a deposit's from and failReason, …) and raw
for every field. Webhooks.events lists the event types. payitgo.close() (and
PayitgoCheckout.close()) closes the http.Client the library made for itself; one you pass as
httpClient stays yours to close.
Errors #
try {
await payitgo.payments.create(orderId: 'ORD-1', amountUsd: 1, token: 'BTC');
} on PayitgoException catch (e) {
print('${e.status} ${e.message}'); // 400 minimum payment in BTC is $10 right now (network fees)
}
constructEvent throws SignatureVerificationException for a bad signature, a stale
timestamp, or when it has no secret (none passed and no webhookSecret).
Development #
dart pub get && dart test