paybetaby / php-sdk
Official PHP SDK for the PayBeta payments API, with first-class Laravel support
Requires
- php: ^8.1
- ext-json: *
- guzzlehttp/guzzle: ^7.8
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.75
- illuminate/support: ^10.0 || ^11.0 || ^12.0
- phpunit/phpunit: ^10.5
Suggests
- illuminate/support: Required to use the Laravel service provider and Paybeta facade (^10.0 || ^11.0 || ^12.0)
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-10 21:05:51 UTC
README
Official PHP SDK for the PayBeta payments API, with first-class Laravel support (auto-discovered service provider, Paybeta facade, publishable config). Framework-agnostic at its core — works in any PHP 8.1+ project.
Table of Contents
- Requirements
- Installation
- Laravel Quick Start
- Plain PHP Quick Start
- Authentication
- Configuration
- Resources
- Error Handling
- Testing
- License
Requirements
- PHP 8.1 or later
- Guzzle 7.8+ (installed automatically as a dependency)
- Laravel 10, 11, or 12 — only if you want the service provider / facade (the core client works without Laravel)
- A PayBeta merchant account — sign up at usepaybeta.com
- An API key from your PayBeta dashboard (
pb_live_…for production,pb_test_…for sandbox)
Installation
composer require paybetaby/php-sdk
Laravel Quick Start
The service provider and Paybeta facade are auto-discovered — no manual registration needed.
1. Add your credentials to .env:
PAYBETA_API_KEY=pb_test_your-key-here PAYBETA_MERCHANT_ID=your-merchant-id PAYBETA_WEBHOOK_SECRET=your-webhook-signing-secret
2. (Optional) Publish the config file if you want to change defaults (base URL, timeout):
php artisan vendor:publish --tag=paybeta-config
This publishes config/paybeta.php.
3. Use the facade, or inject the client:
use Paybeta\Laravel\Facades\Paybeta; $link = Paybeta::paymentLinks()->create([ 'merchantId' => config('paybeta.merchant_id'), 'amount' => 500000, // kobo — ₦5,000.00 'description' => 'Order #12345', 'sellerEmail' => 'seller@example.com', 'buyerEmail' => 'buyer@example.com', 'buyerPhone' => '+2348012345678', ]); return redirect($link['checkoutUrl']);
Or via constructor/method injection, using the Paybeta\PaybetaClient class directly:
use Paybeta\PaybetaClient; class CheckoutController extends Controller { public function store(Request $request, PaybetaClient $paybeta) { $link = $paybeta->paymentLinks()->create([...]); return redirect($link['checkoutUrl']); } }
Plain PHP Quick Start
No framework required — the client works standalone.
require 'vendor/autoload.php'; use Paybeta\PaybetaClient; $paybeta = new PaybetaClient( apiKey: $_ENV['PAYBETA_API_KEY'], webhookSecret: $_ENV['PAYBETA_WEBHOOK_SECRET'] ?? null, ); // One call creates the link AND opens a checkout session — the response // already has a ready-to-use checkoutUrl. This is the recommended way to // accept a payment; no need to create a transaction yourself first. $link = $paybeta->paymentLinks()->create([ 'merchantId' => 'your-merchant-id', 'amount' => 500000, // kobo 'description' => 'Order #12345', 'sellerEmail' => 'seller@example.com', 'buyerEmail' => 'buyer@example.com', 'buyerPhone' => '+2348012345678', ]); header("Location: {$link['checkoutUrl']}");
Authentication
PayBeta uses API key authentication. Pass your key once when constructing the client (or via config/paybeta.php in Laravel) — every request carries it automatically via the X-API-Key header.
| Key prefix | Environment |
|---|---|
pb_live_… |
Production (live) |
pb_test_… |
Sandbox (test mode) |
Keep your API key secret. Never embed it in client-side code or commit it to version control. Use environment variables (
.env, never committed).
Configuration
Plain PHP — PaybetaClient constructor
$paybeta = new PaybetaClient( apiKey: 'pb_live_...', // required baseUrl: 'https://api.usepaybeta.com', // optional — override for staging/self-hosted webhookSecret: 'your-webhook-secret', // optional — required only for webhooks()->constructEvent() timeout: 30, // optional — request timeout in seconds );
Laravel — config/paybeta.php
| Key | Env var | Default |
|---|---|---|
api_key |
PAYBETA_API_KEY |
'' |
merchant_id |
PAYBETA_MERCHANT_ID |
'' |
base_url |
PAYBETA_BASE_URL |
https://api.usepaybeta.com |
webhook_secret |
PAYBETA_WEBHOOK_SECRET |
'' |
timeout |
PAYBETA_TIMEOUT |
30 |
merchant_id isn't read by the SDK itself — it's kept in config as a single place for the rest of your app to pull it from, since every payment-link/transaction/escrow call needs it.
Resources
Every resource method returns a plain PHP array (the API's JSON response, decoded with json_decode($json, true) and unwrapped from the {status, data, timestamp} envelope every endpoint sends).
Payment Links
The recommended integration path. One call creates the link and opens a checkout session with the PSP — the response includes a ready-to-use checkoutUrl.
$link = $paybeta->paymentLinks()->create([ 'merchantId' => 'your-merchant-id', 'amount' => 500000, // kobo — smallest unit 'description' => 'Order #12345', 'sellerEmail' => 'seller@example.com', 'buyerEmail' => 'buyer@example.com', 'buyerPhone' => '+2348012345678', // E.164 — SMS/WhatsApp delivery fallback // Optional: 'currency' => 'NGN', // defaults to NGN 'reference' => 'ORD-2026-001', 'buyerName' => 'Ada Okafor', 'pspType' => 'PAYSTACK', // defaults to PAYSTACK 'paymentMethod' => 'CARD', // defaults to CARD 'redirectUrl' => 'https://yoursite.com/order/confirmed', 'expiresInHours' => 72, // defaults to 72 ]); // $link['checkoutUrl'] — send the buyer here // $link['token'] — the link's identifier, for retrieve()/checkCompletion()
// List a merchant's payment links (bare array, no pagination envelope) $links = $paybeta->paymentLinks()->listForMerchant('your-merchant-id'); // Public — no API key needed. Retrieve link details by token. $link = $paybeta->paymentLinks()->retrieve($token); // Public — what a buyer's browser calls after the PSP redirect. Verifies // the payment's live status and creates the underlying escrow transaction // the first time a payment is confirmed. $status = $paybeta->paymentLinks()->checkCompletion($token); if ($status['status'] === 'FUNDED') { // fulfil the order } // Advanced/public — building a custom checkout page instead of using // checkoutUrl directly. Most integrations don't need this. $session = $paybeta->paymentLinks()->initiate($token, [ 'buyerEmail' => 'buyer@example.com', 'buyerPhone' => '+2348012345678', 'paymentMethod' => 'CARD', 'pspType' => 'PAYSTACK', ]);
Transactions
A transaction represents the commercial relationship between a buyer and seller. Payment links create one for you automatically — create your own directly only if you're building a lower-level, custom flow.
$transaction = $paybeta->transactions()->create([ 'merchantId' => 'your-merchant-id', 'buyerEmail' => 'buyer@example.com', 'buyerPhone' => '+2348012345678', 'sellerEmail' => 'seller@example.com', 'amount' => 1500, // decimal naira (₦1,500.00) — NOT kobo, unlike Payment amounts 'currency' => 'NGN', ], idempotencyKey: 'unique-key-per-attempt'); echo $transaction['id']; // UUID echo $transaction['status']; // 'INITIATED'
// List — merchantId is required for an API-key caller: the bare // /transactions endpoint is platform-role only and 403s a merchant key. $transactions = $paybeta->transactions()->list([ 'merchantId' => 'your-merchant-id', 'status' => 'FUNDED', 'limit' => 20, ]); $transaction = $paybeta->transactions()->retrieve('txn-uuid'); $events = $paybeta->transactions()->listHistory('txn-uuid'); // Advance the transaction's lifecycle directly (escrow/release/dispute/refund) $paybeta->transactions()->updateStatus('txn-uuid', 'RELEASED');
Transaction statuses: INITIATED → FUNDED → IN_ESCROW → RELEASED / DISPUTED / REFUNDED
Payments
A payment records a customer's attempt to fund a transaction via a PSP (Paystack, Flutterwave). Payment links handle this for you — use these methods directly only for a custom, lower-level flow.
$payment = $paybeta->payments()->initiate([ 'merchantId' => 'your-merchant-id', 'transactionId' => $transaction['id'], 'amount' => 150000, // kobo — integer minor-unit, unlike Transaction amounts 'currency' => 'NGN', 'paymentMethod' => 'CARD', // CARD | BANK_TRANSFER | USSD | MOBILE_MONEY | BANK_ACCOUNT 'pspType' => 'PAYSTACK', // PAYSTACK | FLUTTERWAVE | BANK_DIRECT 'customerEmail' => 'buyer@example.com', ]); // Redirect your customer to complete payment header("Location: {$payment['authorizationUrl']}");
// Call when the customer returns from the PSP redirect — never trust the // query string alone, always re-verify. $payment = $paybeta->payments()->verify($paymentId); if ($payment['status'] === 'COMPLETED') { // fulfil the order } $payment = $paybeta->payments()->retrieve($paymentId); $payments = $paybeta->payments()->list(['merchantId' => 'your-merchant-id']); $paybeta->payments()->retry($paymentId); $attempts = $paybeta->payments()->listAttempts($paymentId);
Payment statuses: PENDING → PROCESSING → COMPLETED / FAILED / CANCELLED
Escrows
Escrows hold funds securely between buyer and seller until configurable release conditions are met. Available on Growth and Enterprise plans.
$escrow = $paybeta->escrows()->create([ 'transactionId' => $transaction['id'], 'merchantId' => 'your-merchant-id', 'buyerEmail' => 'buyer@example.com', 'buyerPhone' => '+2348012345678', 'sellerEmail' => 'seller@example.com', 'amount' => 1500, // decimal naira — the API converts to kobo itself 'currency' => 'NGN', 'releasePolicy' => [ // optional 'conditionLogic' => 'AND', // release only when ALL conditions are met 'conditions' => [ ['type' => 'DELIVERY_CONFIRMATION'], ['type' => 'BUYER_CONFIRMATION'], ], ], ]);
Condition types: DELIVERY_CONFIRMATION, BUYER_CONFIRMATION, TIME_BASED, MANUAL_APPROVALCondition logic: AND (all must be met) / OR (any one triggers release)
$paybeta->escrows()->release($escrowId, ['idempotencyKey' => 'release-once']); $paybeta->escrows()->confirmDelivery($escrowId, ['trackingReference' => 'DHL123456']); $paybeta->escrows()->confirmBuyer($escrowId); $paybeta->escrows()->dispute($escrowId); $paybeta->escrows()->refund($escrowId); $paybeta->escrows()->cancel($escrowId); $escrow = $paybeta->escrows()->retrieve($escrowId); // $escrow['status'] is lowercase: 'funded' | 'pending_release' | 'released' | ... // $escrow['amount'] is kobo (integer) — NOT divided down on the way out, unlike on create // All three amounts are decimal strings (already divided from kobo) $balance = $paybeta->escrows()->retrieveBalance($escrowId); // Returns an envelope, not a bare array $conditions = $paybeta->escrows()->retrieveConditions($escrowId); // Unlike payments/transactions/disputes, list() returns a // {escrows, total, limit, offset} envelope, not a bare array $result = $paybeta->escrows()->list(['merchantId' => 'your-merchant-id', 'status' => 'funded']);
Escrow statuses (lowercase): created → funded → pending_release → released / disputed / refunded / cancelled
Disputes
A dispute is opened when buyer and seller cannot agree. PayBeta provides a structured arbitration workflow.
// All fields below are required by the API — no partial/inferred version. $dispute = $paybeta->disputes()->open([ 'transactionId' => $transaction['id'], 'escrowId' => $escrow['id'], 'merchantId' => 'your-merchant-id', 'buyerEmail' => 'buyer@example.com', 'sellerEmail' => 'seller@example.com', 'disputeType' => 'NON_DELIVERY', 'priority' => 'HIGH', 'description' => 'Item not as described.', 'amount' => 150000, // kobo 'currency' => 'NGN', 'openedBy' => 'BUYER', // BUYER | SELLER ]);
// Field names match the API's JSON body exactly $paybeta->disputes()->uploadEvidence($disputeId, [ 'evidenceType' => 'IMAGE', // IMAGE | DOCUMENT | VIDEO | OTHER 'uploadedBy' => 'BUYER', // BUYER | SELLER | ARBITRATOR 'fileName' => 'packaging.jpg', 'fileData' => base64_encode(file_get_contents('packaging.jpg')), 'mimeType' => 'image/jpeg', 'description' => 'Photo of damaged packaging', ]); $paybeta->disputes()->resolve($disputeId, [ 'outcome' => 'BUYER_WINS', // BUYER_WINS | SELLER_WINS | PARTIAL_REFUND | PARTIAL_RELEASE | SPLIT | CANCELLED 'notes' => 'Evidence confirmed item was not delivered.', ]); $paybeta->disputes()->cancel($disputeId, ['reason' => 'Parties reached mutual agreement.']); $dispute = $paybeta->disputes()->retrieve($disputeId); $disputes = $paybeta->disputes()->list(['merchantId' => 'your-merchant-id', 'status' => 'OPENED']); $evidence = $paybeta->disputes()->listEvidence($disputeId);
Webhooks
PayBeta sends signed webhook events to your server when key state changes occur (payment completed, escrow released, dispute opened, etc.).
constructEvent() verifies HMAC-SHA256(webhookSecret, "{timestamp}.{rawBody}") against the X-PayBeta-Signature header (sent as sha256=<hex>) — both the signature and X-PayBeta-Timestamp headers are required, since the timestamp is part of what's actually signed, not just metadata.
Laravel example
use Illuminate\Http\Request; use Paybeta\Exceptions\PaybetaException; use Paybeta\Laravel\Facades\Paybeta; class PaybetaWebhookController extends Controller { public function __invoke(Request $request) { try { $event = Paybeta::webhooks()->constructEvent( $request->getContent(), $request->header('X-PayBeta-Signature', ''), $request->header('X-PayBeta-Timestamp', ''), ); } catch (PaybetaException $e) { report($e); return response('Webhook signature verification failed', 400); } match ($event['eventType']) { 'payment.received' => $this->handlePaymentReceived($event['data']), 'transaction.funded' => $this->handleTransactionFunded($event['data']), 'escrow.released' => $this->handleEscrowReleased($event['data']), 'dispute.opened' => $this->handleDisputeOpened($event['data']), default => null, }; return response()->json(['received' => true]); } }
Register the route outside any middleware that reads/mutates the raw body before you do (e.g. Laravel's CSRF middleware doesn't apply to API routes by default, but double-check any custom body-parsing middleware) — constructEvent() needs the exact raw bytes PayBeta signed.
// routes/api.php Route::post('/webhooks/paybeta', PaybetaWebhookController::class);
Plain PHP example
$rawBody = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_PAYBETA_SIGNATURE'] ?? ''; $timestamp = $_SERVER['HTTP_X_PAYBETA_TIMESTAMP'] ?? ''; try { $event = $paybeta->webhooks()->constructEvent($rawBody, $signature, $timestamp); } catch (\Paybeta\Exceptions\PaybetaException $e) { http_response_code(400); exit('Webhook signature verification failed'); } // ... handle $event['eventType'] / $event['data']
Event types
Exactly the events PayBeta can emit — there is no dispute.cancelled or any escrow.* event besides escrow.released.
| Event type | Description |
|---|---|
transaction.created |
New transaction created |
transaction.funded |
Customer's payment cleared; funds received |
transaction.escrowed |
Funds moved into escrow hold |
transaction.released |
Funds released to seller |
transaction.disputed |
Dispute opened on transaction |
transaction.refunded |
Transaction refunded to buyer |
payment.received |
Payment confirmed as successful |
payment.failed |
Payment failed or declined |
dispute.opened |
Dispute opened |
dispute.resolved |
Dispute resolved with outcome |
escrow.released |
Escrow funds disbursed to seller |
Error Handling
PaybetaApiException
Thrown when the API returns a non-2xx response.
use Paybeta\Exceptions\PaybetaApiException; try { $escrow = $paybeta->escrows()->create([...]); } catch (PaybetaApiException $e) { echo $e->getMessage(); // Human-readable error message echo $e->status; // HTTP status code (e.g. 402, 403, 404) echo $e->errorCode; // Machine-readable code (e.g. 'FEATURE_NOT_AVAILABLE') echo $e->traceId; // PayBeta trace ID for support }
Common error codes:
| Code | Status | Meaning |
|---|---|---|
FEATURE_NOT_AVAILABLE |
403 | Feature not enabled on your plan (e.g. escrow on Starter) |
VOLUME_LIMIT_EXCEEDED |
402 | Monthly volume limit reached — upgrade your plan |
API_KEY_LIMIT_EXCEEDED |
402 | API key count limit reached for your plan |
NOT_FOUND |
404 | Resource not found |
UNAUTHORIZED |
401 | Invalid or missing API key |
TOO_MANY_REQUESTS |
429 | Rate limit exceeded |
BAD_REQUEST |
400 | Validation error — check the error message for field details |
PaybetaException
The base exception — thrown for anything that isn't an API response: request timeout, DNS/connection failures, webhook signature failure, missing configuration. PaybetaApiException extends this, so catching PaybetaException catches both.
use Paybeta\Exceptions\PaybetaException; try { $event = $paybeta->webhooks()->constructEvent($rawBody, $signature, $timestamp); } catch (PaybetaException $e) { // signature mismatch, missing webhookSecret, network failure, etc. echo $e->getMessage(); }
Testing
git clone https://github.com/Besaiem/paybeta-php-sdk cd paybeta-php-sdk composer install composer test # PHPUnit, mocked HTTP — no real API calls or credentials needed composer cs-check # code style check composer cs-fix # auto-fix code style
Releasing
Versioning, CHANGELOG.md, and GitHub Releases are all automated by release-please — there's no manual version bump or tagging step.
- Commit to
mainusing Conventional Commits (feat:,fix:,feat!:/BREAKING CHANGE:footer for a major bump, etc.) — a PR's commits are linted for this automatically. - release-please maintains a standing "Release PR" that accumulates those commits into
CHANGELOG.mdand the next semver version. - Merging that PR tags the release and publishes a GitHub Release.
composer.jsondeliberately has noversionfield — Composer itself warns against one for a Packagist-published package, since the git tag is the actual source of truth. - Packagist picks up the new tag automatically via its GitHub webhook — no separate publish step.
License
MIT © PayBeta