PHP library

The payitgo/payitgo-php package wraps the payitgo API for PHP: payments, tokens, your webhook (IPN) and its signature check, and the public checkout calls. It needs only the curl and json extensions. Use it on your server only: it holds your secret key.

Responses are associative arrays with the API's own keys (order_id, checkout_url, …), so the Payments, Webhooks and Checkout pages describe every field this library returns.

Method HTTP Returns
$payitgo->payments->create($params) POST /v1/payments Payment
$payitgo->payments->retrieve($id) GET /v1/payments/{id} Payment
$payitgo->payments->list($params) GET /v1/payments Payment list
$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 of TokenInfo
$payitgo->webhooks->get() GET /v1/webhook Webhook
$payitgo->webhooks->set($url) PUT /v1/webhook Webhook
$payitgo->webhooks->remove() DELETE /v1/webhook Webhook
$payitgo->webhooks->test() POST /v1/webhook/test Delivery
$payitgo->webhooks->constructEvent($payload, $header, $secret, $tolerance) none (local) Event
\Payitgo\Webhook::constructEvent($payload, $header, $secret, $tolerance) none (local, no client) Event
$payitgo->checkout->retrieve($checkoutToken) GET /checkout/{checkout_token} Payment
$payitgo->checkout->selectToken($checkoutToken, $token) POST /checkout/{checkout_token}/token Payment
$payitgo->isLive() GET /v1/webhook, the first time only bool

Install

