PHP SDK
Reference for gobtcpay/php-merchant-sdk, the PHP client for the GoBTC Pay API. Use it to create Bitcoin payments from PHP code, verify webhook deliveries, and poll a payment from a background job.
This page covers version 1.1.0.
Both keys are secrets. The POS terminal apiKey and the merchant sk_live_… key authenticate every request. Keep them on your server or on a controlled POS device, never in a browser.
Overview
The package ships two clients that share the same webhook and polling helpers:
| Client | Authenticates with | Default base URL |
|---|---|---|
GoBTCPay | A per-terminal key. Every request is signed with HMAC-SHA256 and a timestamp. | https://api.gobtcpay.com/public/api/v1.1 |
GoBTCPayServer | The merchant secret key (sk_live_…), sent as a Bearer token. | https://api.gobtcpay.com/public/api/v1.2 |
All classes live under the GoBTCPay\PosApiSdk namespace, including GoBTCPayServer.
Installation
composer require gobtcpay/php-merchant-sdk guzzlehttp/guzzleRequires PHP 8.1 or newer and the json extension.
HTTP client
The package depends only on the PSR interfaces: psr/http-client (PSR-18), psr/http-factory (PSR-17) and psr/http-message (PSR-7). It does not install an HTTP client, so you either install Guzzle or pass your own implementation.
Each constructor accepts three optional arguments: httpClient (a PSR-18 ClientInterface), requestFactory (a PSR-17 RequestFactoryInterface) and streamFactory (a PSR-17 StreamFactoryInterface). For any argument you leave out, the SDK looks for a concrete class itself:
| Component | Found automatically when installed |
|---|---|
| HTTP client | guzzlehttp/guzzle only |
| Request factory | guzzlehttp/guzzle, then nyholm/psr7 |
| Stream factory | guzzlehttp/guzzle, then nyholm/psr7 |
This is a plain class check, not php-http/discovery. If nothing is found, the constructor throws \RuntimeException, which is not a GoBTCPayException.
The timeoutMs option is applied only to the Guzzle client the SDK creates itself. When you pass your own httpClient, configure the timeout on that client.
GoBTCPay (POS terminal)
use GoBTCPay\PosApiSdk\GoBTCPay;
$btcPay = new GoBTCPay(
apiKey: (string) getenv('POS_API_KEY'),
posTerminalId: (string) getenv('POS_TERMINAL_ID'),
);
$payment = $btcPay->createPayment(amount: 10, currency: 'USD', description: 'Order #1024');
echo $payment->paymentId, PHP_EOL, $payment->qrString, PHP_EOL;| Option | Required | Default | Description |
|---|---|---|---|
apiKey | yes | none | Per-terminal key used to sign every request. Must not be empty. |
posTerminalId | no | null | Default terminal for createPayment. |
baseUrl | no | https://api.gobtcpay.com/public/api/v1.1 | Override the API base URL. |
timeoutMs | no | 30000 | Per-request timeout for the auto-discovered Guzzle client. |
httpClient | no | auto-discovered | PSR-18 client. |
requestFactory | no | auto-discovered | PSR-17 request factory. |
streamFactory | no | auto-discovered | PSR-17 stream factory. |
| Method | Signature | Description |
|---|---|---|
createPayment | createPayment(float $amount, string $currency, ?string $posTerminalId, ?string $description, ?int $ttlSeconds, ?string $externalId): Payment | Create a payment. posTerminalId falls back to the constructor value; without either it throws GoBTCPayException. |
getPayment | getPayment(string $paymentId): Payment | Fetch the current state of a payment. |
watchPayment | watchPayment(array $options): PaymentPoller | Build a poller (see Polling). |
webhooks | webhooks(string $signingSecret, int $toleranceSeconds = 300, int $dedupeCacheSize = 1000): WebhookHandler | Build a webhook handler (see Webhooks). |
GoBTCPay never retries a request: it is built with zero retries, so every failure reaches your code on the first attempt.
GoBTCPayServer (shop backend)
use GoBTCPay\PosApiSdk\GoBTCPayServer;
$gobtcpay = new GoBTCPayServer(apiKey: (string) getenv('GOBTCPAY_SECRET_KEY'));
$payment = $gobtcpay->createPayment(amount: 49.99, currency: 'USD', externalId: 'order-1024');
header('Location: ' . $payment->checkoutUrl);The constructor rejects an empty key, a publishable key (pk_live_…), and any key that does not start with sk_live_, each with a GoBTCPayException.
| Option | Required | Default | Description |
|---|---|---|---|
apiKey | yes | none | Merchant secret key, sk_live_…. |
baseUrl | no | https://api.gobtcpay.com/public/api/v1.2 | Override the API base URL. |
timeoutMs | no | 30000 | Per-request timeout for the auto-discovered Guzzle client. |
maxRetries | no | 2 | Retries after the first attempt, for calls that are retried (see below). |
onEvent | no | null | callable(array $event): void, called once per request attempt. |
httpClient | no | auto-discovered | PSR-18 client. |
requestFactory | no | auto-discovered | PSR-17 request factory. |
streamFactory | no | auto-discovered | PSR-17 stream factory. |
| Method | Signature | Retried | Description |
|---|---|---|---|
createPayment | createPayment(float $amount, string $currency, ?string $externalId, ?string $description, ?int $ttlSeconds): Payment | only with externalId | Create a payment for an order. |
getPayment | getPayment(string $paymentId): Payment | yes | Fetch the current state of a payment. |
cancelPayment | cancelPayment(string $paymentId): Payment | yes | Cancel an unpaid payment. |
listPaymentsPage | listPaymentsPage(?array $status, ?string $externalId, ?array $dateRange, int $limit = 50, int $skip = 0): array | yes | One page: ['items' => list<PaymentListItem>, 'totalCount' => int]. limit is capped at 100. |
listPayments | listPayments(?array $status, ?string $externalId, ?array $dateRange, int $limit = 50): Generator | yes | Iterates every page and yields PaymentListItem. |
watchPayment | watchPayment(array $options): PaymentPoller | per getPayment | Build a poller (see Polling). |
createWebhook | createWebhook(string $url, ?WebhookScope $scope = null, ?array $events = null, ?string $label = null): WebhookEndpointWithSecret | no | Register a webhook endpoint. Returns the signing secret (see Webhook endpoints). |
listWebhooks | listWebhooks(?string $status, int $limit = 50, int $skip = 0): array | yes | One page of webhook endpoints: ['items' => list<WebhookEndpoint>, 'totalCount' => int]. limit is clamped to 1..100. |
findWebhookByUrl | findWebhookByUrl(string $url): ?WebhookEndpoint | per listWebhooks | Find the endpoint registered on exactly this URL (see Webhook endpoints). |
testWebhook | testWebhook(string $webhookId): void | no | Ask the platform to queue a test delivery. |
webhooks | webhooks(string $signingSecret, int $toleranceSeconds = 300, int $dedupeCacheSize = 1000): WebhookHandler | n/a | Build a webhook handler (see Webhooks). |
For listPayments and listPaymentsPage, status is a list of PaymentStatus cases and dateRange is ['start' => int, 'end' => int] in unix seconds over the creation time.
Retries
A retried call is repeated after a NetworkException, an HTTP 429, or an HTTP 5xx, up to maxRetries times. Any other error is thrown at once. Between attempts the SDK sleeps: the Retry-After value for a 429 that sends one, otherwise a random delay between 0 and 250 ms × 2^attempt, capped at 4 seconds. The sleep happens inside the call.
createPayment is retried only when you pass externalId. The SDK passes retry: $externalId !== null for this call. With externalId the call is idempotent, so a repeat returns the payment already created for that order. Without it a repeat could create a second payment, so the SDK makes a single attempt.
testWebhook is never retried, because a repeat would queue a second test delivery. createWebhook is never retried either: the call has no idempotency key, so a repeat after a timeout could register a second endpoint.
Request events
onEvent receives one array per attempt with path, attempt, durationMs, and, depending on the outcome, httpStatus, requestId, error, and willRetry. An exception thrown inside onEvent is ignored.
willRetry describes the kind of failure, not what this call will do: it is true for every network error and for every 429 or 5xx, even on the last attempt and on calls that are not retried.
Do not log the error object from onEvent whole. For a network failure it is a NetworkException whose previous is the HTTP client's PSR-18 exception, and that exception can return the request it was sending, including the Authorization header (Bearer <key> when the SDK uses the merchant secret). Log the message and code (getMessage(), getCode()), not the exception object or its request.
Polling
watchPayment() returns a PaymentPoller. Its poll() method is a blocking loop: it fetches the payment, calls your listeners, sleeps for the interval, and repeats until the payment reaches one of the until statuses. With the default options there is no timeout.
Do not call poll() inside a web request. The request does not return until polling ends, and without timeoutMs there is no upper bound on how long that takes. Run it in a CLI worker or a queued job. pollAsync() is an alias of poll() and blocks in exactly the same way.
| Option | Default | Description |
|---|---|---|
paymentId | required | The payment to poll. |
intervalMs | 3000 | Wait between polls. Values below 3000 are raised to 3000. |
timeoutMs | 0 (no timeout) | When greater than 0, poll() throws GoBTCPayException once this much time has passed. Checked before each fetch. |
immediate | true | Fetch once before the first sleep. |
until | PaymentStatus::finalStatuses() | List of PaymentStatus cases at which poll() returns the payment. |
stopOnError | false | When true, the first error is thrown from poll() instead of being reported to onError. |
The default until set stops on expired, and that loses a late payment. PaymentStatus::finalStatuses() contains Expired, but a payment that has expired can still become paid when funds arrive late. A poller that stops there never sees that change. The webhook is the primary channel for payment updates. When you do poll, leave PaymentStatus::Expired out of until and set timeoutMs.
use GoBTCPay\PosApiSdk\Dto\Payment;
use GoBTCPay\PosApiSdk\Dto\PaymentStatus;
use GoBTCPay\PosApiSdk\Exception\GoBTCPayException;
// In a CLI worker, not in a web request.
$poller = $gobtcpay->watchPayment([
'paymentId' => $paymentId,
'intervalMs' => 5000,
'timeoutMs' => 30 * 60 * 1000,
// The default stop set without PaymentStatus::Expired.
'until' => [PaymentStatus::Paid, PaymentStatus::Cleared, PaymentStatus::Canceled, PaymentStatus::Failed],
]);
$poller->onPaid(function (Payment $payment): void {
markOrderPaid($payment->paymentId);
});
$poller->onError(function (\Throwable $error): void {
error_log('GoBTC Pay poll failed: ' . $error->getMessage());
});
try {
$payment = $poller->poll();
} catch (GoBTCPayException $e) {
// The timeout was reached. The payment can still change: keep relying on the webhook.
}PaymentPoller members:
| Member | Description |
|---|---|
poll() | Run the loop. Returns the Payment once it reaches an until status. |
pollAsync() | Alias of poll(). Also blocking. |
onChange($cb) | Called after every successful poll with (PaymentStatus $status, Payment $payment), whether or not the status changed. |
onUpdate($cb) | Called after every successful poll with the Payment. |
onPaid($cb) | Called with the Payment when a poll sees paid and the previous successful poll did not. |
onSettled($cb) | Called with the Payment when it reaches an until status, just before poll() returns. |
onError($cb) | Called with the \Throwable when a poll fails. |
attempts() | Number of polls made so far, successful or not. |
lastPayment() | The most recent Payment, or null before the first successful poll. |
The on* methods return the poller, so they can be chained. There is no unsubscribe and no stop(): the loop ends only on an until status, on the timeout, on an error with stopOnError, or when an onError listener itself throws.
With stopOnError left at false, every \Throwable raised by the fetch or by an onChange, onUpdate, onPaid or onSettled listener goes to onError and polling continues. That includes an exception thrown by one of your own listeners and a \ValueError for a status the SDK does not know. With stopOnError: true, poll() rethrows it, wrapping anything that is not a GoBTCPayException in one.
For what each status means, see Payment Lifecycle.
Webhooks
WebhookHandler verifies a delivery, drops duplicates, and dispatches it to your listeners. Create it with new WebhookHandler($signingSecret) or with the webhooks() method of either client. The signing secret is the one returned once when the webhook endpoint is created, either in the merchant dashboard or by GoBTCPayServer::createWebhook() as ->signingSecret.
use GoBTCPay\PosApiSdk\Dto\WebhookEvent;
use GoBTCPay\PosApiSdk\Exception\WebhookSignatureException;
use GoBTCPay\PosApiSdk\WebhookHandler;
$webhooks = new WebhookHandler((string) getenv('GOBTCPAY_WEBHOOK_SECRET'));
$webhooks->on(WebhookEvent::TYPE_PAYMENT_STATUS_UPDATED, function (WebhookEvent $event): void {
if (!$event->hasPaymentData()) {
return; // A test delivery: acknowledge it, there is no order to update.
}
$payment = $event->payment();
// Apply the update only if $payment->version is greater than the version you stored.
updateOrder($payment);
});
// Pass the raw body. Decoding and re-encoding it breaks the signature.
$rawBody = file_get_contents('php://input') ?: '';
$signature = $_SERVER['HTTP_X_GOBTCPAY_SIGNATURE'] ?? null;
try {
$webhooks->handle($rawBody, $signature);
http_response_code(200);
} catch (WebhookSignatureException $e) {
http_response_code(400);
}| Member | Description |
|---|---|
__construct(string $signingSecret, int $toleranceSeconds = 300, int $dedupeCacheSize = 1000) | Create the handler. |
on(string $type, callable $listener) | Subscribe to an event type. Returns a callable that unsubscribes. |
handle(string $rawBody, ?string $signatureHeader) | Verify, de-duplicate and dispatch. Returns the WebhookEvent, or null for a duplicate. |
constructEvent(string $rawBody, ?string $signatureHeader) | Verify and parse only: no de-duplication, no listeners. |
Signature verification
The X-GoBTCPay-Signature header has the form t={timestamp},v1={signature}. The handler computes HMAC-SHA256(signingSecret, "{t}.{rawBody}") and compares it with v1 in constant time.
Before that it checks the timestamp: a delivery whose t is more than toleranceSeconds away from the current time, in either direction, is rejected. The default window is 300 seconds; 0 turns the check off.
handle() and constructEvent() throw WebhookSignatureException when the header is missing or malformed, the timestamp is outside the window, the signature does not match, or the body is not valid JSON.
De-duplication
handle() remembers the eventId of the last dedupeCacheSize deliveries (1000 by default; 0 turns it off) and returns null for one it has already seen, without calling listeners.
That memory is an array inside the handler instance and nothing else. Under PHP-FPM, or any setup that builds the handler per request, it is empty on every request, so a redelivered event reaches your listeners again. Make your own processing idempotent: record the eventId you have handled, or compare Payment::$version with the last one you stored and ignore anything not greater.
In a long-running process the opposite case applies: the eventId is remembered before the listeners run, so if a listener throws and the same delivery arrives again in that process, handle() returns null for it.
Webhook endpoints
GoBTCPayServer can register and look up the merchant's webhook endpoints, so a shop integration can connect itself without the dashboard. GoBTCPay has no such methods.
$url = 'https://shop.example.com/webhooks/gobtcpay';
// Throws WebhookSearchLimitException when the answer is unknown: do not create then.
$existing = $gobtcpay->findWebhookByUrl($url);
if ($existing === null) {
$endpoint = $gobtcpay->createWebhook($url);
saveSigningSecret($endpoint->signingSecret); // returned only here
}- Call
findWebhookByUrl()first and callcreateWebhook()only when it returnsnull.createWebhook()is never retried and does not check for an existing endpoint on the same URL. IffindWebhookByUrl()throwsWebhookSearchLimitException, do not create blindly: the endpoint may already exist. createWebhook()without$scoperegisters the endpoint for the whole merchant.$scope(Dto\WebhookScope) only filters which deliveries reach the endpoint; it is not a restriction on the API key.- The signing secret is returned by
createWebhook()and nowhere else:listWebhooks()andfindWebhookByUrl()returnWebhookEndpointwithout it, and it cannot be read back later. Rotating the secret is the only way to get a new one, and it invalidates the previous one at once.WebhookEndpointWithSecrethas the fields ofWebhookEndpointplussigningSecret;toEndpoint()returns the same endpoint without the secret. createWebhook()is not retried. After aNetworkExceptionthe outcome is unknown: look the URL up withfindWebhookByUrl()before creating again.- A URL the platform refuses raises
ValidationException;type()returns thewebhook_url_*code unchanged. findWebhookByUrl()pages throughlistWebhooks()100 endpoints at a time, at most 20 pages (2000 endpoints), and compares the URL for exact equality, without normalising it.nullmeans the listing ended and the URL is not registered.WebhookSearchLimitExceptionmeans 20 full pages were read and the answer is unknown. The search stops on a short page, not ontotalCount.
A store-scoped key is refused. createWebhook(), listWebhooks(), findWebhookByUrl() and testWebhook() are merchant-level: a key limited to one store gets HTTP 403 (AuthException, permission_error). The platform returns the same permission_error for a revoked, unknown or publishable key and for an inactive merchant, with no machine-readable marker to tell them apart. Show the message to a person; do not branch on it.
Duplicates are not prevented. The platform does not check the URL against existing endpoints. "Find, then create" is two requests with nothing atomic between them: two processes connecting the same shop at once can both find nothing and both create, leaving two endpoints with one URL. Hold a lock for the duration of the connection if that matters to you.
Test deliveries
GoBTCPayServer::testWebhook() asks the platform to queue a test delivery. Returning normally means the delivery was queued, not that it arrived; if the platform does not confirm, the method throws GoBTCPayException.
A test delivery is correctly signed and has test set to true, but its data is null. On such an event hasPaymentData() returns false and payment() throws GoBTCPayException. Check hasPaymentData() first, as in the example above, and answer with a 2xx.
Errors
Every SDK-defined exception extends GoBTCPayException, which extends PHP's \RuntimeException.
| Exception | Extends | Thrown when |
|---|---|---|
GoBTCPayException | \RuntimeException | Base class. Also thrown directly for a rejected apiKey, a missing posTerminalId, a poll timeout, payment() on a delivery without data, a test delivery the platform did not queue, and a createWebhook() response that does not contain the created endpoint. |
ApiException | GoBTCPayException | The API answered with an error or a body the SDK does not recognise. Thrown as the subclass for the HTTP status below, or as ApiException itself for any other status. Exposes httpStatus, body, requestId and type(). |
AuthException | ApiException | HTTP 401 or 403. |
NotFoundException | ApiException | HTTP 404. |
RateLimitException | ApiException | HTTP 429. retryAfterMs is set when the response had a Retry-After header. |
ServerException | ApiException | HTTP 5xx. |
ValidationException | ApiException | Any other HTTP 4xx. For a URL that createWebhook() refuses, type() is the webhook_url_* code. |
NetworkException | GoBTCPayException | No response arrived. isTimeout is true when the HTTP client's error message mentions a timeout. |
WebhookSignatureException | GoBTCPayException | A webhook delivery failed verification (see Signature verification). |
WebhookSearchLimitException | GoBTCPayException | findWebhookByUrl() read 20 full pages without finding the URL: whether it is registered is unknown. Exposes pagesScanned, scannedCount and totalCount (as reported by the server; informational only). |
Four exceptions come from outside this hierarchy, so catch (GoBTCPayException $e) does not stop them:
\ValueErrorfromPaymentStatus::from(). EveryPaymentandPaymentListItemis built with it, so a response or webhook whosestatusis missing or not a known value throws\ValueErrorfromcreatePayment,getPayment,cancelPayment,listPayments,listPaymentsPageandWebhookEvent::payment(). It is an\Error, socatch (\Exception $e)does not catch it either; catch\ValueErroror\Throwable. Insidepoll()it is handled like any other poll error.\RuntimeExceptionfrom the constructors when no PSR-18 client or PSR-17 factory can be found (see HTTP client).\JsonExceptionwhen the request parameters cannot be encoded as JSON.\InvalidArgumentExceptionfromcreateWebhook()for a store scope withoutstoreIdor a POS scope withoutposTerminalId, thrown before any request is made.
Versioning
| Constant | Value | Meaning |
|---|---|---|
GoBTCPay::SDK_VERSION | 1.1.0 | Package version, sent in the User-Agent. |
GoBTCPay::API_VERSION | v1.1 | API version used by the POS client. |
GoBTCPayServer::SERVER_API_VERSION | v1.2 | API version used by the server client. |
The API version is part of the default base URL, so you do not put it in your own URLs.