blindpay / php
Official PHP SDK for BlindPay API - Global payments infrastructure
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- guzzlehttp/guzzle: ^7.9
Requires (Dev)
- laravel/pint: ^1.21
- pestphp/pest: ^3.7
- phpstan/phpstan: ^2.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-13 14:09:27 UTC
README
The official PHP SDK for BlindPay, the stablecoin API for global payments.
- PHP 8.2 or higher, Guzzle 7.9+
- Typed inputs and responses, open enums, retries, idempotency keys, pagination generators and a standalone webhook verifier
- Upgrading from 3.x? Read UPGRADING.md.
Installation
composer require blindpay/php
Quick start
Get your API key and instance ID from the BlindPay dashboard (https://app.blindpay.com/instances/{instanceId}/api-keys). Keep the key out of source control; read it from the environment.
<?php declare(strict_types=1); require __DIR__.'/vendor/autoload.php'; use BlindPay\SDK\BlindPay; $blindpay = new BlindPay( apiKey: getenv('BLINDPAY_API_KEY'), instanceId: 'in_000000000000', ); $response = $blindpay->available->getRails(); if ($response->isError()) { throw new RuntimeException($response->error->message); } foreach ($response->data as $rail) { echo "{$rail->label} ({$rail->value}) - {$rail->country}\n"; }
More runnable scripts live in examples/: webhook endpoint setup and verification, a managed-wallet payout, a fiat payin and a reconciliation job.
Configuration
$blindpay = new BlindPay( apiKey: getenv('BLINDPAY_API_KEY'), instanceId: 'in_000000000000', baseUrl: 'https://api.blindpay.com', // default timeout: 60.0, // seconds per attempt, default 60 maxRetries: 2, // retries after the first attempt, default 2; 0 disables handler: null, // a Guzzle handler or HandlerStack, default null );
| Option | Default | Notes |
|---|---|---|
baseUrl |
https://api.blindpay.com |
API origin without /v1. Every request, including your API key, goes to this URL. Override it only for tests or a proxy you trust. |
timeout |
60.0 |
Seconds allowed per attempt (connect, send and receive). Can be overridden per call with RequestOptions(timeout: ...). |
maxRetries |
2 |
Retries after the first attempt, so 3 attempts in total. 0 turns retries off, and also stops the SDK generating idempotency keys. |
handler |
Guzzle default | A Guzzle handler or HandlerStack. Inject a MockHandler in tests, or a stack with your own middleware (logging, metrics, tracing). |
The client is immutable and safe to share (build it once, reuse it everywhere). Per-request options never change it, and var_dump() / print_r() of the client never show the API key.
Testing with a mock handler:
use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Response; $mock = new MockHandler([new Response(200, [], json_encode([]))]); $stack = HandlerStack::create($mock); $history = []; $stack->push(Middleware::history($history)); $blindpay = new BlindPay(apiKey: 'test_key', instanceId: 'in_000000000000', handler: $stack);
Results and errors
Resource methods return a BlindPayApiResponse. They do not throw for API, network or decoding failures: check isError().
$response = $blindpay->payouts->get('pa_000000000000'); if ($response->isError()) { $error = $response->error; // BlindPay\SDK\Types\ErrorResponse error_log(sprintf( '[%s] %s (status %s, code %s, request %s)', $error->type->value, $error->message, $error->status ?? '-', $error->code ?? '-', $error->requestId ?? '-', )); return; } $payout = $response->data; // Payout echo $response->requestId; // the X-Request-Id of the response
On success, data holds the decoded value, requestId the X-Request-Id response header, and idempotencyReplayed is true when the API replayed a stored response for your idempotency key.
ErrorResponse fields:
| Field | Meaning |
|---|---|
message |
The API message (for example not_enough_balance), or an SDK description. Never contains the request body or the API key. |
status |
HTTP status, or null when no response was received. |
code |
The API's stable, UPPERCASE error code (for example PAYOUTS_INSUFFICIENT_BALANCE); compare it with the ErrorCode constants (see Error codes). Prefer it over message. Absent on some errors (idempotency conflicts, some legacy paths). |
description |
A longer explanation, when the API sends one. |
errors |
The raw errors array of the body (validation details); empty by default. |
traceId |
The API trace_id, when present. Quote it to support. |
requestId |
The X-Request-Id response header. |
idempotencyKey |
The key the request carried, yours or generated. Use it to resend safely. |
retryable |
Whether resending the same request with the same key may succeed. |
type |
An ErrorType, see below. |
headers |
Response headers (lower-cased names), when a response arrived. |
ErrorType tells you where a failure came from:
ErrorType |
Meaning |
|---|---|
API |
The API answered with a non-2xx status. Non-JSON error bodies (for example an HTML 502 page) still produce an API error with the status. |
NETWORK |
No response was received: connection, DNS or TLS failure, or the per-attempt timeout. |
VALIDATION |
The SDK rejected the call before sending it (an empty ID, an invalid idempotency key, ...). |
DECODE |
The API answered 2xx but the body did not fit the SDK's types. |
$error->isNetworkError() and $error->isApiError() are shortcuts. An empty 2xx body (for example 204) is a success with data === null.
Error codes
BlindPay\SDK\Types\ErrorCode has a constant for every error code the API documents, generated from the API spec. ErrorCode::info($code) returns the code's typical HTTP status and its retry class, and $error->retryClass() returns that class for an error:
RetryClass |
Meaning |
|---|---|
RETRYABLE |
The same request can be retried as is. |
TERMINAL |
Do not retry. |
ACTIONABLE |
The request succeeds only after you fix something (fund a balance, correct bank details, ...). |
UNKNOWN |
The error has no code, or a code newer than this SDK version. |
use BlindPay\SDK\Types\ErrorCode; use BlindPay\SDK\Types\RetryClass; $response = $blindpay->transfers->createQuote($input); if ($response->isError()) { $error = $response->error; if ($error->code === ErrorCode::TRANSFERS_SWAP_AMOUNT_BELOW_MINIMUM) { // Swaps between different tokens need at least 100 USDC, measured on the USDC side. } elseif ($error->retryClass() === RetryClass::ACTIONABLE) { // Show $error->description: the user has to fix something first. } }
code stays a plain string, so a code the API adds after your SDK version still reaches you, and ErrorCode::info() returns null for it. Some errors carry no code (idempotency conflicts and some legacy paths), so keep message as the fallback (the swap minimum's message is fx_amount_below_minimum). The retry class is guidance for your own handling: the SDK's automatic retries (see Retries) follow their own rules.
If you prefer exceptions, getDataOrFail() returns the data or throws BlindPay\SDK\BlindPayApiException, which carries the ErrorResponse. Every exception the SDK throws (configuration errors, BlindPayApiException, WebhookException) extends BlindPay\SDK\BlindPayException, so one catch covers them all:
use BlindPay\SDK\BlindPayApiException; try { $payout = $blindpay->payouts->get('pa_000000000000')->getDataOrFail(); } catch (BlindPayApiException $e) { echo $e->error->code, ' ', $e->error->requestId; // the ErrorResponse echo $e->getCode(); // the HTTP status (0 when no response was received) echo $e->getErrorCode(); // the API error code }
Idempotency
Every POST, PUT, PATCH and DELETE JSON request can carry an Idempotency-Key, so a repeated request is executed once. Pass your own key (an order or payment ID works well) in RequestOptions, the trailing argument of every resource method:
use BlindPay\SDK\Resources\Payouts\CreateEvmPayoutInput; use BlindPay\SDK\Types\RequestOptions; $response = $blindpay->payouts->createEvm( new CreateEvmPayoutInput(quoteId: 'qu_000000000000', walletId: 'wa_000000000000'), new RequestOptions(idempotencyKey: 'order-4821-payout'), );
- A key is 1 to 255 characters. An empty or longer key is rejected with a
VALIDATIONerror before anything is sent (the API silently ignores an empty key, which would look protected and not be). - When you pass no key and retries are on (
maxRetries > 0), the SDK generates a UUIDv4 key for every mutation before the first attempt, and reuses it on every retry. It is exposed on$response->error->idempotencyKeyif the call fails. - Multipart requests (uploads) never carry an idempotency key.
- The terms of service call has its own
idempotencyKeyin the request body (InitiateInput). That is unrelated to the header.
RequestOptions also takes timeout (seconds, for that call) and headers (extra headers; they cannot override the SDK's own Authorization, User-Agent, Idempotency-Key or Content-Type).
Retries
The SDK retries a failed attempt up to maxRetries times:
| Request | Retried on |
|---|---|
GET |
network errors and timeouts, HTTP 408, 429, 500, 502, 503 and 504 (not a 5xx whose code is PAYINS_CARD_PAYMENT_PENDING, which the API marks terminal) |
POST / PUT / PATCH / DELETE (JSON) |
only where the idempotency key fully protects the request: network errors and timeouts, HTTP 429, and HTTP 409 idempotency_key_in_flight. Never on a 5xx. |
| Multipart (uploads) | only when nothing was sent (a DNS or connect failure) |
Other 4xx responses are never retried. The delay is the Retry-After header when it is a whole number of seconds up to 60, otherwise min(8, 0.5 * 2^attempt) seconds multiplied by a random factor between 0.5 and 1. The request body is serialised once and the same bytes are resent on every attempt.
A mutation that gets a 5xx is not retried because the API releases the idempotency key on a 5xx, so a resend would run the handler again. The error comes back with retryable: true and the key instead. If a create or payout returns a 5xx, look the resource up (by quote, external id, or a list filter) before resending. Resend with the same idempotency_key from the error ($response->error->idempotencyKey).
if ($response->isError() && $response->error->retryable && $response->error->status >= 500) { // 1. check whether the payout exists (payouts->list with a filter, or by quote) // 2. only if it does not, resend with the same key $retry = $blindpay->payouts->createEvm($input, new RequestOptions(idempotencyKey: $response->error->idempotencyKey)); }
A 409 idempotency_key_in_flight usually carries a Retry-After above 60 seconds, so after the retries run out you get a retryable error with the key: wait, then resend the same request with the same key.
Indicative FX quote endpoints are rate limited per instance; a limited call returns 429 AUTH_RATE_LIMITED with a Retry-After header, which the SDK honours when it retries. That covers quotes->getFxRate() and payins->quotes->getFxRate() in production, and every call on a development instance.
Pagination
List endpoints return a bare array when you send no pagination parameter, and {data, pagination} when you send limit, starting_after or ending_before. The SDK decodes both into one type, ListResponse (payouts, payins, transfers, customers, payables and wallet deposits):
use BlindPay\SDK\Resources\Payouts\ListPayoutsInput; $page = $blindpay->payouts->list(new ListPayoutsInput(limit: 50))->getDataOrFail(); foreach ($page as $payout) { // iterable and Countable; $page->data is the list echo $payout->id, "\n"; } if ($page->hasMore()) { $next = $blindpay->payouts->list(new ListPayoutsInput(limit: 50, startingAfter: $page->pagination->nextPage)); }
$page->pagination (hasMore, nextPage, prevPage) is null for the bare-array form. PaginationParams does not validate limit client-side: the API accepts 10, 50, 100, 200, 500 or 1000 (wallet deposits: 5, 10, 50, 100, 200). offset is deprecated and ignored by the API; use startingAfter.
Unpaginated lists return every row. For a large list (a busy instance's payouts, say), page it with the generator instead. listAutoPaging() exists on payouts, payins, customers, transfers and payables; wallets->custodial->getDepositsAutoPaging() does the same for wallet deposits:
use BlindPay\SDK\BlindPayApiException; try { foreach ($blindpay->payouts->listAutoPaging(new ListPayoutsInput(customerId: 'cu_000000000000')) as $payout) { echo $payout->id, "\n"; } } catch (BlindPayApiException $e) { error_log('page failed: '.$e->error->message); }
The generator is lazy, asks for limit=100 unless you set one, follows next_page as starting_after while has_more, and goes forward only (endingBefore throws BlindPayException). Each page goes through the normal retry path. A failed page throws BlindPayApiException, so put the try around the foreach.
Lists that are always plain arrays (blockchain wallets, virtual accounts, partner fees, webhook endpoints, ...) return a PHP array in data; customer bank accounts return ListBankAccountsResponse, with the rows in data.
Webhooks
Verify against the raw request body, never re-encoded JSON, and do it before you trust anything in it. No API client is needed, only the endpoint's signing secret (whsec_..., from $blindpay->instances->webhookEndpoints->getSecret($id) or the dashboard). Never log the secret.
use BlindPay\SDK\Resources\Webhooks\WebhookEvents; use BlindPay\SDK\Webhooks\WebhookException; use BlindPay\SDK\Webhooks\WebhookVerifier; $rawBody = file_get_contents('php://input'); $headers = getallheaders(); // header names are matched case-insensitively $secret = getenv('BLINDPAY_WEBHOOK_SECRET'); // Option 1: just check the signature (never throws; 5 minute timestamp tolerance by default) if (! WebhookVerifier::verify($rawBody, $headers, $secret)) { http_response_code(400); exit; } // Option 2: verify and decode into a typed event try { $event = WebhookVerifier::constructEvent($rawBody, $headers, $secret); } catch (WebhookException) { http_response_code(400); exit; } // Deliveries can repeat: dedupe on the svix-id header $deliveryId = array_change_key_case($headers, CASE_LOWER)['svix-id'] ?? ''; // if (alreadyProcessed($deliveryId)) { http_response_code(200); exit; } if ($event->type === WebhookEvents::PAYOUT_COMPLETE || $event->type === WebhookEvents::PAYOUT_UPDATE) { $payout = $event->data; // CreateEvmPayoutResponse // see "Failed payouts" below } http_response_code(200);
WebhookEvent has type (a WebhookEvents case, or the raw string for an event added after this SDK version), data (the typed payload, null for an unknown event), and raw (the decoded JSON). An unknown event never throws: acknowledge it with a 2xx. $event->isKnown() tells them apart.
| Events | data |
|---|---|
customer.new, customer.update |
IndividualWithStandardKYC, IndividualWithEnhancedKYC or BusinessWithStandardKYB |
customer.delete |
CustomerDeletedPayload |
bankAccount.new |
BankAccountListItem |
blockchainWallet.new, blockchainWallet.update |
BlockchainWallet |
limitIncrease.new, limitIncrease.update |
LimitIncreaseRequest (reviewReason says why compliance rejected the request or approved lower limits, and what to send to get the full amount) |
payout.new, .update, .complete, .partnerFee |
CreateEvmPayoutResponse |
payin.new, .update, .complete, .partnerFee |
CreateEvmPayinResponse |
transfer.new, .update, .complete |
CreateTransferResponse |
payable.new, .update, .complete |
Payable |
virtualAccount.new, virtualAccount.complete |
VirtualAccount |
wallet.new, wallet.update |
CustodialWallet |
wallet.inbound |
WalletInboundPayload |
tos.accept |
TosAcceptedPayload |
Failed payouts
A failed payout arrives on both payout.update (with status: "failed") and payout.complete (the dev simulation, for example, sends payout.complete with a failed status). Do not infer failure from the event name; branch on status:
use BlindPay\SDK\Types\TransactionStatus; $payout = $event->data; match ($payout->status) { // TransactionStatus|string: a status this SDK does not know is a plain string TransactionStatus::COMPLETED => markPaid($payout->id), TransactionStatus::FAILED => markFailed($payout->id), TransactionStatus::REFUNDED => markRefunded($payout->id), default => null, // processing, on_hold, ... };
To register an endpoint, see examples/webhook-endpoint.php. The older $blindpay->verifyWebhookSignature(...) still works but is deprecated in favour of WebhookVerifier::verify().
File uploads
KYC and KYB fields take the URL of an uploaded file. Uploads are multipart/form-data; give an UploadFile a path, a stream or bytes:
use BlindPay\SDK\Resources\Upload\AnalyzeUploadInput; use BlindPay\SDK\Resources\Upload\PresignInput; use BlindPay\SDK\Resources\Upload\UploadAnalyzeType; use BlindPay\SDK\Resources\Upload\UploadBucket; use BlindPay\SDK\Resources\Upload\UploadFile; use BlindPay\SDK\Resources\Upload\UploadInput; $file = UploadFile::fromPath('/path/to/proof-of-address.pdf'); // or new UploadFile($bytes, 'doc.pdf', 'application/pdf') $uploaded = $blindpay->upload->create(new UploadInput(UploadBucket::ONBOARDING, $file)); echo $uploaded->data->fileUrl ?? ''; // Rate how likely a document is to be approved, before you submit it $analysis = $blindpay->upload->analyze(new AnalyzeUploadInput( file: UploadFile::fromPath('/path/to/proof-of-address.pdf'), type: UploadAnalyzeType::PROOF_OF_ADDRESS, metadata: ['full_name' => 'Jane Doe'], )); // A short-lived signed URL to read an uploaded file $signed = $blindpay->upload->presign(new PresignInput($uploaded->data->fileUrl));
Uploads authenticate against your instance ID and need a completed onboarding. They carry no idempotency key and are retried only when nothing was sent.
Units
| Value | Unit |
|---|---|
Payout, payin, quote, transfer and limit amounts (requestAmount, senderAmount, ...) |
integer cents (1000 is 10.00) |
| Partner percentage fees | basis points (100 is 1%) |
| Partner flat fees | cents |
| Wallet balances | whole tokens, decimal (10.5 is 10.5 USDC) |
Some response amounts (quote, transfer, payin and payout amounts and fees, totalFeeAmount included) are typed float for backwards compatibility but always hold integer cents; their docblocks say so, and they become int in the next major.
A transfer that swaps between different tokens needs at least 100 USDC, measured on the USDC side; a smaller quote fails with ErrorCode::TRANSFERS_SWAP_AMOUNT_BELOW_MINIMUM (see Error codes).
For whole-unit sender currencies, a CurrencyType::SENDER payin quote amount must be a multiple of 100. Never build cent amounts by multiplying a float: round explicitly ((int) round($dollars * 100)).
Enums
Every enum in a response is open: a value this SDK version does not know is kept as the raw string instead of failing the whole response. Response properties are typed Enum|string, so compare defensively:
if ($payout->status === TransactionStatus::COMPLETED) { /* known value */ } if ($payout->status instanceof TransactionStatus) { $payout->status->value; } // a string never has ->value
Enums in requests (inputs) stay closed, for type safety.
"Exactly one of" inputs
Some inputs take exactly one of two fields. The SDK does not validate this; the API answers 400 if you send both or neither.
- Payout creation (
createEvm,createSolana,createStellar,authorizeStellarToken):senderWalletAddressorwalletId(a BlindPay managed wallet). - Payout quotes (
CreateQuoteInput):bankAccountIdorpayableId.
Tracking endpoints
payouts->getTrack(), payins->getTrack() and transfers->getTrack() call /v1/e/*, the public tracking endpoints of BlindPay's web and iOS apps. From a server-side SDK they return 401 in production. Use payouts->get($id) (and payins->get, transfers->get) instead.
Testing against a development instance
- Amounts of
66600(666.00) make a payout fail, and77700(777.00) make it refund. - Payins complete on their own after about 30 seconds.
- Quotes expire after 5 minutes (OTC payin quotes after 10 seconds; SEPA quotes may expire sooner).
- Development instances are rate limited per minute on every call. A 429
AUTH_RATE_LIMITEDis retried for you, honouringRetry-After. - Settlement is simulated with the test token USDB.
Resources
Everything hangs off the client. Every method takes a trailing ?RequestOptions $options = null.
| Property | Methods |
|---|---|
available |
getBankDetails, getRails, getSwiftCodeBankDetails, getNaicsCodes |
customers |
list, listAutoPaging, create, createIndividualWithStandardKYC, createIndividualWithEnhancedKYC, createBusinessWithStandardKYB, get, update, delete, getLimits, getLimitIncreaseRequests, requestLimitIncrease |
customers->bankAccounts |
list, get, delete, createPix, createPixSafe, createArgentinaTransfers, createSpei, createColombiaAch, createAch, createWire, createInternationalSwift, createRtp, createSepa, createTed, createFasterPayments, createPlaidLink |
customers->rfi |
get, submit |
wallets->blockchain |
list, createWithAddress, createWithHash, getMessage, get, delete, setSelfCustody, createAssetTrustline, mintUsdbStellar, mintUsdbSolana, prepareSolanaDelegationTransaction |
wallets->offramp |
list, create, get, update |
wallets->custodial |
list, get, create, getBalance, delete, getDeposits, getDepositsAutoPaging |
quotes |
create, getFxRate |
payouts |
list, listAutoPaging, get, getTrack, createEvm, createSolana, createStellar, authorizeStellarToken, submitDocuments |
payins |
list, listAutoPaging, get, getTrack, createEvm |
payins->quotes |
create, getFxRate |
transfers |
createQuote, create, list, listAutoPaging, get, getTrack |
payables |
create, list, listAutoPaging, get, delete |
virtualAccounts |
create, update, get, list |
partnerFees |
list, create, get, delete |
fees |
get |
upload |
create, analyze, presign |
instances |
get, update, delete, getMembers, deleteMember, updateMemberRole |
instances->webhookEndpoints |
list, create, delete, getSecret, getPortalAccessUrl |
instances->tos |
initiate |
instances->onboarding |
getBusinessDetails, getBusinessProfile, getOwnershipDocuments |
instances->rfi |
get, submit |
ownership |
migrate |
Responses are the readonly classes next to each resource (BlindPay\SDK\Resources\Payouts\Payout, ...). Request inputs are ...Input classes with named constructor arguments.
Documentation
Releasing
Maintainers: RELEASING.md covers the version source, what a merge to main triggers, how to re-run a stuck release and how to verify the published package.
Support
- Email: support@blindpay.com
- Issues: GitHub Issues
License
This project is licensed under the MIT License. See the LICENSE file for details.
Made by the BlindPay team.