PovitoDevelopers

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#

Composerbash
composer require povito/checkout

Quickstart#

Four steps. The fourth is the one integrations skip and regret.

1. Create a session#

Createphp
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#

Redirectphp
header('Location: '.$session['url']);
exit;

3. Verify the webhook#

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

Fulfilphp
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#

Optionsphp
$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:

Escape hatchphp
$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:

Your own keyphp
$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:

Correlatephp
$povito->sessions->retrieve($id, ['request_id' => $myCorrelationId]);

Errors#

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

Money helpersphp
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);       // true

reconciles() 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#

Register an endpointphp
$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 key

rotateSecret() 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#

Partial refundphp
$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#

PSR-18php
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:

Fire a test eventphp
$povito->webhookEndpoints->test($endpointId, 'checkout.session.completed');

And to sign a payload in your own test suite — never in production code:

Sign for a testphp
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_url query parameters for order state. Read the session.
  • Register your success_url and cancel_url hosts in advance; unregistered hosts are refused at create time.
  • TLS verification cannot be turned off through the client's options.