gobtcpay / php-merchant-sdk
PHP client for the GoBTC Pay POS & merchant API — create Bitcoin payments, verify webhooks, and poll for settlement.
Requires
- php: ^8.1
- ext-json: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.0
- guzzlehttp/guzzle: ^7.0
- nyholm/psr7: ^1.8
- php-http/mock-client: ^1.0
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.0
Suggests
- guzzlehttp/guzzle: Default PSR-18 HTTP client, auto-discovered when installed (^7.0).
- nyholm/psr7: Lightweight PSR-17 request/stream factories (^1.8).
Provides
None
Conflicts
None
Replaces
None
README
PHP client for the GoBTC Pay API — accept Bitcoin payments and track them until they settle on-chain. Two clients share one toolkit:
GoBTCPay— POS terminals. Every request is signed with a fresh HMAC-SHA256 signature + timestamp (per-terminal key).GoBTCPayServer— server-side shop integrations. Authenticates with the merchant's secret key (sk_live_…): create a payment for an order, track it, cancel it, reconcile.
Both share webhook verification and payment polling helpers.
Requirements
- PHP 8.1+
- A PSR-18 HTTP client and PSR-17 factories. Install Guzzle and the SDK auto-discovers it — or pass your own implementation.
Installation
composer require gobtcpay/php-merchant-sdk guzzlehttp/guzzle
guzzlehttp/guzzle is a suggestion, not a hard dependency: any PSR-18 client
works. With Guzzle installed you don't need to wire anything up.
⚠️ The POS
apiKeyand the serversk_live_…key are secrets. Keep them server-side or on a controlled POS device. Never ship them to a browser.
Quick start
POS terminal (GoBTCPay)
use GoBTCPay\PosApiSdk\GoBTCPay; $btcPay = new GoBTCPay( apiKey: getenv('POS_API_KEY'), posTerminalId: getenv('POS_TERMINAL_ID'), ); // Create a payment and show the QR to the customer. $payment = $btcPay->createPayment( amount: 10, currency: 'USD', description: 'Order #1024', ); echo $payment->paymentId, PHP_EOL; echo $payment->qrString, PHP_EOL; // Fetch the current state. $latest = $btcPay->getPayment($payment->paymentId); echo $latest->status->value; // initiated | detected | paid | expired | canceled | failed | cleared
Server-side shop (GoBTCPayServer)
use GoBTCPay\PosApiSdk\GoBTCPayServer; $gobtcpay = new GoBTCPayServer(apiKey: getenv('GOBTCPAY_SECRET_KEY')); $payment = $gobtcpay->createPayment( amount: 49.99, currency: 'USD', externalId: "order-{$order->id}", // makes create idempotent — pass it! ); header('Location: ' . $payment->checkoutUrl);
Bring your own HTTP client
Any PSR-18 client + PSR-17 factories work. Pass them explicitly to skip auto-discovery (or to reuse a configured client):
use GoBTCPay\PosApiSdk\GoBTCPayServer; use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; $factory = new HttpFactory(); $gobtcpay = new GoBTCPayServer( apiKey: getenv('GOBTCPAY_SECRET_KEY'), httpClient: new Client(['timeout' => 30]), requestFactory: $factory, streamFactory: $factory, );
Configuration
GoBTCPay (POS)
| Option | Required | Default | Description |
|---|---|---|---|
apiKey |
yes | — | Per-terminal secret used to sign every request. |
posTerminalId |
no | — | Default terminal for createPayment. |
baseUrl |
no | https://api.gobtcpay.com/public/api/v1.1 |
Override for staging/dev environments. |
timeoutMs |
no | 30000 |
Per-request timeout. |
httpClient |
no | auto-discovered Guzzle | Your PSR-18 client. |
requestFactory |
no | auto-discovered Guzzle/Nyholm | Your PSR-17 request factory. |
streamFactory |
no | auto-discovered Guzzle/Nyholm | Your PSR-17 stream factory. |
GoBTCPayServer
| Option | Required | Default | Description |
|---|---|---|---|
apiKey |
yes | — | Merchant secret key (sk_live_…). pk_live_… rejected. |
baseUrl |
no | https://api.gobtcpay.com/public/api/v1.2 |
Override for staging/dev environments. |
timeoutMs |
no | 30000 |
Per-request timeout. |
maxRetries |
no | 2 |
Retries after the first attempt (network / 429 / 5xx). |
onEvent |
no | — | callable(array $event) called once per attempt. |
httpClient / requestFactory / streamFactory |
no | auto-discovered | Same as above. |
Methods
GoBTCPay
$btcPay->createPayment(amount, currency, posTerminalId?, description?, ttlSeconds?, externalId?): Payment; $btcPay->getPayment(paymentId): Payment; $btcPay->watchPayment(['paymentId' => ..., 'intervalMs' => ..., 'timeoutMs' => ..., 'until' => [...], 'immediate' => ..., 'stopOnError' => ...]): PaymentPoller; $btcPay->webhooks(signingSecret, toleranceSeconds?, dedupeCacheSize?): WebhookHandler;
GoBTCPayServer
$gobtcpay->createPayment(amount, currency, externalId?, description?, ttlSeconds?): Payment; $gobtcpay->getPayment(paymentId): Payment; $gobtcpay->cancelPayment(paymentId): Payment; $gobtcpay->listPayments(status?, externalId?, dateRange?, limit?): Generator<PaymentListItem>; $gobtcpay->listPaymentsPage(status?, externalId?, dateRange?, limit?, skip?): array{items: PaymentListItem[], totalCount: int}; $gobtcpay->watchPayment([...]): PaymentPoller; $gobtcpay->listWebhooks(status?, limit?, skip?): array{items: WebhookEndpoint[], totalCount: int}; $gobtcpay->testWebhook(webhookId): void; $gobtcpay->webhooks(signingSecret, toleranceSeconds?, dedupeCacheSize?): WebhookHandler;
listPayments() is a generator that pages transparently, newest first:
use GoBTCPay\PosApiSdk\Dto\PaymentStatus; foreach ($gobtcpay->listPayments(status: [PaymentStatus::Paid]) as $item) { $this->reconcile($item); }
Auto-polling
watchPayment() returns a PaymentPoller. Because PHP request handlers are
synchronous, poll() is a blocking loop: it calls getPayment on an
interval until the payment reaches one of the statuses it stops at (paid /
cleared / expired / canceled / failed). The interval defaults to and is
clamped to a minimum of 3 seconds.
$poller = $btcPay->watchPayment(['paymentId' => $payment->paymentId, 'intervalMs' => 3000]); $poller->onChange(fn ($status) => render($status)); // any status change $poller->onUpdate(fn ($payment) => {}); // every successful poll $poller->onPaid(fn ($payment) => {}); // transition into `paid` $poller->onSettled(fn ($payment) => {}); // the poller stopped (see the note below) $poller->onError(fn ($error) => {}); // a poll failed $final = $poller->poll(); // blocks, returns the settled Payment
Options (array keys): paymentId (required), intervalMs, timeoutMs,
immediate (default true), until (list of PaymentStatus), stopOnError.
paidvscleared:paidmeans the funds are confirmed on-chain — the success state for external wallet payments. To stop as soon as that happens, pass'until' => [PaymentStatus::Paid], or react toonPaidwhile polling continues.
Stopping is not the same as deciding. The default stop set is "no longer worth polling", not "nothing can change".
expiredin particular is not terminal on the server: the window closing does not close the payment, and funds arriving within the grace period still move it topaidafterwards — so cancelling an order onexpiredcan strand a payment that later succeeds. When your decision is irreversible, read$payment->paidAt(settlement time,nulluntil the payment settles) and$payment->transactionsrather thanPaymentStatus::isFinal().
Webhooks
Register a webhook URL in the merchant dashboard to receive
payment.status.updated events. The handler verifies the X-GoBTCPay-Signature
header (t={timestamp},v1={hmac_hex}), enforces a replay window, and
de-duplicates on eventId.
$webhooks = $gobtcpay->webhooks(getenv('POS_WEBHOOK_SECRET')); $webhooks->on('payment.status.updated', function ($event) { if (!$event->hasPaymentData()) { return; // a test delivery — nothing to update } $payment = $event->payment(); // typed Payment // $event->data is also the raw decoded array error_log("{$payment->paymentId} -> {$payment->status->value}"); });
Guard on hasPaymentData(). A test delivery (see below) arrives correctly
signed, through the same listener, with no payment behind it — $event->test is
true and $event->data is empty. Calling payment() on one throws a
GoBTCPayException; answering 2xx anyway is the right behaviour, and it is what
tells the sender the endpoint works.
handle() returns the parsed WebhookEvent, or null if it was a duplicate.
Use constructEvent() to only verify + parse without dispatching. Always feed
the raw request body — do not decode and re-encode it, or the signature will
not match.
Inspecting and testing endpoints
listWebhooks() returns the endpoints configured for the merchant, and
testWebhook() asks the platform to send a test delivery to one of them:
foreach ($gobtcpay->listWebhooks()['items'] as $endpoint) { echo $endpoint->url, ' ', $endpoint->status, PHP_EOL; } $gobtcpay->testWebhook($endpointId);
The test delivery is a normal signed delivery of type payment.status.updated
carrying test: true and no payment data, so your existing handler receives it —
guard with hasPaymentData() as shown above and answer 2xx.
Three things worth knowing before you build a "test my webhook" button on this:
testWebhook()returning normally means the delivery was queued, not that it arrived. Deliveries are dispatched by a scheduled job, so the test lands at your endpoint some time later — and can still fail there. Whether the webhook works is answered at the receiving end, by observing the delivery.- Both calls are merchant-level, so
listWebhooks()doubles as the cheapest probe of whether a key can manage webhooks at all — an empty list is a perfectly successful answer, while a key restricted to a single store is refused with HTTP 403 (AuthException). The refusal does not say why: the same 403 covers a store-scoped key, a revoked key, an unknown key, a publishable key and an inactive merchant. Show the server's message rather than guessing the cause. listWebhooks()returns one page. Compare the page againsttotalCountand page withskipbefore concluding that a URL is not registered.
Plain PHP
$rawBody = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_GOBTCPAY_SIGNATURE'] ?? null; try { $webhooks->handle($rawBody, $signature); http_response_code(200); // any 2xx acknowledges; non-2xx is retried } catch (\GoBTCPay\PosApiSdk\Exception\WebhookSignatureException) { http_response_code(400); }
Laravel
use Illuminate\Http\Request; Route::post('/webhooks/gobtcpay', function (Request $request) use ($webhooks) { try { $webhooks->handle( $request->getContent(), // RAW body $request->header('X-GoBTCPay-Signature'), ); return response()->noContent(); // 204 } catch (\GoBTCPay\PosApiSdk\Exception\WebhookSignatureException) { return response('invalid signature', 400); } });
Symfony
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; #[Route('/webhooks/gobtcpay', methods: ['POST'])] public function gobtcpay(Request $request): Response { try { $this->webhooks->handle( $request->getContent(), // RAW body $request->headers->get('X-GoBTCPay-Signature'), ); return new Response('', Response::HTTP_NO_CONTENT); } catch (\GoBTCPay\PosApiSdk\Exception\WebhookSignatureException) { return new Response('invalid signature', Response::HTTP_BAD_REQUEST); } }
Error handling
Every error extends GoBTCPay\PosApiSdk\Exception\GoBTCPayException:
| Exception | When |
|---|---|
ApiException |
Any API error envelope / non-2xx (->httpStatus, ->body, ->requestId, ->type()). Base for the ones below. |
AuthException |
401 / 403 — key missing, malformed, revoked, not permitted. |
ValidationException |
400 / 402 — request rejected; retrying unchanged won't help. |
NotFoundException |
404 — no such payment or webhook endpoint. |
RateLimitException |
429 — too many requests (->retryAfterMs). |
ServerException |
5xx — the API failed; safe to retry idempotent calls. |
NetworkException |
No response: connection / DNS / TLS / timeout (->isTimeout). |
WebhookSignatureException |
A webhook signature failed verification. |
use GoBTCPay\PosApiSdk\Exception\ApiException; use GoBTCPay\PosApiSdk\Exception\NetworkException; use GoBTCPay\PosApiSdk\Exception\RateLimitException; try { $payment = $gobtcpay->createPayment(amount: 10, currency: 'USD', externalId: 'order-1'); } catch (RateLimitException $e) { // back off for $e->retryAfterMs } catch (ApiException $e) { error_log("API {$e->httpStatus} ({$e->requestId}): {$e->getMessage()}"); } catch (NetworkException $e) { // connection failure / timeout ($e->isTimeout) }
The server client retries network failures, 429 and 5xx automatically
(maxRetries, default 2) with exponential backoff + jitter. Reads and
idempotent create calls (those with an externalId) are retried; a
create without an externalId is not, so a retry can't double-charge. The
POS client does not retry — a human at the terminal sees the failure and taps
again.
Development
composer install composer lint # php-cs-fixer --dry-run --diff composer lint:fix # apply fixes composer phpstan # static analysis (level 8) composer test # PHPUnit unit suite
Docker
A Dockerfile is included so that all checks run in an identical environment
locally and in CI:
docker build -t gobtcpay-php-sdk . docker run --rm gobtcpay-php-sdk sh -c 'composer install && composer test && composer phpstan && composer lint'
CI builds this image per pipeline and runs lint, phpstan, and test jobs
against it.
Testing
Unit tests — pure logic, no network. They gate every MR and run in CI:
request signing (HMAC cross-checked against hash_hmac), the envelope
transport (retries, typed exceptions), webhook verification (signature / replay
window / de-dup), and the poller lifecycle.
composer test
composer test:coverage
Integration tests — exercise the SDK end-to-end against a live POS API on the test contour. Opt-in and self-skipping unless credentials are present:
cp .env.example .env # fill in test-contour values, then:
POS_API_KEY=... POS_TERMINAL_ID=... composer test:integration
Use test-contour credentials only — never a production key.
Versioning
The API version is pinned inside each client (POS v1.1, server v1.2),
exported as GoBTCPay::API_VERSION / GoBTCPayServer::SERVER_API_VERSION — you
don't put it in a URL. Need a different version or environment? Override
baseUrl.
Publishing
How it works
- A merge into
maintriggers thelint,phpstan, andtestGitHub Actions jobs. - If they pass, the
releasejob runs semantic-release, which reads the Conventional Commits since the last release:fix:bumps a patch version,feat:bumps minor, aBREAKING CHANGE:footer bumps major. Anything else (docs:,chore:,ci:, …) does not trigger a release. - On a release, semantic-release updates
CHANGELOG.md, bumpsSDK_VERSIONinsrc/GoBTCPay.php/src/GoBTCPayServer.php, commitschore(release): x.y.z [skip ci], tags it, and creates a GitHub release. - That push fires a GitHub webhook (Settings → Webhooks, configured on
this repo) that pings
https://packagist.org/api/github. Packagist re-reads the tag from GitHub and publishes it as a new version — no manual "Update" click, no separate publish step.
Distribution is through the public Packagist
package gobtcpay/php-merchant-sdk.
Giving this to a merchant
Nothing special — it's a public Composer package like any other. Point them
at this repo or the Packagist page;
they run composer require gobtcpay/php-merchant-sdk in their own project and
follow Installation / Quick start. No token,
no invite, no access request. Their apiKey / sk_live_… credentials are
issued separately and are unrelated to installing the package.
Exception — WordPress/WooCommerce/Shopify plugins: those merchants typically don't run Composer at all, and bundling this SDK's dependencies (Guzzle, PSR interfaces) as-is risks class name collisions with other plugins on the same site. A plugin that embeds this SDK should vendor it at build time with a namespace-prefixing tool (php-scoper or Strauss) and ship the prefixed code inside the plugin's own zip — the merchant never touches Composer or this package directly.
По-русски
Как это работает.
- Мерж в
mainзапускает джобыlint,phpstan,testв GitHub Actions. - Если они прошли — джоба
releaseзапускает semantic-release, которая читает Conventional Commits с прошлого релиза:fix:поднимает patch-версию,feat:— minor, футерBREAKING CHANGE:— major. Остальное (docs:,chore:,ci:и т.п.) релиз не создаёт. - При релизе semantic-release обновляет
CHANGELOG.md, поднимаетSDK_VERSIONвsrc/GoBTCPay.php/src/GoBTCPayServer.php, коммититchore(release): x.y.z [skip ci], ставит тег и создаёт GitHub release. - Этот пуш триггерит GitHub webhook (настроен в Settings → Webhooks
этого репозитория), который стучится в
https://packagist.org/api/github. Packagist сам подтягивает новый тег с GitHub и публикует версию — без ручного нажатия Update, без отдельного шага публикации.
Дистрибуция — через публичный Packagist, пакет
gobtcpay/php-merchant-sdk.
Как передать этот пакет мерчанту. Ничего особенного делать не нужно —
это обычный публичный Composer-пакет. Дайте ссылку на этот репозиторий или
на страницу пакета на Packagist;
у себя в проекте мерчант просто выполняет
composer require gobtcpay/php-merchant-sdk и дальше следует разделам
Installation / Quick start. Токен, приглашение
или согласование доступа не требуются. Ключи apiKey / sk_live_… выдаются
отдельно и никак не связаны с установкой пакета.
Исключение — плагины для WordPress/WooCommerce/Shopify. Такие мерчанты обычно вообще не используют Composer, а прямое встраивание зависимостей SDK (Guzzle, PSR-интерфейсы) рискует конфликтом имён классов с другими плагинами на том же сайте. Плагин, встраивающий этот SDK, должен вендорить его на этапе сборки инструментом с префиксацией неймспейсов (php-scoper или Strauss) и поставлять уже префиксированный код внутри zip-архива плагина — мерчант напрямую с Composer или этим пакетом не взаимодействует.
License
MIT — see LICENSE. You may freely use, copy, modify, merge, publish, distribute, sublicense, and sell the software, including in closed-source and commercial products. Keep the copyright notice and license text in copies. Provided "as is", without warranty.