codetay/bankapi-php

BankAPI.VN PHP SDK — API client and webhook signature verification

Maintainers

Package info

github.com/codetay/bankapi-php

pkg:composer/codetay/bankapi-php

Transparency log

Statistics

Installs: 3

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-08-27 15:29 UTC

This package is not auto-updated.

Last update: 2026-08-28 01:10:39 UTC


README

Official PHP SDK for BankAPI.VN — bank account connections, transaction listing/matching, payment intents, and webhook signature verification.

Framework-agnostic. If you're on Laravel, use codetay/bankapi-laravel instead (service provider, facade, webhook middleware) — see its README.

Install

composer require codetay/bankapi-php

The SDK talks HTTP through PSR-18 and auto-discovers an installed client via php-http/discovery. If your project doesn't already depend on a PSR-18 client, add one:

composer require guzzlehttp/guzzle

You can also pass a specific PSR-18 client, request factory, and stream factory explicitly to BankApi::__construct() instead of relying on discovery.

Configure the client you pass in. A discovered client runs with its own defaults, and Guzzle's default is no timeout at all — one hung API call then occupies a PHP-FPM worker until the socket closes. Two settings are worth making explicit in any client you hand to the SDK:

$client = new \GuzzleHttp\Client([
    'timeout' => 10,
    'connect_timeout' => 5,
    // The API key travels in the X-API-Key header, which Guzzle does NOT
    // strip when a redirect changes host — don't follow redirects.
    'allow_redirects' => false,
]);

$bankapi = new \BankApi\BankApi('bk_live_...', 'https://api.bankapi.vn', $client);

The Laravel package ships a client configured this way out of the box.

The base URL must be https (plain http is accepted only for loopback hosts during local development); anything else is refused with an InvalidArgumentException rather than sending your API key in clear text.

Quickstart

use BankApi\BankApi;

$bankapi = new BankApi('bk_live_...');

// List transactions, newest first, and page through everything lazily.
$page = $bankapi->banking()->transactions(limit: 50);
foreach ($page->autoPaging() as $transaction) {
    echo "{$transaction->id}: {$transaction->amount} ({$transaction->direction})\n";
}

// Reconcile an inbound transaction against a payment intent.
$detail = $bankapi->banking()->matchTransaction(
    txId: 'tx_01H...',
    intentId: 'pi_01H...',
);

BankApi::__construct(string $apiKey, string $baseUrl = 'https://api.bankapi.vn', ...) authenticates every request with an X-API-Key: <apiKey> header.

Base URL is your organization host. Org-scoped operations (banking, webhook endpoints) must be called on your organization's own host — https://<your-org-slug>.bankapi.vn — not the account-level API host. Calling them on a non-organization host fails with 403 "this operation must be accessed on an organization host". Pass your org host as $baseUrl:

$bankapi = new BankApi('bk_live_...', baseUrl: 'https://acme.bankapi.vn');

Pagination

List methods (banking()->transactions(), banking()->paymentIntents(), webhookEndpoints()->all(), webhookEndpoints()->deliveries()) return a BankApi\Page, a cursor-paginated single page:

$page = $bankapi->banking()->transactions(limit: 20);

foreach ($page as $transaction) {   // items on this page only
    // ...
}

$page->nextCursor;                  // '' when this was the last page

foreach ($page->autoPaging() as $transaction) {
    // fetches subsequent pages lazily as you iterate
}

Webhook verification

Verify the signature on every incoming webhook request before trusting its payload, then respond 2xx quickly (BankAPI retries on 408/429/5xx):

use BankApi\Exception\SignatureVerificationException;
use BankApi\Webhook\Webhook;

$payload = file_get_contents('php://input');

try {
    $event = Webhook::constructEvent(
        payload: $payload,
        headers: getallheaders(),
        secret: $_ENV['BANKAPI_WEBHOOK_SECRET'],
    );
} catch (SignatureVerificationException $e) {
    http_response_code(400);
    exit;
}

// Dedupe retried deliveries using $event->deliveryId (e.g. a unique
// constraint or a seen-ids cache) before acting on the event.
if (! already_processed($event->deliveryId)) {
    handle($event->type, $event->data);
    mark_processed($event->deliveryId);
}

http_response_code(200);

Webhook::constructEvent() verifies an HMAC-SHA256 hex signature computed over "<delivery_id>.<timestamp>.<raw body>", carried as X-Webhook-Signature: sha256=<hex> alongside X-Webhook-Event, X-Webhook-Delivery-Id, and X-Webhook-Timestamp (Unix seconds). It also rejects deliveries whose timestamp is older than $tolerance seconds (default 300) to guard against replay. Always pass the raw, unparsed request body — re-encoding JSON before verifying will break the signature check.

The webhook secret (whsec_...) is shown once, in the response of webhookEndpoints()->create(...) — store it immediately, it cannot be retrieved again later.

Errors

Every failure raises a subclass of BankApi\Exception\ApiException — HTTP errors are built from the API's problem+json body, transport failures are wrapped too:

Condition Exception Notes
400, 422 BankApi\Exception\ValidationException invalid request/parameters
401 BankApi\Exception\AuthenticationException missing/invalid API key
403 BankApi\Exception\PermissionException key lacks permission
404 BankApi\Exception\NotFoundException resource does not exist
429 BankApi\Exception\RateLimitException has ->retryAfter (seconds, nullable)
other non-2xx BankApi\Exception\ApiException base class, catches everything in this table
no response (DNS/connect/TLS/timeout) BankApi\Exception\ConnectionException ->status is 0; original PSR-18 error via ->getPrevious()
2xx with a non-JSON body BankApi\Exception\MalformedResponseException e.g. a proxy or captive portal answered instead of the API

All of them expose ->status, ->title, ->detail, and ->body (the decoded problem response). Catching ApiException catches every failure the SDK can raise during a request.

use BankApi\Exception\RateLimitException;
use BankApi\Exception\ApiException;

try {
    $bankapi->banking()->transaction('tx_does_not_exist');
} catch (RateLimitException $e) {
    sleep($e->retryAfter ?? 1);
} catch (ApiException $e) {
    log_error("bankapi error {$e->status}: {$e->detail}");
}

GET requests are retried automatically (up to 2 extra attempts) on network errors, 429, and 5xx responses, with backoff and jitter. Non-GET requests are never retried automatically, since they may not be idempotent.