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.

Note

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:

ClientAuthenticates withDefault base URL
GoBTCPayA per-terminal key. Every request is signed with HMAC-SHA256 and a timestamp.https://api.gobtcpay.com/public/api/v1.1
GoBTCPayServerThe 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

Shell
composer require gobtcpay/php-merchant-sdk guzzlehttp/guzzle

Requires 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:

ComponentFound automatically when installed
HTTP clientguzzlehttp/guzzle only
Request factoryguzzlehttp/guzzle, then nyholm/psr7
Stream factoryguzzlehttp/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)

php
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;
OptionRequiredDefaultDescription
apiKeyyesnonePer-terminal key used to sign every request. Must not be empty.
posTerminalIdnonullDefault terminal for createPayment.
baseUrlnohttps://api.gobtcpay.com/public/api/v1.1Override the API base URL.
timeoutMsno30000Per-request timeout for the auto-discovered Guzzle client.
httpClientnoauto-discoveredPSR-18 client.
requestFactorynoauto-discoveredPSR-17 request factory.
streamFactorynoauto-discoveredPSR-17 stream factory.
MethodSignatureDescription
createPaymentcreatePayment(float $amount, string $currency, ?string $posTerminalId, ?string $description, ?int $ttlSeconds, ?string $externalId): PaymentCreate a payment. posTerminalId falls back to the constructor value; without either it throws GoBTCPayException.
getPaymentgetPayment(string $paymentId): PaymentFetch the current state of a payment.
watchPaymentwatchPayment(array $options): PaymentPollerBuild a poller (see Polling).
webhookswebhooks(string $signingSecret, int $toleranceSeconds = 300, int $dedupeCacheSize = 1000): WebhookHandlerBuild 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)

php
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.

OptionRequiredDefaultDescription
apiKeyyesnoneMerchant secret key, sk_live_….
baseUrlnohttps://api.gobtcpay.com/public/api/v1.2Override the API base URL.
timeoutMsno30000Per-request timeout for the auto-discovered Guzzle client.
maxRetriesno2Retries after the first attempt, for calls that are retried (see below).
onEventnonullcallable(array $event): void, called once per request attempt.
httpClientnoauto-discoveredPSR-18 client.
requestFactorynoauto-discoveredPSR-17 request factory.
streamFactorynoauto-discoveredPSR-17 stream factory.
MethodSignatureRetriedDescription
createPaymentcreatePayment(float $amount, string $currency, ?string $externalId, ?string $description, ?int $ttlSeconds): Paymentonly with externalIdCreate a payment for an order.
getPaymentgetPayment(string $paymentId): PaymentyesFetch the current state of a payment.
cancelPaymentcancelPayment(string $paymentId): PaymentyesCancel an unpaid payment.
listPaymentsPagelistPaymentsPage(?array $status, ?string $externalId, ?array $dateRange, int $limit = 50, int $skip = 0): arrayyesOne page: ['items' => list<PaymentListItem>, 'totalCount' => int]. limit is capped at 100.
listPaymentslistPayments(?array $status, ?string $externalId, ?array $dateRange, int $limit = 50): GeneratoryesIterates every page and yields PaymentListItem.
watchPaymentwatchPayment(array $options): PaymentPollerper getPaymentBuild a poller (see Polling).
createWebhookcreateWebhook(string $url, ?WebhookScope $scope = null, ?array $events = null, ?string $label = null): WebhookEndpointWithSecretnoRegister a webhook endpoint. Returns the signing secret (see Webhook endpoints).
listWebhookslistWebhooks(?string $status, int $limit = 50, int $skip = 0): arrayyesOne page of webhook endpoints: ['items' => list<WebhookEndpoint>, 'totalCount' => int]. limit is clamped to 1..100.
findWebhookByUrlfindWebhookByUrl(string $url): ?WebhookEndpointper listWebhooksFind the endpoint registered on exactly this URL (see Webhook endpoints).
testWebhooktestWebhook(string $webhookId): voidnoAsk the platform to queue a test delivery.
webhookswebhooks(string $signingSecret, int $toleranceSeconds = 300, int $dedupeCacheSize = 1000): WebhookHandlern/aBuild 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.

