Skip to content

Developers SDKs

PHP SDK

The REST API for PHP 8.2 and newer: an idempotency key on every POST, bounded retries, typed exceptions and webhook verification. No dependencies beyond ext-curl and ext-json. Writing TypeScript or Python? Use the TypeScript or Python SDK. Run a PHP with the 21 October 2026 security release (8.2.35, 8.3.36, 8.4.27, 8.5.12 or newer): why.

Install

Composer
composer require payshen/payshen-php

On Packagist, with its source on GitHub (MIT). Responses are plain PHP arrays, shaped as the reference describes.

Configuration

One client per process, made with your secret key. A ps_test_ key reaches the sandbox, a ps_live_ key production. Keep the key on your server: never in a page or an app.

PHP
$payshen = new Payshen\Payshen(getenv('PAYSHEN_API_KEY'), [
    'timeout' => 20,        // seconds per attempt
    'max_retries' => 3,
    'base_url' => 'https://www.payshen.com',
    'ca_bundle' => null,    // a PEM file, for a PHP with no trusted roots configured
]);

base_url must be https; plain http is accepted for localhost only, and anything else is refused when the client is made.

Your first payment

PHP
$payshen = new Payshen\Payshen(getenv('PAYSHEN_API_KEY')); // ps_test_... in the sandbox

$payment = $payshen->payments->create([
    'amount' => 5000,              // minor units: EUR 50.00
    'currency' => 'EUR',
    'country' => 'DE',
    'reference' => 'order-1024',
    'capture_method' => 'manual',  // hold now, capture when you ship
    'return_url' => 'https://yourshop.com/checkout/return',
], ['idempotency_key' => 'order-1024']);

// REQUIRES_ACTION: send the customer to the provider's page.
header('Location: ' . $payment['next_action']['url']);

The customer pays on the provider's page and comes back to your return_url. The return is not the outcome: the outcome is the webhook (payment.authorized, payment.captured, payment.declined, ...). Amounts are integers in the currency's minor unit; how the scale changes per currency.

Capture, void, refund

PHP
use Payshen\Exception\PayshenException;

$payshen->payments->capture($paymentId, [], ['idempotency_key' => 'capture-order-1024']);                   // in full
$payshen->payments->capture($paymentId, ['amount' => 3000], ['idempotency_key' => 'capture-order-1024']); // or in part
$payshen->payments->void($paymentId, ['idempotency_key' => 'void-order-1024']);                              // before capture

try {
    $payshen->payments->refund($paymentId, ['amount' => 2000], ['idempotency_key' => 'refund-order-1024-1']);
} catch (PayshenException $e) {
    // 409 refund_pending: accepted, not failed. The provider confirms a moment later and
    // payment.refunded or payment.refund_failed follows. Do not send it again under a new key.
    if ($e->errorCode !== 'refund_pending') {
        throw $e;
    }
}

A capture or void a provider only accepted answers with the payment PENDING; it becomes CAPTURED or CANCELLED when the provider confirms, and the webhook follows.

Reading payments and events

PHP
$payment = $payshen->payments->get($paymentId);
$timeline = $payshen->payments->events($paymentId);

$page = $payshen->payments->list(['limit' => 50, 'status' => 'captured']);
foreach ($payshen->payments->all(['status' => 'captured']) as $payment) {
    // every page, newest first
}

foreach ($payshen->events->all(['type' => 'payment.captured']) as $event) {
    // the same events webhooks deliver, for polling
}

list returns one page; all is a generator that follows the cursor for you.

Idempotency

Every POST carries an Idempotency-Key. Pass your own as idempotency_key (an order or refund reference is a good one); without one the client makes one and reuses it across its own retries. The same key with a different body is refused with 422, so a retry can never charge twice.

Verifying webhooks

Verify the signature against the raw body, then act on the event.

An empty secret never verifies anything: missing_secret is thrown instead, so an unset environment variable fails loudly rather than letting a forged event through (0.1.2).

PHP
use Payshen\Exception\SignatureVerificationException;
use Payshen\Webhook;

$raw = file_get_contents('php://input'); // the RAW body: one re-encoded from json_decode never verifies
$secret = getenv('PAYSHEN_WEBHOOK_SECRET');
if (!is_string($secret) || $secret === '') {
    throw new RuntimeException('PAYSHEN_WEBHOOK_SECRET is not set'); // never verify against an empty secret
}

try {
    $event = Webhook::parseEvent($raw, $_SERVER['HTTP_X_PAYSHEN_SIGNATURE'] ?? null, $secret);
} catch (SignatureVerificationException $e) {
    http_response_code(400);
    exit;
}

switch ($event['type']) {
    case 'payment.captured':
        // fulfil $event['data']['reference']
        break;
}
http_response_code(204);

The signature proves a delivery came from Payshen and is recent (5 minutes by default); it does not make it new. Deliveries are retried, so the same event can arrive twice. Webhook::claimEvent verifies, parses and takes each event once, with a store you back with your database:

$event = Webhook::claimEvent($raw, $_SERVER['HTTP_X_PAYSHEN_SIGNATURE'] ?? null, $secret, $store);
if ($event === null) {
    http_response_code(204); // already handled
    exit;
}

Errors

  • AuthenticationException: 401 or 403: a missing, wrong or revoked key, or one not allowed this call.
  • ValidationException: 422: refused as sent. errorCode names the rule.
  • RateLimitException: 429 after the client's own retries. retryAfterSeconds when the API said.
  • NetworkException: No answer at all. On a POST, retry with the same idempotency key.
  • PayshenException: Everything else, and the parent of all of the above.

Every exception has status, errorCode (refund_pending, not_capturable, payment_result_unknown, ...) and requestId. Branch on errorCode, never on the message, and log requestId so support can find the call. Every code is in the reference.

Retries and timeouts

The client retries 408, 425, 429, 500, 502, 503 and 504 answers with backoff and jitter, up to max_retries (3), honouring Retry-After. It never retries an answer the API says a retry cannot improve, such as payment_result_unknown: there the payment may have happened, so read it before doing anything else. timeout is per attempt, 20 seconds by default.

Rotating a webhook secret

During a rotation pass both secrets, the new one first, and drop the old one once every delivery is signed with the new:

PHP
$event = Webhook::parseEvent($raw, $header, [$newSecret, $oldSecret]);

Everything else

$payshen->insights, $payshen->suggestions and, for white-label partners, $payshen->merchants cover the rest of the API. $payshen->request('GET', '/api/v1/...') calls any endpoint and returns the whole answer.

Before going live

The checklist is the same in every language: the go-live list in the TypeScript guide.