bitpay/sdk
Official BitPay PHP SDK for integrating Bitcoin and crypto payments into Laravel/PHP apps. Create and manage invoices, handle payment notifications, and access merchant APIs with a clean, Composer-installable client.
composer require bitpay/sdk
There is no service provider, facade, or publishable config — you wire it yourself. First stop after install is vendor/bitpay/sdk/examples/, not the README: it contains a runnable script per endpoint (CreateInvoice.php, GetInvoice.php, RefundInvoice.php, CreatePayout.php, …) that shows the exact bootstrap for your pinned version. The second stop is src/Client.php to confirm the method names, because the v2/v3 and v4 bootstraps differ:
// v2/v3 style
$client = new \BitPaySDK\Client();
$client->init($privateKeyPath, \BitPaySDK\Env::TEST, $merchantToken);
// v4 style (fluent builder)
$client = \BitPaySDK\Client::create()
->withPrivateKeyFromFile($privateKeyPath, \BitPaySDK\Env::TEST)
->withMerchantToken($merchantToken);
Prerequisites on the BitPay side:
test.bitpay.com (never start on prod)..pem is a signing key, not an API secret, and it never travels over the wire.Register it once in the container:
// config/services.php
'bitpay' => [
'base_url' => env('BITPAY_BASE_URL', 'https://test.bitpay.com'),
'private_key' => env('BITPAY_PRIVATE_KEY_PATH', storage_path('app/bitpay/private_key.pem')),
'merchant_token' => env('BITPAY_MERCHANT_TOKEN'),
'payout_token' => env('BITPAY_PAYOUT_TOKEN'),
],
// AppServiceProvider::register()
$this->app->singleton(\BitPaySDK\Client::class, fn () => (new \BitPaySDK\Client())->init(
config('services.bitpay.private_key'),
config('services.bitpay.base_url'),
config('services.bitpay.merchant_token'),
));
First use case: create an invoice when an order is placed, persist the invoice ID, and redirect the customer to BitPay's hosted checkout.
$invoice = new \BitPaySDK\Model\Invoice\Invoice(49.99, 'USD');
$invoice->setOrderId((string) $order->id);
$invoice->setItemDesc("Order #{$order->id}");
$invoice->setNotificationURL(route('webhooks.bitpay'));
$invoice->setRedirectURL(route('checkout.return', $order));
$created = app(\BitPaySDK\Client::class)->createInvoice($invoice);
$order->update(['bitpay_invoice_id' => $created->getId()]);
return redirect()->away($created->getUrl());
Two-phase checkout (hosted). Never trust the redirectURL as proof of payment — the customer controls the browser. Treat the redirect as "come back and poll", and treat the webhook as the source of truth. On return, call getInvoice($id) and check getStatus() before showing a success page.
Webhook endpoint. Fulfillment belongs in a queued job; the HTTP handler should verify and dispatch in under a second, because BitPay retries non-2xx responses and will hammer a slow endpoint.
Route::post('/webhooks/bitpay', BitPayWebhook::class)
->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
class BitPayWebhook
{
public function __invoke(Request $request)
{
$raw = $request->getContent(); // raw body only — see gotchas
if (! $this->ipn->verify($raw, $request->header('X-Signature'), $request->header('X-Identity'))) {
abort(403);
}
ProcessBitPayEvent::dispatch(json_decode($raw, true))->onQueue('webhooks');
return response()->json(['status' => 'success']);
}
}
The X-Signature header is an ECDSA signature over the raw body, verifiable with the public key belonging to X-Identity. The SDK includes IPN helpers (src/Util/, plus an example); use them rather than hand-rolling verification.
Status state machine. Model it explicitly and only transition forward:
new → invoice created, awaiting paymentpaid → seen on-chain, unconfirmedconfirmed → on-chain confirmed, not yet settledcomplete → settled and credited; safe to fulfillexpired / invalid → stop polling, release inventoryFulfil on complete unless you deliberately accept the settlement risk BitPay documents for confirmed.
Idempotency. Webhooks are at-least-once. Make ProcessBitPayEvent idempotent by guarding on bitpay_invoice_id + status, or wrap the handler in Cache::lock("bitpay:{$invoiceId}"). Also guard invoice creation — a retried checkout request will happily create a second invoice; check for an existing open invoice first.
Payouts. A separate token with payout scope, a separate client instance, and a Recipient that must exist and be approved before you can pay it. Payouts are also the place where the private key matters most (request signing), so the key file must be readable by the queue worker, not just the web process. Model payouts as their own table with a local state machine mirroring BitPay's (new → funded → processing → complete).
Reconciliation. Don't build a reconciliation report from webhooks alone — they get missed. A nightly job over getInvoices (date range) plus getLedgerEntries catches everything, and getExchangeRates / getCurrencies are the endpoints for any currency picker or display logic.
BitPay\Client, new BitPay\Invoice) are everywhere online and will not work. This package is BitPaySDK\. Copy from vendor/bitpay/sdk/examples/, not from blog posts.new Client(); $client->init(...). Pin the version in composer.json and re-read the examples on upgrade — this package has churned its public surface across majors.test.bitpay.com (or vice versa) produces a confusing auth error, not a clear "wrong environment". Keep the token and the base URL in the same env block and never let them be set independently.application/json into $request->all(), and re-encoding reorders keys and breaks the signature. Always $request->getContent().\n sequences and OpenSSL rejects it. Use a file path under storage/app/ with restrictive permissions, or str_replace('\n', "\n", $value).notificationURL must be publicly reachable HTTPS. Localhost silently receives nothing; use ngrok/Expose during development and set the URL per invoice rather than relying on token-level defaults, so it's obvious in code review.setPrice() + setCurrency(), not floating-point arithmetic you round yourself; 0.1 + 0.2 issues become API validation errors. Store money as integer minor units and convert at the boundary.setExpirationTime() deliberately for high-ticket items, and don't leave stale "pending" orders open forever.BitPaySDK\Exceptions\BitPayException (and its BitPayApiException / BitPayValidationException subclasses), then log getMessage() plus the API code — the message is often a JSON blob and the useful bit is buried in it.createInvoice or createPayout without an idempotency guard.test.bitpay.com still issues real (testnet) invoices with real webhook flows, which is what you want — but test-mode checkout has its own payment simulator, so "I paid it" in test tells you nothing about real settlement timing.src/ is a viable debugging tool when the API rejects something.How can I help you explore Laravel packages today?