Warning

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.

Important

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.

OptionDefaultDescription
paymentIdrequiredThe payment to poll.
intervalMs3000Wait between polls. Values below 3000 are raised to 3000.
timeoutMs0 (no timeout)When greater than 0, poll() throws GoBTCPayException once this much time has passed. Checked before each fetch.
immediatetrueFetch once before the first sleep.
untilPaymentStatus::finalStatuses()List of PaymentStatus cases at which poll() returns the payment.
stopOnErrorfalseWhen true, the first error is thrown from poll() instead of being reported to onError.
Warning

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.

php
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:

MemberDescription
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.

php
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);
}
MemberDescription
__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.

php
$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 call createWebhook() only when it returns null. createWebhook() is never retried and does not check for an existing endpoint on the same URL. If findWebhookByUrl() throws WebhookSearchLimitException, do not create blindly: the endpoint may already exist.
  • createWebhook() without $scope registers 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() and findWebhookByUrl() return WebhookEndpoint without 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. WebhookEndpointWithSecret has the fields of WebhookEndpoint plus signingSecret; toEndpoint() returns the same endpoint without the secret.
  • createWebhook() is not retried. After a NetworkException the outcome is unknown: look the URL up with findWebhookByUrl() before creating again.
  • A URL the platform refuses raises ValidationException; type() returns the webhook_url_* code unchanged.
  • findWebhookByUrl() pages through listWebhooks() 100 endpoints at a time, at most 20 pages (2000 endpoints), and compares the URL for exact equality, without normalising it. null means the listing ended and the URL is not registered. WebhookSearchLimitException means 20 full pages were read and the answer is unknown. The search stops on a short page, not on totalCount.
Warning

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.

ExceptionExtendsThrown when
GoBTCPayException\RuntimeExceptionBase 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.
ApiExceptionGoBTCPayExceptionThe 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().
AuthExceptionApiExceptionHTTP 401 or 403.
NotFoundExceptionApiExceptionHTTP 404.
RateLimitExceptionApiExceptionHTTP 429. retryAfterMs is set when the response had a Retry-After header.
ServerExceptionApiExceptionHTTP 5xx.
ValidationExceptionApiExceptionAny other HTTP 4xx. For a URL that createWebhook() refuses, type() is the webhook_url_* code.
NetworkExceptionGoBTCPayExceptionNo response arrived. isTimeout is true when the HTTP client's error message mentions a timeout.
WebhookSignatureExceptionGoBTCPayExceptionA webhook delivery failed verification (see Signature verification).
WebhookSearchLimitExceptionGoBTCPayExceptionfindWebhookByUrl() 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:

  • \ValueError from PaymentStatus::from(). Every Payment and PaymentListItem is built with it, so a response or webhook whose status is missing or not a known value throws \ValueError from createPayment, getPayment, cancelPayment, listPayments, listPaymentsPage and WebhookEvent::payment(). It is an \Error, so catch (\Exception $e) does not catch it either; catch \ValueError or \Throwable. Inside poll() it is handled like any other poll error.
  • \RuntimeException from the constructors when no PSR-18 client or PSR-17 factory can be found (see HTTP client).
  • \JsonException when the request parameters cannot be encoded as JSON.
  • \InvalidArgumentException from createWebhook() for a store scope without storeId or a POS scope without posTerminalId, thrown before any request is made.

Versioning

ConstantValueMeaning
GoBTCPay::SDK_VERSION1.1.0Package version, sent in the User-Agent.
GoBTCPay::API_VERSIONv1.1API version used by the POS client.
GoBTCPayServer::SERVER_API_VERSIONv1.2API 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.