composer require payitgo/payitgo-php
Requirement
PHP 8.1 or later
Extensions curl, json
Autoloading PSR-4, namespace Payitgo\ (Composer's vendor/autoload.php)
Class What it is
Payitgo\Client The client
Payitgo\Payments, Payitgo\Tokens, Payitgo\Webhooks, Payitgo\Checkout Its resources, reached as $payitgo->payments and so on
Payitgo\Webhook constructEvent without a client
Payitgo\Exception\PayitgoException A failed API call
Payitgo\Exception\SignatureVerificationException A notification that failed verification

Quickstart

<?php
require __DIR__ . '/vendor/autoload.php';

$payitgo = new \Payitgo\Client(getenv('PAYITGO_KEY'), ['base_url' => 'https://api.payitgo.co']);

$payment = $payitgo->payments->create([
    'order_id' => 'ORD-1001',
    'amount_usd' => '49.90',
    'token' => 'USDT_TRC20',
]);
echo $payment['crypto_amount'], ' ', $payment['token'], ' to ', $payment['deposit_address'], PHP_EOL;

Then set your webhook URL and fulfil the order when payment.confirmed arrives.

Configuration

new Client

new \Payitgo\Client(string $apiKey, array $options = [])
Parameter Type Required Default Description
$apiKey string yes Your secret key, test or live (Authentication). An empty key throws
$options['base_url'] string no PAYITGO_BASE_URL, then the address the package was released with The gateway's address, https://api.payitgo.co. Trailing slashes are removed. Leave the key out (or null) to use the default; an empty string throws
$options['timeout'] int no 30 Seconds one attempt may take, from connecting to the last byte (CURLOPT_TIMEOUT). A timeout counts as a network error, so it is retried
$options['max_retries'] int no 2 How many times a request is retried after a network error, a 429 or a 5xx. 0 turns retries off
$options['webhook_secret'] string no none Your mode's signing secret. webhooks->constructEvent() uses it when you pass none
$options['webhook_url'] string no none Sent as webhook_url with every payments->create() that does not pass its own

The constructor makes no request. It throws \InvalidArgumentException when:

Message Cause
payitgo: the API key is required $apiKey is empty
payitgo: set base_url (or PAYITGO_BASE_URL) to your gateway's address No base_url, no PAYITGO_BASE_URL, and no address built into the package
$payitgo = new \Payitgo\Client(getenv('PAYITGO_KEY'), [
    'base_url' => 'https://api.payitgo.co',                     // or set PAYITGO_BASE_URL
    'timeout' => 15,
    'max_retries' => 2,
    'webhook_secret' => getenv('PAYITGO_WEBHOOK_SECRET'), // the signing secret of the key's mode
    'webhook_url' => 'https://acme.example/payitgo/ipn',  // optional default for payments->create()
]);

Properties

All are public readonly.

Property Type Description
$payitgo->payments Payitgo\Payments Payments
$payitgo->tokens Payitgo\Tokens Tokens
$payitgo->webhooks Payitgo\Webhooks Webhooks
$payitgo->checkout Payitgo\Checkout Checkout

\Payitgo\Client::VERSION is '0.1.0', sent in the User-Agent: payitgo-php/0.1.0 header.

isLive

GET /v1/webhook · API reference

public function isLive(): bool

Returns true for a live key, false for a test key. A key does not say its mode, so the first call asks payitgo (the livemode of the key's webhook; a read-only key may call it) and the answer is kept for the client's life: later calls make no request. A PHP script that builds its client on every request asks once per request.

Throws 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 (!$payitgo->isLive()) {
    echo "test mode: payments are simulated\n";
}

Base URL

The gateway address is taken from, in order: the base_url option, the PAYITGO_BASE_URL environment variable (getenv), and the address the package was released with:

export PAYITGO_BASE_URL=https://api.payitgo.co

Test and live mode

The key decides the mode. A test key creates and reads test payments (no coins: you simulate what the customer sends) and manages the test webhook; a live key does the same for live ones. The key does not say which it is: the dashboard shows a Test or Live badge next to it, and $payitgo->isLive() asks payitgo. Pair each key with the webhook secret of the same mode. See Test mode.

HTTP client

Requests go through the curl extension, with the headers Authorization: Bearer <key>, Accept: application/json, Content-Type: application/json (when there is a body) and User-Agent: payitgo-php/0.1.0. There is no option to pass your own HTTP client (Guzzle, PSR-18).

Errors

PayitgoException

Every call that reaches the API throws \Payitgo\Exception\PayitgoException (a \RuntimeException) when the request fails.

class PayitgoException extends \RuntimeException
{
    public readonly int $status;
}
Member Type Description
$e->status int The HTTP status, or 0 when no answer came (network error or timeout, after the retries). $e->getCode() is the same
$e->getMessage() 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. payitgo: request failed: <curl error> for a network error

Branch on status; log the message (its wording may change, see Errors and limits).

status Meaning Retried by the library
0 No answer: DNS, connection, TLS or timeout Yes
400 The request is invalid (the message says which field) No
401 missing api key or invalid api key No
403 Not allowed, for example a live key while live mode is off, or a storefront key changing the account's webhook No
404 No such payment in the key's mode No
409 Conflicts with the current state, such as choosing a payment's coin twice, or simulating a payment whose simulation is running No
429 Over the rate limit Yes
500 and other 5xx Something failed on payitgo's side Yes
use Payitgo\Exception\PayitgoException;

try {
    $payitgo->payments->create(['order_id' => 'ORD-1', 'amount_usd' => 1, 'token' => 'BTC']);
} catch (PayitgoException $e) {
    if ($e->status !== 400) {
        throw $e;
    }
    error_log('payitgo refused: ' . $e->getMessage()); // e.g. an amount under the coin's minimum
}

A request whose parameters cannot be encoded as JSON (a string that is not valid UTF-8, for example) throws \JsonException before anything is sent.

SignatureVerificationException

constructEvent() throws \Payitgo\Exception\SignatureVerificationException (a \RuntimeException) when a notification cannot be trusted. Answer 400. getMessage() says why:

Message Cause
missing or malformed X-Webhook-Signature header No header, or no t or v1 in it
signature does not match The body, the timestamp or the secret differ from what payitgo signed
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 webhook_secret The client method got no secret and the client has no webhook_secret

A body that is correctly signed but is not JSON throws \JsonException; payitgo never sends one.

Retries

A request is retried, up to max_retries times (default 2, so at most 3 attempts), after a network error or timeout, a 429 or a 5xx. It waits 0.5 s before the first retry and 1 s before the second. Other statuses are never retried. When the last attempt fails, the call 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

public function create(array $params): array

Creates a payment for one of your orders. With a token, the payment comes back with its deposit address, amount, QR payload and a 15-minute deadline. Without one, it comes back with token_options and the customer (or your page, with selectToken()) chooses the coin. While the order has an open payment, creating it again returns that payment.

Key of $params Type Required Description
order_id string yes Your id for the order, 1 to 128 bytes. One open payment per order and mode
amount_usd string, int or float yes, unless price_amount + price_currency The price in US dollars, such as '49.90'. Sent as a string; prefer a string to a float
price_amount string, int or float with price_currency Instead of amount_usd: the price in another currency, such as '45.50', which payitgo converts into dollars at the rate of the moment (prices in other currencies). Sent as a string; prefer a string to a float
price_currency string with price_amount The ISO 4217 code of its currency, such as 'EUR', 'GBP' or 'NGN' (any case)
token string no The coin, such as 'USDT_TRC20' (from tokens->list()). Leave it out to let the customer choose
tokens list of strings no Without token: the coins to offer, such as ['USDT_TRC20', 'USDT_BEP20']. Default: every coin you accept whose minimum the amount reaches
description string no What the customer pays for, up to 500 bytes
metadata array of string to string no Your own data: up to 20 keys of up to 40 bytes, values of up to 500
customer_email string no The customer's email address
success_url string no http(s) URL the hosted checkout links to once the payment is confirmed
cancel_url string no http(s) URL the hosted checkout links back to if the payment closes unpaid
webhook_url string no https:// URL that gets this payment's notifications instead of your account's webhook URL (per-payment URL). Left out (or null): the client's webhook_url. '': none for this payment, even with a client default
simulate string no Test keys only: the outcome to simulate, one of \Payitgo\Payments::SIMULATION_OUTCOMES. With token it starts at once; without, once the coin is chosen

Every key is sent to the API as it is (the API ignores keys it does not know).

Returns the Payment array: 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:
$payment = $payitgo->payments->create([
    'order_id' => 'ORD-1001',
    'amount_usd' => '49.90',
    'token' => 'USDT_TRC20',
    'description' => '2 x Acme T-shirt',
    'metadata' => ['customer_id' => 'cus_42'],
    'customer_email' => 'jane@example.com',
    'success_url' => 'https://acme.example/thanks',
    'cancel_url' => 'https://acme.example/cart',
]);
// $payment['deposit_address'], $payment['crypto_amount'], $payment['qr_payload'], $payment['expires_at']

// The customer chooses the coin (on the hosted page, or yours):
$open = $payitgo->payments->create(['order_id' => 'ORD-1002', 'amount_usd' => 25]);
foreach ($open['token_options'] ?? [] as $option) {
    echo $option['token'], ' ', $option['min_usd'], ' ', $option['amount_due_usd'] ?? '', PHP_EOL;
}

// This payment's notifications go to its own URL:
$payitgo->payments->create([
    'order_id' => 'ORD-1003',
    'amount_usd' => 30,
    'token' => 'USDT_TRC20',
    'webhook_url' => 'https://acme.example/orders/ORD-1003/ipn',
]);

// Priced in euros: converted into dollars at the rate of the moment.
$priced = $payitgo->payments->create(['order_id' => 'ORD-1004', 'price_amount' => '45.50', 'price_currency' => 'EUR']);
// $priced['price_amount'] '45.5', $priced['price_currency'] 'EUR', $priced['price_rate'] '1.082', $priced['usd_amount'] '49.23'

payments->retrieve

GET /v1/payments/{id} · API reference

public function retrieve(string $id): array
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 array.

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.

$byId = $payitgo->payments->retrieve('8f3a2c1e9b7d4a6f05e1c2d3');
$byOrder = $payitgo->payments->retrieve('ORD-1001'); // the order's open payment
if ($byId['status'] === 'confirmed') {
    echo 'paid at ', $byId['confirmed_at'], PHP_EOL;
}

payments->list

GET /v1/payments · API reference

public function list(array $params = []): array

Payments of the key's mode, newest first.

Key of $params 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'
store_id string no all Only payments of this store (ignored for a storefront key)
limit int no 50 Page size, 1 to 200 (outside that range the API uses 50)
offset int no 0 How many to skip

Keys whose value is null or '' are left out of the query; the others are sent as they are.

Returns a Payment list array: payments, total (matches across all pages), and the limit and offset applied.

Errors the common statuses only. An unknown status, chain or token matches nothing.

// Every confirmed payment, page by page:
$confirmed = [];
for ($offset = 0; ; $offset += $page['limit']) {
    $page = $payitgo->payments->list(['status' => 'confirmed', 'limit' => 100, 'offset' => $offset]);
    array_push($confirmed, ...$page['payments']);
    if ($offset + $page['limit'] >= $page['total']) {
        break;
    }
}

payments->selectToken

POST /v1/payments/{id}/token · API reference

public function selectToken(string $id, string $token): array

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 token_options[].token, such as 'USDT_TRC20'

Returns the whole Payment, now with deposit_address, crypto_amount, rate_locked, qr_payload and an expires_at 15 minutes away.

Errors PayitgoException:

status Message Cause
400 token is required Empty $token
400 token is not one of this payment's options A coin not in token_options
400 minimum payment in … is $… right now (it is converted on its way to the merchant, and the exchange takes no less) The coin needs a bigger payment than this one: it is converted through the exchange on its way to the merchant, which takes no less, and the merchant pays the fees or hides such coins (hide_below_minimum). Offer the other coins
400 this payment can no longer be paid The payment is no longer pending, or its hour to choose has passed
404 payment not found No such payment in the key's mode
409 the coin for this payment has already been chosen The coin was set before (by you or on the hosted page)
$payment = $payitgo->payments->create(['order_id' => 'ORD-1002', 'amount_usd' => 25]);
// …your page lists $payment['token_options'], the customer picks one…
$chosen = $payitgo->payments->selectToken($payment['id'], 'USDT_BEP20');
echo $chosen['crypto_amount'], ' ', $chosen['token'], ' to ', $chosen['deposit_address'], PHP_EOL;

payments->simulate

POST /v1/payments/{id}/simulate · API reference

public function simulate(string $id, string $outcome): array

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 \Payitgo\Payments::SIMULATION_OUTCOMES, such as 'confirmed' (what each does)

Returns the whole Payment, with its simulation started. Simulated deposits have 'simulated' => true; their tx_id names no transaction on any chain.

Errors 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'
$payment = $payitgo->payments->simulate('ORD-1002', 'partially_paid');
echo $payment['simulation']['outcome'], ' ', $payment['simulation']['state'], PHP_EOL; // partially_paid running
// once it is done, pay the rest:
$payitgo->payments->simulate('ORD-1002', 'confirmed');

Tokens

tokens->list

GET /v1/tokens · API reference

public function list(string|int|float|null $amountUsd = null): array

The coins your customers can pay with in the key's mode, with today's network fee and minimum. With an amount, only the coins a payment of that amount offers (its token_options), each with amount_due_usd when you pass the fees on.

Parameter Type Required Description
$amountUsd string, int, float or null no The order's amount, such as '49.90'

Returns a list of TokenInfo arrays (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.

foreach ($payitgo->tokens->list('49.90') as $t) {
    printf("%s on %s: pay $%s, %s\n", $t['token'], $t['chain'], $t['amount_due_usd'] ?? '49.90', $t['fee_note']);
}

Webhooks

The webhook (IPN) of the key's mode: one URL that receives every notification of that mode, and the secret that signs them (Webhooks). A test key manages the test webhook, a live key the live one. A storefront key can get() and test() it, but not set() or remove() it.

\Payitgo\Webhooks::EVENTS lists the event types.

webhooks->get

GET /v1/webhook · API reference

public function get(): array

Returns the Webhook array: url (null when none is set) and secret, the signing secret to give the client as webhook_secret. It always succeeds for a valid key.

$webhook = $payitgo->webhooks->get();
echo $webhook['url'] ?? '(none)', ' ', $webhook['secret'], PHP_EOL;

webhooks->set

PUT /v1/webhook · API reference

public function set(string $url): array
Parameter Type Required Description
$url string yes An absolute https:// URL on a public host that accepts POST

Returns the Webhook with its new url. The secret does not change. Setting the URL it already has changes nothing.

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

$payitgo->webhooks->set('https://acme.example/payitgo/ipn');

webhooks->remove

DELETE /v1/webhook · API reference

public function remove(): array

Stops posting the mode's notifications to its URL. Payments created with their own webhook_url still get theirs, and the secret is kept.

Returns the Webhook with 'url' => 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

public function test(): array

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

Returns the Delivery array, 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.

$delivery = $payitgo->webhooks->test();
echo $delivery['id'], ' ', $delivery['event_type'], ' ', $delivery['url'], PHP_EOL; // 6710c3a1f2b4e5d6a7b8c9d0 ping https://…

webhooks->constructEvent

No request · how verification works

public function constructEvent(
    string $payload,
    ?string $signatureHeader,
    ?string $secret = null,
    int $tolerance = \Payitgo\Webhook::DEFAULT_TOLERANCE, // 300
): array

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 (hash_equals), checks that t is within the tolerance of your clock, and only then decodes the body.

Parameter Type Required Description
$payload string yes The raw request body, exactly as received: file_get_contents('php://input'), or $request->getContent() in Laravel and Symfony. Never json_encode($_POST) or a decoded array (keep the raw body)
$signatureHeader ?string yes The X-Webhook-Signature header: $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null
$secret ?string no The signing secret, as payitgo gave it. Default: the client's webhook_secret. A secret passed here wins
$tolerance int no How far t may be from your clock, in seconds. Default 300. 0 turns the timestamp check off (only for tests with captured bodies)

Returns the Event array.

Errors SignatureVerificationException. Answer 400.

$event = $payitgo->webhooks->constructEvent(
    file_get_contents('php://input'),
    $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null,
);
if ($event['type'] === 'payment.confirmed') {
    echo $event['data']['order_id'];
}

Webhook::constructEvent

public static function constructEvent(
    string $payload,
    ?string $signatureHeader,
    string $secret,
    int $tolerance = self::DEFAULT_TOLERANCE, // 300
): array

The same check without a client, for code that has the secret but no API key. $secret is required. \Payitgo\Webhook::DEFAULT_TOLERANCE is 300.

$event = \Payitgo\Webhook::constructEvent($rawBody, $signatureHeader, getenv('PAYITGO_WEBHOOK_SECRET'));

Errors SignatureVerificationException, except the "no webhook secret" one.

Checkout

These two calls use the public checkout API: they are sent without your API key and are keyed by the payment's checkout_token. They return the payer's view of a payment. On your server you rarely need them: payments->retrieve() and payments->selectToken() return more. The client still needs a valid key to be created.

checkout->retrieve

GET /checkout/{checkout_token} · API reference

public function retrieve(string $checkoutToken): array
Parameter Type Required Description
$checkoutToken string yes The payment's checkout_token

Returns a Payment array without your private keys: merchant_id, metadata, customer_email, webhook_url, checkout_token and checkout_url are absent.

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

checkout->selectToken

POST /checkout/{checkout_token}/token · API reference

public function selectToken(string $checkoutToken, string $token): array
Parameter Type Required Description
$checkoutToken string yes The payment's checkout_token
$token string yes One of the payment's token_options[].token

Returns the payer's view of the Payment with its coin, address, amount and a 15-minute deadline.

Errors the same as payments->selectToken(), and 429 (rate limit exceeded) beyond 5 choices a second from one IP address.

$view = $payitgo->checkout->retrieve('b28d4f61e9a03c75d2e8f1a6');
if ($view['token'] === '') {
    $chosen = $payitgo->checkout->selectToken('b28d4f61e9a03c75d2e8f1a6', 'USDT_TRC20');
    echo $chosen['deposit_address'], PHP_EOL;
}

Types

Every response is an associative array decoded from the API's JSON (json_decode($body, true)). Amounts are decimal strings ('49.9'): compare and add them with bcmath or a decimal library, not floats. Times are RFC 3339 strings ('2026-10-10T09:15:00.112Z'): new \DateTimeImmutable($value) reads them. Optional keys are absent when they have no value: read them with ??.

The tables list each array's keys and their PHP types; the linked API pages say what each one means.

Payment

The payment object.

Key Type
id, order_id, status string
merchant_id string (absent from the checkout view)
store_id string, optional
token, chain, deposit_address string ('' until the coin is chosen)
token_options list of TokenInfo, optional (only while token is '')
destination_tag int, optional (XRP only)
livemode, fees_paid_by_customer bool
simulation array, optional (a test payment's simulated outcome): outcome (one of \Payitgo\Payments::SIMULATION_OUTCOMES) and state ('requested', 'running' or 'done')
crypto_amount, received_amount, usd_amount, amount_due_usd, network_fee, network_fee_usd, received_usd, rate_locked string (decimal)
price_amount, price_rate string (decimal), optional (only for a payment priced in another currency)
price_currency string, optional (its ISO 4217 code, upper case: 'EUR')
settlement string: 'direct' or 'custodial'
tx_id string, optional
overpaid, paid_late bool, optional
deposits list of Deposit
expires_at, created_at string (RFC 3339)
confirmed_at string, optional
network_fee_note, qr_payload string ('' until the coin is chosen)
checkout_token string (absent from webhooks and the checkout view)
checkout_url string, optional
description, customer_email, success_url, cancel_url, webhook_url string, optional
metadata array of string, optional

Deposit

The deposit object.

Key Type
tx_id, token, chain, status, seen_at string
amount, usd_value string (decimal)
counted bool
late bool, optional
simulated bool, optional (played by a test payment's simulation: its tx_id is on no chain)
confirmed_at, from, made_at, failed_at, fail_reason string, optional
block int, optional

Payment list

What payments->list() returns.

Key Type
payments list of Payment
total, limit, offset int

TokenInfo

The TokenInfo object.

Key Type
token, chain, fee_note string
min_usd, network_fee_usd string (decimal)
amount_due_usd string (decimal), optional: with an amount, when you pass the fees on

Webhook

The webhook object.

Key Type
livemode bool
url ?string (null when none is set)
secret string (signs the mode's notifications)
created_at, updated_at string

Delivery

The delivery object.

Key Type
id, merchant_id, url, event_id, event_type, payload, next_attempt_at, created_at string
target string: 'account' or 'payment'
status string: 'pending', 'sending', 'delivered', 'failed' or 'resolved'
attempts, max_attempts int
last_status_code int, optional
last_error, last_attempt_at, delivered_at, resolved_at, resolution_note string, optional
history list, optional; each entry has at (string) and, when they apply, url, signed, error, response, note (string), status_code, millis (int)

Event

What constructEvent() returns (the body).

Key Type
id string (the same on every retry: deduplicate on it)
type string: one of \Payitgo\Webhooks::EVENTS
livemode bool
created_at string
data array: the Payment for payment.*, the settlement for settlement.*, the payout for payout.*, ['message' => 'pong'] for ping

\Payitgo\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

Show the coins on your own site, create the payment in the coin the customer picks, and render the amount, address, QR code and deadline from the payment itself (what to show). Plain PHP, with chillerlan/php-qrcode for the QR code:

<?php
// pay.php?order=ORD-1001 — composer require payitgo/payitgo-php chillerlan/php-qrcode
require __DIR__ . '/vendor/autoload.php';

use chillerlan\QRCode\QRCode;
use Payitgo\Exception\PayitgoException;

$payitgo = new \Payitgo\Client(getenv('PAYITGO_KEY'));
$order = findOrder($_GET['order'] ?? '');      // your code: ['id' => …, 'total_usd' => '49.90']
$networks = [
    'tron' => 'Tron (TRC-20)', 'ethereum' => 'Ethereum (ERC-20)', 'bsc' => 'BNB Smart Chain (BEP-20)',
    'bitcoin' => 'Bitcoin', 'litecoin' => 'Litecoin', 'dogecoin' => 'Dogecoin', 'xrp' => 'XRP Ledger',
];
$h = fn ($s) => htmlspecialchars((string) $s, ENT_QUOTES);

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    // 1. The coins this order can be paid with, and what each costs the customer.
    echo '<form method="post">';
    foreach ($payitgo->tokens->list($order['total_usd']) as $t) {
        printf('<button name="token" value="%s">%s on %s: $%s</button>',
            $h($t['token']), $h($t['token']), $h($networks[$t['chain']] ?? $t['chain']),
            $h($t['amount_due_usd'] ?? $order['total_usd']));
    }
    echo '</form>';
    exit;
}

// 2. The customer picked one: create the payment in it and show it.
try {
    $p = $payitgo->payments->create([
        'order_id' => $order['id'],                // the same order again returns its open payment
        'amount_usd' => $order['total_usd'],
        'token' => (string) ($_POST['token'] ?? ''),
    ]);
} catch (PayitgoException $e) {
    error_log("payitgo {$e->status}: {$e->getMessage()}"); // log it; do not show it to the customer
    http_response_code(502);
    exit('We could not start this payment. Please try again.');
}

if (!$p['livemode']) {
    echo '<p><b>Test payment:</b> do not send coins to this address.</p>';
}
printf('<p>Send exactly <b>%s %s</b> on <b>%s</b> to</p><p><code>%s</code></p>',
    $h($p['crypto_amount']), $h(explode('_', $p['token'])[0]),
    $h($networks[$p['chain']] ?? $p['chain']), $h($p['deposit_address']));
if (isset($p['destination_tag'])) {
    printf('<p>Destination tag (required): <b>%d</b></p>', $p['destination_tag']);
}
printf('<img src="%s" width="220" height="220" alt="QR code">', (new QRCode())->render($p['qr_payload']));
printf('<p>Pay before <time datetime="%s">%s</time></p>', $h($p['expires_at']),
    $h((new DateTimeImmutable($p['expires_at']))->format('H:i T')));

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():

$p = $payitgo->payments->create(['order_id' => $order['id'], 'amount_usd' => $order['total_usd']]);
// render $p['token_options'] (each with token, chain, min_usd, amount_due_usd, fee_note)…
// …and when the customer picks one:
$p = $payitgo->payments->selectToken($p['id'], $pickedToken);
// render $p as above

To follow the payment live on the page, connect the page's script to the socket's default namespace (/) and subscribe with $p['checkout_token'] (with an acknowledgement): every payment:update then carries the whole payment (follow it live). No polling. Fulfil the order from the webhook, never from the page.

A webhook (IPN) handler in plain PHP

Read the raw body, verify it, handle each event once, and answer 2xx within 10 seconds (answering and retries). Here the processed event ids go in a table with the event id as its primary key, in the same transaction as the fulfilment, so a failure rolls both back and payitgo's retry runs it again:

<?php
// ipn.php
require __DIR__ . '/vendor/autoload.php';

use Payitgo\Exception\SignatureVerificationException;

$payitgo = new \Payitgo\Client(getenv('PAYITGO_KEY'), [
    'webhook_secret' => getenv('PAYITGO_WEBHOOK_SECRET'), // the signing secret of the key's mode
]);

try {
    $event = $payitgo->webhooks->constructEvent(
        file_get_contents('php://input'),               // the raw body
        $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? null,
    );
} catch (SignatureVerificationException) {
    http_response_code(400);                            // not from payitgo, changed, or too old
    exit;
}

$db = new PDO(getenv('DATABASE_DSN'), getenv('DATABASE_USER'), getenv('DATABASE_PASSWORD'),
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$db->beginTransaction();
// CREATE TABLE payitgo_events (id VARCHAR(64) PRIMARY KEY)
$insert = $db->prepare('INSERT INTO payitgo_events (id) VALUES (?) ON CONFLICT DO NOTHING'); // MySQL: INSERT IGNORE
$insert->execute([$event['id']]);
if ($insert->rowCount() === 1 && $event['livemode'] === $payitgo->isLive()) { // first time, and this mode
    switch ($event['type']) {
        case 'payment.confirmed':                       // final: fulfil
            markOrderPaid($db, $event['data']['order_id'], $event['data']['id']);
            break;
        case 'payment.expired':                         // nothing arrived in time
            releaseOrder($db, $event['data']['order_id']);
            break;
    }
}
$db->commit();
http_response_code(200);

markOrderPaid and releaseOrder are your own code. If anything throws, PHP answers 500, the transaction is rolled back, and payitgo retries the notification. More in handle events safely.

A webhook (IPN) handler in Laravel

Bind the client once, in config/services.php and a service provider:

// config/services.php
'payitgo' => [
    'key' => env('PAYITGO_KEY'),
    'base_url' => env('PAYITGO_BASE_URL'),
    'webhook_secret' => env('PAYITGO_WEBHOOK_SECRET'),
],
// app/Providers/AppServiceProvider.php, in register():
$this->app->singleton(\Payitgo\Client::class, fn () => new \Payitgo\Client(config('services.payitgo.key'), [
    'base_url' => config('services.payitgo.base_url'),
    'webhook_secret' => config('services.payitgo.webhook_secret'),
]));

The controller reads the raw body with $request->getContent():

<?php
// app/Http/Controllers/PayitgoWebhookController.php
namespace App\Http\Controllers;

use App\Models\Order;                                    // your model
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Payitgo\Client;
use Payitgo\Exception\SignatureVerificationException;

class PayitgoWebhookController extends Controller
{
    public function __invoke(Request $request, Client $payitgo): Response
    {
        try {
            $event = $payitgo->webhooks->constructEvent(
                $request->getContent(),                  // the raw body, never $request->all()
                $request->header('X-Webhook-Signature'),
            );
        } catch (SignatureVerificationException) {
            return response('', 400);
        }

        DB::transaction(function () use ($event, $payitgo) {
            // payitgo_events: a table with the event id as its primary key
            if (DB::table('payitgo_events')->insertOrIgnore(['id' => $event['id']]) === 0) {
                return;                                  // a retry or redelivery
            }
            if ($event['livemode'] !== $payitgo->isLive()) {
                return;                                  // the other mode's event
            }
            match ($event['type']) {
                'payment.confirmed' => Order::markPaid($event['data']['order_id'], $event['data']['id']),
                'payment.expired' => Order::release($event['data']['order_id']),
                default => null,
            };
        });

        return response('', 200);
    }
}

Register the route and exempt it from CSRF protection (payitgo cannot send a CSRF token):

// routes/web.php
Route::post('/payitgo/ipn', \App\Http\Controllers\PayitgoWebhookController::class);

// bootstrap/app.php (Laravel 11 and later)
->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: ['payitgo/ipn']);
})

On Laravel 10 and earlier, add 'payitgo/ipn' to $except in app/Http/Middleware/VerifyCsrfToken.php instead. Order::markPaid and Order::release stand for your own code; make them safe to run twice.

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

$payitgo = new \Payitgo\Client(getenv('PAYITGO_TEST_KEY'), ['base_url' => 'https://api.payitgo.co']); // your test key
var_dump($payitgo->isLive()); // bool(false)

// Point the TEST webhook at your development server and read its secret.
$hook = $payitgo->webhooks->set('https://dev.acme.example/payitgo/ipn');
echo $hook['secret'], PHP_EOL; // test mode's signing secret: your dev PAYITGO_WEBHOOK_SECRET

// A signed ping arrives within seconds.
$payitgo->webhooks->test();

// A test payment that pays itself: payment.detecting, then payment.confirmed,
// arrive at the test webhook within half a minute.
$payment = $payitgo->payments->create([
    'order_id' => 'TEST-1', 'amount_usd' => 10, 'token' => 'USDT_TRC20', 'simulate' => 'confirmed',
]);
var_dump($payment['livemode'], $payment['simulation']['state']); // bool(false) string(7) "running"

// Another outcome on a payment you already have:
$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 0, because the example is old):

$body = '{"created_at":"2026-10-10T09:12:44.512837Z","data":{"message":"pong"},"id":"5c0e1f9a2b7d4e3c8a6f1b2d","livemode":false,"type":"ping"}';
$event = \Payitgo\Webhook::constructEvent(
    $body,
    't=1791623565,v1=1af0ba31872e3152c422edaf9442c9f0eb4ebd202beebafbaaba14f77336194b',
    'a3858862701d1ce11ab06cb5dd9bb941d4849283f81fc0c3f0db0d99a2b82d48',
    0,
);
echo $event['type'], PHP_EOL; // ping

To test your handler without the network, sign a body yourself:

function sign(string $body, string $secret, ?int $t = null): string
{
    $t ??= time();
    return 't=' . $t . ',v1=' . hash_hmac('sha256', $t . '.' . $body, $secret);
}
// POST $body to your handler with the header X-Webhook-Signature: sign($body, $secret)

Going live: put the live key and the live webhook secret in your production environment, set the live webhook URL with the live key, and keep the livemode check in your handler (going live).