# 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](/docs/payments),
[Webhooks](/docs/webhooks) and [Checkout](/docs/checkout) pages describe every
field this library returns.

| Method | HTTP | Returns |
|---|---|---|
| [`$payitgo->payments->create($params)`](#payments-create) | `POST /v1/payments` | [Payment](#payment) |
| [`$payitgo->payments->retrieve($id)`](#payments-retrieve) | `GET /v1/payments/{id}` | [Payment](#payment) |
| [`$payitgo->payments->list($params)`](#payments-list) | `GET /v1/payments` | [Payment list](#payment-list) |
| [`$payitgo->payments->selectToken($id, $token)`](#payments-selecttoken) | `POST /v1/payments/{id}/token` | [Payment](#payment) |
| [`$payitgo->payments->simulate($id, $outcome)`](#payments-simulate) | `POST /v1/payments/{id}/simulate` | [Payment](#payment) |
| [`$payitgo->tokens->list($amountUsd)`](#tokens-list) | `GET /v1/tokens` | list of [TokenInfo](#tokeninfo) |
| [`$payitgo->webhooks->get()`](#webhooks-get) | `GET /v1/webhook` | [Webhook](#webhook) |
| [`$payitgo->webhooks->set($url)`](#webhooks-set) | `PUT /v1/webhook` | [Webhook](#webhook) |
| [`$payitgo->webhooks->remove()`](#webhooks-remove) | `DELETE /v1/webhook` | [Webhook](#webhook) |
| [`$payitgo->webhooks->test()`](#webhooks-test) | `POST /v1/webhook/test` | [Delivery](#delivery) |
| [`$payitgo->webhooks->constructEvent($payload, $header, $secret, $tolerance)`](#webhooks-constructevent) | none (local) | [Event](#event) |
| [`\Payitgo\Webhook::constructEvent($payload, $header, $secret, $tolerance)`](#webhookconstructevent) | none (local, no client) | [Event](#event) |
| [`$payitgo->checkout->retrieve($checkoutToken)`](#checkout-retrieve) | `GET /checkout/{checkout_token}` | [Payment](#payment) |
| [`$payitgo->checkout->selectToken($checkoutToken, $token)`](#checkout-selecttoken) | `POST /checkout/{checkout_token}/token` | [Payment](#payment) |
| [`$payitgo->isLive()`](#islive) | `GET /v1/webhook`, the first time only | bool |

## Install

```bash
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
<?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](#webhooks-set) and fulfil the order when
[`payment.confirmed`](#a-webhook-ipn-handler-in-plain-php) arrives.

## Configuration

### new Client

```php
new \Payitgo\Client(string $apiKey, array $options = [])
```

| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| `$apiKey` | string | yes | | Your secret key, test or live ([Authentication](/docs/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](#retries) |
| `$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()`](#webhooks-constructevent) uses it when you pass none |
| `$options['webhook_url']` | string | no | none | Sent as `webhook_url` with every [`payments->create()`](#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 |

```php
$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](#payments) |
| `$payitgo->tokens` | `Payitgo\Tokens` | [Tokens](#tokens) |
| `$payitgo->webhooks` | `Payitgo\Webhooks` | [Webhooks](#webhooks) |
| `$payitgo->checkout` | `Payitgo\Checkout` | [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](/docs/webhooks#read-the-webhook)

```php
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](#webhooks-get); 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](#errors): `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.

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

```bash
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](#payments-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()`](#islive) asks payitgo. Pair each key with the
webhook secret of the same mode. See [Test mode](#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.

```php
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](/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 | 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 |

```php
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()`](#webhooks-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](/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)

```php
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()`](#payments-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](/docs/payments#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()`](#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](/docs/webhooks#per-payment-webhook-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](#payments-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](#payment) array: 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).

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

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

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

```php
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](/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'` |
| `store_id` | string | no | all | Only payments of this [store](/docs/account#stores) (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](#payment-list) array: `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.

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

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

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

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

```php
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](/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 `\Payitgo\Payments::SIMULATION_OUTCOMES`, such as `'confirmed'` ([what each does](/docs/testing#outcomes)) |

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

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

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

```php
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](/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.

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

### webhooks->get

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

```php
public function get(): array
```

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

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

### webhooks->set

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

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

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

### webhooks->remove

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

```php
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](#webhook) 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)

```php
public function test(): array
```

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

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

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

### webhooks->constructEvent

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

```php
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](/docs/webhooks#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](#event) array.

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

```php
$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

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

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

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

## Checkout

These two calls use the [public checkout API](/docs/checkout#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](/docs/checkout#read-the-payment)

```php
public function retrieve(string $checkoutToken): array
```

| Parameter | Type | Required | Description |
|---|---|---|---|
| `$checkoutToken` | string | yes | The payment's `checkout_token` |

**Returns** a [Payment](#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](/docs/checkout#choosing-the-coin)

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

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

```php
$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](/docs/payments#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](#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](/docs/testing#simulate-a-payment)): `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](/docs/payments#prices-in-other-currencies)) |
| `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](#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](/docs/payments#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()`](#payments-list) returns.

| Key | Type |
|---|---|
| `payments` | list of [Payment](#payment) |
| `total`, `limit`, `offset` | int |

### TokenInfo

[The TokenInfo object](/docs/payments#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](/docs/webhooks#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](/docs/webhooks#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](/docs/webhooks#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](#payment) for `payment.*`, the [settlement](/docs/settlement#following-settlements) for `settlement.*`, the [payout](/docs/account#the-payout-object) 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](/docs/checkout#what-to-show)). Plain PHP, with
[`chillerlan/php-qrcode`](https://packagist.org/packages/chillerlan/php-qrcode)
for the QR code:

```php
<?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()`:

```php
$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](/docs/checkout#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](/docs/webhooks#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
<?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](/docs/webhooks#handle-events-safely).

### A webhook (IPN) handler in Laravel

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

```php
// config/services.php
'payitgo' => [
    'key' => env('PAYITGO_KEY'),
    'base_url' => env('PAYITGO_BASE_URL'),
    'webhook_secret' => env('PAYITGO_WEBHOOK_SECRET'),
],
```

```php
// 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
<?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):

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

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

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

```php
$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:

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