PHP SDK#
povito/checkout is the PHP client for the Checkout API: PHP 8.1+, no
framework, no HTTP dependency. A cURL transport is built in and any PSR-18
client can replace it. It mirrors @povito/checkout-node
method for method, including the webhook signature scheme.
If you run WooCommerce, you probably want the plugin instead — it is built on this package.
Install#
composer require povito/checkoutQuickstart#
Four steps. The fourth is the one integrations skip and regret.
1. Create a session#
use Povito\Checkout\Client;
use Povito\Checkout\Money;
$povito = new Client(getenv('POVITO_CHECKOUT_SECRET_KEY'));
$session = $povito->sessions->create([
'reference_id' => 'POV-1041', // your order number
'amount' => 45000, // integer minor units — IQD has none
'currency' => 'IQD',
'line_items' => [
['label' => 'Basket', 'amount' => 42000, 'type' => 'charge'],
['label' => 'Delivery — Hi-Express', 'amount' => 5000, 'type' => 'shipping'],
['label' => 'Coupon RAMADAN', 'amount' => 2000, 'type' => 'discount'],
],
'payment_method_types' => ['zaincash', 'fib', 'card'],
'customer' => ['phone' => '+9647800000000', 'name' => 'Ali M. Ismail'],
'locale' => 'ar',
'success_url' => 'https://your-store.example/checkout/return',
'cancel_url' => 'https://your-store.example/cart',
'metadata' => ['order_id' => 'POV-1041'],
]);Store reference_id → $session['id'] before you redirect. A webhook that
never arrives is then recoverable with
$povito->sessions->list(['reference_id' => 'POV-1041']).
2. Redirect to url#
header('Location: '.$session['url']);
exit;3. Verify the webhook#
use Povito\Checkout\Webhook;
use Povito\Checkout\Exception\SignatureVerificationException;
$payload = file_get_contents('php://input'); // the RAW body, before any parsing
$header = $_SERVER['HTTP_POVITO_SIGNATURE'] ?? null;
try {
$event = Webhook::constructEvent($payload, $header, getenv('POVITO_WEBHOOK_SECRET'));
} catch (SignatureVerificationException) {
http_response_code(401);
exit;
}constructEvent($payload, $sigHeader, $secret, $tolerance = 300) checks
t=<unix>,v1=<hex> against HMAC-SHA256(secret, "<t>.<raw body>") with
hash_equals, then parses the JSON. Webhook::verifySignature() does only the
first half and returns a boolean.
Verify the raw bytes
A body your framework decoded and you re-encoded will not match — key order
and whitespace differ. In Laravel that is $request->getContent(); in
Symfony, $request->getContent(); in WordPress, $request->get_body().
4. Read the session before fulfilling#
A webhook is a prompt to read the session. It is not the answer.
if ('checkout.session.completed' === $event['type']) {
$session = $povito->sessions->retrieve($event['data']['object']['id']);
if ('paid' === $session['payment_status'] && $session['amount'] === $order->totalMinorUnits()) {
$order->markPaid($session['id']); // idempotently: this can arrive twice
}
}
http_response_code(200);Answer 2xx quickly and do the slow work afterwards. Handle events
idempotently by $event['id']: a redelivery is indistinguishable from a retry.
See webhooks for the retry schedule.
The client#
$povito = new Client($secretKey, [
'base_url' => 'https://api.checkout.povito.com/v1', // default
'timeout' => 15.0, // seconds, per attempt
'connect_timeout' => 10.0,
'max_retries' => 2,
'transport' => null, // any Povito\Checkout\Http\Transport
]);The key must be povito_ck_live_… or povito_ck_test_…. A publishable key is
refused: it is the browser's credential and would not work here anyway. See
keys and environments. $povito->isTestMode() tells
you which one you are holding.
Resources#
| Resource | Methods |
|---|---|
$povito->sessions |
create, retrieve, list, expire, isPaid |
$povito->refunds |
create, retrieve, list |
$povito->webhookEndpoints |
create, list, retrieve, update, delete, rotateSecret, test |
$povito->events |
list, retrieve, redeliver |
$povito->paymentMethods |
list |
$povito->links |
create, list, retrieve, update, sessions |
Responses are plain associative arrays with @phpstan-type shapes in
src/Types.php. Nothing is hydrated into objects, so a field added to the API
tomorrow reaches you today — ignore what you do not
recognise, and treat unknown enum values as "other".
Anything the package does not model yet is one call away:
$povito->request('GET', '/some/new/route', null, ['query' => ['limit' => 10]]);Idempotency and retries#
Every POST carries a UUIDv4 Idempotency-Key, generated unless you pass one:
$povito->sessions->create($params, ['idempotency_key' => 'order-1041-attempt-2']);Pass your own when a retry can cross a process boundary — a queue worker
picking the job up again after a crash. The same key with the same body returns
the stored response; the same key with a different body is 409 idempotency_key_conflict.
429 and 5xx are retried at 250 ms, 1 s and 3 s — honouring a short
Retry-After — for reads and for the three creating calls, which the contract
stores and replays by key. Other POSTs are not retried even though they carry
one: repeating rotateSecret() could mint two secrets and show you a single
one. Pass ['retry' => true] to opt a call in.
X-Request-Id is passed through when you supply one, so a single identifier
spans your logs and Povito's:
$povito->sessions->retrieve($id, ['request_id' => $myCorrelationId]);Errors#
use Povito\Checkout\Exception\ApiException;
use Povito\Checkout\Exception\TransportException;
try {
$session = $povito->sessions->create($params);
} catch (ApiException $e) {
$e->getStatus(); // 400
$e->getErrorCode(); // 'amount_mismatch'
$e->getMessage(); // 'Line items sum to 44,000 IQD but amount is 45,000 IQD.'
$e->getDetails(); // [['field' => 'line_items', 'issue' => 'sum_differs']]
$e->getRequestId(); // 'req_01J…' — quote this to support
} catch (TransportException $e) {
// No HTTP response at all. The operation may still have happened:
// re-read by reference_id before creating a second session.
}getCode() is the HTTP status, because PHP's \Exception::$code is an int;
the API's string code is getErrorCode(). Both exceptions implement
Povito\Checkout\Exception\PovitoCheckoutException, so catching that catches
everything the package throws. The codes themselves are in
errors.
Money#
Integer minor units, always. IQD has no minor unit — the commonest integration bug in Iraq is multiplying an IQD total by 100, a hundredfold overcharge the API cannot detect because the number is valid.
use Povito\Checkout\Money;
Money::exponent('IQD'); // 0
Money::exponent('USD'); // 2
Money::toMinor('45000', 'IQD'); // 45000
Money::toMinor(19.99, 'USD'); // 1999 (no float artefacts)
Money::toMajor(1999, 'USD'); // '19.99'
Money::format(45000, 'IQD'); // '45,000 IQD'
Money::sumLineItems($lineItems); // charge + shipping + fee − discount
Money::reconciles($lineItems, 45000); // truereconciles() runs the API's own reconciliation locally, so you fail on your
own server instead of collecting a 400 amount_mismatch after the shopper has
clicked pay.
Webhook endpoints#
$endpoint = $povito->webhookEndpoints->create([
'url' => 'https://your-store.example/webhooks/povito',
'events' => ['checkout.session.completed', 'refund.succeeded'],
'mode' => 'test',
]);
$endpoint['secret']; // shown ONCE — store it where you store the secret keyrotateSecret() returns a new one, also once. The old secret keeps verifying
for 24 hours — both signatures arrive as v1 entries — so deploy the new one
inside that window. Nothing in your handler changes.
Managing endpoints covers the rest.
Refunds#
$povito->refunds->create([
'session_id' => $session['id'],
'amount' => 5000,
'reason' => 'Customer returned one item, ticket #4521', // 10–1500 characters
]);amount may not exceed captured minus already-refunded. Cash on delivery, and
any rail without a refund API, answer 422 method_not_refundable. See
refunds.
Your own HTTP stack#
use Povito\Checkout\Http\Psr18Transport;
$povito = new Client($key, [
'transport' => new Psr18Transport($psr18Client, $requestFactory, $streamFactory),
]);psr/http-client and psr/http-factory are suggests, not requires: the class
loads only if you construct it. Implement Http\Transport — one method — to
sit on something else entirely. The WooCommerce plugin does exactly that, to
route through wp_remote_request() and inherit the site's proxy and CA
settings.
Test mode#
Use a povito_ck_test_… key: sessions it creates render the simulated gateway,
the test phone +9647500000000 accepts OTP 000000, and nothing moves real
money. Full details in testing.
To exercise your handler without a payment at all:
$povito->webhookEndpoints->test($endpointId, 'checkout.session.completed');And to sign a payload in your own test suite — never in production code:
Webhook::sign($payload, $secret); // 't=…,v1=…'Security#
- The secret key is server-side only. Never in a browser, an app bundle, a repository or a log line.
- Verify raw bytes, before parsing, with
hash_equals. Never===on a signature. - Never trust
success_urlquery parameters for order state. Read the session. - Register your
success_urlandcancel_urlhosts in advance; unregistered hosts are refused at create time. - TLS verification cannot be turned off through the client's options.