POS API SDK

Reference for @gobtcpay/pos-api-sdk, the official client for the GoBTC Pay API. Use it to accept Bitcoin payments from a point-of-sale terminal: create a payment, show the customer the QR code, and track it until it settles on-chain. The same package also ships GoBTCPayServer, a server-side client for shops (see Server client).

This page covers version 2.0.0.

For a setup walkthrough, see Accept Payments Online.

Note

Where to put the apiKey. The apiKey is a per-terminal secret used to sign every request. Keep it server-side, or on a controlled POS device. Do not ship it in a public website. For browser usage, the API must also allow your origin via CORS, otherwise route the calls through your own backend.

Overview

The package is a thin client for the POS API surface. It signs each request for you with a fresh HMAC signature and timestamp, so there is no token to manage or refresh.

The same package runs on a server, in a bundled browser app, and in a plain HTML page, because it uses the Web Crypto API (built into both browsers and Node) rather than node:crypto.

Use it when you are building a checkout or POS integration against a specific terminal, and you hold that terminal's key.

Installation

Shell
npm install @gobtcpay/pos-api-sdk

Runs on Node 18 or newer and in modern browsers. Both ESM and CommonJS entry points ship, along with TypeScript declarations.

TypeScript
import { GoBTCPay } from "@gobtcpay/pos-api-sdk";
JavaScript
const { GoBTCPay } = require("@gobtcpay/pos-api-sdk");

Initialization

Construct the client once. The terminal key signs every request automatically.

TypeScript
import { GoBTCPay } from "@gobtcpay/pos-api-sdk";

const btcPay = new GoBTCPay({
  apiKey: process.env.POS_API_KEY,
  posTerminalId: process.env.POS_TERMINAL_ID,
});
OptionRequiredDefaultDescription
apiKeyyesnonePer-terminal secret used to sign every request.
posTerminalIdnononeDefault terminal for createPayment.
baseUrlnohttps://api.gobtcpay.com/public/api/v1.1Override for staging or dev environments.
timeoutMsno30000Per-request timeout.
fetchnoglobal fetchCustom fetch implementation.

The POS client never retries a failed request: a failure is thrown on the first attempt. Retries exist only in GoBTCPayServer.

Note

API version is pinned in the SDK. The version (v1.1, exported as API_VERSION) lives inside the package, so you do not put it in a URL. The SDK always speaks the API version it was built and tested against. To target a different environment, override baseUrl.

Reference

Classes and types

NameKindDescription
GoBTCPayclassThe POS client. Construct it with the options above.
GoBTCPayServerclassThe server client for shops, authenticated with a secret key. See Server client.
PaymentPollerclassReturned by watchPayment(). Holds live state, subscriptions, and a done() promise.
WebhookHandlerclassReturned by webhooks(). Verifies, de-duplicates and dispatches webhook deliveries.
FINAL_STATUSESconstantStatuses at which the poller stops by default. See Auto-polling.
MIN_POLL_INTERVAL_MSconstantMinimum and default polling interval: 3000.
API_VERSIONconstantThe API version the POS client speaks (v1.1).
SERVER_API_VERSIONconstantThe API version GoBTCPayServer speaks (v1.2).
SIGNATURE_HEADERconstantName of the webhook signature header, lower-cased: "x-gobtcpay-signature".
isTestDeliveryfunctionisTestDelivery(delivery) is true for a test delivery and narrows the type: when it is false, delivery.data is the payment. See Webhooks.
WebhookSearchLimitErrorclassThrown by webhookEndpoints.findByUrl when its page limit is reached. See Errors.
WebhookDeliverytypeWhat a webhook listener receives: a WebhookEvent or a WebhookTestEvent.
WebhookTestEventtypeA test delivery: { eventId, type, createdAt, test: true, data: null }.
WebhookEndpointtypeA registered webhook endpoint: { id, merchantId, url, scope, events, status, label, createdAt, updatedAt }. Carries no signing secret.
WebhookEndpointWithSecrettypeWebhookEndpoint plus signingSecret. Returned only by webhookEndpoints.create.
WebhookEndpointStatustype"active" or "disabled".
WebhookScopetypeWhich deliveries an endpoint receives: { type: "merchant" }, { type: "store", storeId } or { type: "pos", posTerminalId }. Not a restriction on the API key.
CreateWebhookEndpointParamstypeParameters of webhookEndpoints.create: { url, scope?, events?, label? }.
ListWebhookEndpointsParamstypeParameters of webhookEndpoints.list: { status?, limit?, skip? }.

A payment's status field is a string. This page does not describe what each status means; see Payment Lifecycle.

Methods

MethodSignatureDescription
createPaymentcreatePayment({ amount, currency, description?, ttlSeconds?, externalId?, posTerminalId? })Create a payment. Returns paymentId and qrString to show the customer.
getPaymentgetPayment({ paymentId })Fetch the current state of a payment.
watchPaymentwatchPayment({ paymentId, intervalMs?, timeoutMs?, until?, immediate?, stopOnError? })Poll getPayment until one of the until statuses. Returns a started PaymentPoller.
webhookswebhooks({ signingSecret, toleranceSeconds?, dedupeCacheSize? })Create a webhook handler (see Webhooks).

PaymentPoller members:

MemberDescription
stateLive, readable object: { status, payment, error, attempts, isPolling, isFinal, startedAt, updatedAt }.
start()Starts polling and returns the poller. watchPayment() already calls it. A no-op while running or once stopped at an until status.
onChange(cb)Fires on any state change. Returns an unsubscribe function.
onUpdate(cb)Fires on every successful poll.
onPaid(cb)Fires when a poll returns paid and the previously known status was not paid.
onSettled(cb)Fires when a poll returns one of the until statuses.
onError(cb)Fires when a poll fails.
done()Promise that resolves with the payment that stopped the poller, or rejects on timeout (or on the first error with stopOnError). Call it before the poller stops: a done() first called after the poller has reached an until status never settles.
stop()Stops polling early. Does not resolve done().

Events

The only named event is the webhook event below. The PaymentPoller delivers its updates through the subscription methods in the table above (onChange, onUpdate, onPaid, onSettled, onError), not as named events.

EventPayloadDescription
payment.status.updated{ eventId, type, createdAt, data }; data has the getPayment() shape. A test delivery has the same type with test: true and data: nullA payment changed status, or the platform sent a test delivery. Delivered to your webhook endpoint (see Webhooks).

Errors

Every error class the SDK defines extends GoBTCPayError. Each error's name is its class name.

ErrorExtendsWhen it is thrownExtra fields
GoBTCPayErrorErrorBase class. Thrown directly for invalid client setup: missing apiKey, no fetch, a rejected key in GoBTCPayServer, missing posTerminalId in createPayment. Also thrown by webhookEndpoints.create when the response does not contain the created endpoint, and by webhookEndpoints.test when the platform does not confirm it queued the test delivery.none
GoBTCPayApiErrorGoBTCPayErrorThe API answered with an error. Thrown as-is only when the HTTP status is below 400; otherwise one of the subclasses below.httpStatus, body, requestId, getter type (body.data.type)
AuthErrorGoBTCPayApiErrorHTTP 401 or 403.same as GoBTCPayApiError
NotFoundErrorGoBTCPayApiErrorHTTP 404.same as GoBTCPayApiError
RateLimitErrorGoBTCPayApiErrorHTTP 429.retryAfterMs, parsed from the Retry-After header when the API sends it
ValidationErrorGoBTCPayApiErrorAny other 4xx.same as GoBTCPayApiError
ServerErrorGoBTCPayApiErrorHTTP 500 or above.same as GoBTCPayApiError
NetworkErrorGoBTCPayErrorNo response: connection failure, a timeout, or an aborted AbortSignal.isTimeout, cause
WebhookSignatureErrorGoBTCPayErrorA webhook delivery failed verification, or its body was already parsed.none
WebhookSearchLimitErrorGoBTCPayErrorwebhookEndpoints.findByUrl scanned 20 full pages without finding the URL; whether the URL is registered is unknown.pagesScanned, scannedCount, totalCount (as reported by the server; diagnostic only)

requestId is the X-Request-Id response header; quote it in support requests.

One rejection is not a GoBTCPayError: when a poller's timeoutMs runs out, done() rejects with a plain Error (Payment poll timed out after <timeoutMs>ms).

Auto-polling

watchPayment() polls getPayment on an interval and returns a started PaymentPoller with live state, subscriptions, and a done() promise. Polling stops when a poll returns one of the until statuses.

OptionDefaultBehavior
untilFINAL_STATUSESStops at paid, expired or canceled. The exported list also contains cleared and failed. Passing until replaces the list.
timeoutMs0No timeout: the poller runs until an until status, or until you call stop().
intervalMs3000Delay between polls. Values below 3000 are raised to 3000.
immediatetruePoll once right away instead of waiting one interval.
stopOnErrorfalseKeep polling after a failed request. With true, the first error stops polling and rejects done().
TypeScript
const watcher = btcPay.watchPayment({ paymentId, intervalMs: 3000 });

// Live state, readable at any time:
watcher.state; // { status, payment, error, attempts, isPolling, isFinal, ... }

// Subscriptions (each returns an unsubscribe function):
watcher.onChange((state) => render(state.status)); // any state change
watcher.onUpdate((payment) => {});                 // every successful poll
watcher.onPaid((payment) => {});                   // a poll returned `paid`
watcher.onSettled((payment) => {});                // a poll returned an `until` status
watcher.onError((err) => {});                      // a poll failed

// Or just await the outcome:
const finalPayment = await watcher.done();

watcher.stop(); // stop early
Warning

By default the poller stops at expired, so it misses a late payment. The package itself notes that expired is not final on the server: funds that arrive after expiry can still settle the payment. A poller that has stopped at expired will not see it.

The primary channel for payment updates is the webhook. If you poll, pass until without expired and set your own timeoutMs, so polling covers the time you keep the order open and still ends.

TypeScript
import { FINAL_STATUSES } from "@gobtcpay/pos-api-sdk";

const watcher = btcPay.watchPayment({
  paymentId,
  until: FINAL_STATUSES.filter((s) => s !== "expired"),
  timeoutMs: 60 * 60 * 1000, // how long you keep the order open
});

Webhooks

Register a webhook URL in the merchant dashboard, or from code with webhookEndpoints.create on the server client, to receive payment.status.updated events. Prefer webhooks to polling. The handler verifies the X-GoBTCPay-Signature header, enforces a replay window, and de-duplicates on eventId.

TypeScript
import { isTestDelivery } from "@gobtcpay/pos-api-sdk";

const webhooks = btcPay.webhooks({ signingSecret: process.env.POS_WEBHOOK_SECRET });

webhooks.on("payment.status.updated", (event) => {
  // A test delivery has the same type but no payment: event.data is null.
  if (isTestDelivery(event)) return;

  // event.data has the same shape as getPayment()
  console.log(event.eventId, event.data.paymentId, event.data.status);
});

A listener receives a WebhookDelivery: either a payment delivery or a test delivery (test: true, data: null) that the platform sends with the same type, for example after webhookEndpoints.test. A test delivery is dispatched to the listeners of its type like any other. In TypeScript with strictNullChecks (on under strict), reading data as a payment without first narrowing with isTestDelivery(event) does not compile. Test deliveries go through the same eventId de-duplication as payment deliveries.

OptionDefaultBehavior
signingSecretnoneRequired. The secret the signature is computed with.
toleranceSeconds300Rejects a delivery whose signed timestamp differs from the current time by more than this, in either direction. 0 disables the check.
dedupeCacheSize1000How many recent eventIds to remember. Beyond that, the oldest is forgotten. 0 disables de-duplication.

The header has the form t=<unix seconds>,v1=<hex>, where v1 is an HMAC-SHA256 of <t>.<raw body> with the signing secret.

Warning

De-duplication lives in process memory. The seen eventIds are kept inside one WebhookHandler object. A second instance of your service, or the same service after a restart, does not know them. If you run more than one instance, or must not process an event twice across restarts, de-duplicate by eventId yourself in durable storage.

The handler records an eventId before it calls your listeners. If a listener throws, a redelivery of that event to the same handler is treated as a duplicate and your listeners are not called again. Make listeners that must not lose an event persist it first, or set dedupeCacheSize: 0 and de-duplicate yourself.

Framework adapters

Pass the raw request body, not a re-serialized object, or the signature will not match. The adapters read it for you:

TypeScript
declare const SIGNATURE_HEADER = "x-gobtcpay-signature";

declare const expressWebhookHandler: (handler: WebhookHandler) => (req: NodeLikeRequest, res: NodeLikeResponse) => Promise<unknown>;

declare const fastifyWebhookHandler: (handler: WebhookHandler) => (request: NodeLikeRequest, reply: {
    code: (code: number) => {
        send: (body?: unknown) => unknown;
    };
}) => Promise<unknown>;

declare const fetchWebhookHandler: (handler: WebhookHandler) => (request: Request) => Promise<Response>;

declare const verifyRequest: (handler: WebhookHandler, rawBody: string, signatureHeader: string | null | undefined) => Promise<WebhookDelivery | null>;
  • All three handlers reply 200 once a delivery is verified and dispatched, and also for a duplicate. They reply 400 with the error message on a WebhookSignatureError. Any other error, including one thrown by your listener, is rethrown to your framework.
  • expressWebhookHandler and fastifyWebhookHandler read req.rawBody, falling back to req.body. It must be a string or bytes (Buffer/Uint8Array); an already parsed body is rejected with 400.
  • fetchWebhookHandler reads the body with request.text(), so it needs no parser setup. It suits any runtime with Request/Response, such as Next.js route handlers.
  • verifyRequest verifies, de-duplicates and dispatches like handle(), and returns the delivery (which may be a test delivery), or null for a duplicate. Use it for frameworks the adapters do not cover.
TypeScript
import { expressWebhookHandler, fetchWebhookHandler } from "@gobtcpay/pos-api-sdk";

// Express
app.post("/webhooks/gobtcpay", express.raw({ type: "application/json" }), expressWebhookHandler(webhooks));

// Next.js: app/api/gobtcpay/webhook/route.ts
export const POST = fetchWebhookHandler(webhooks);

Without an adapter, call the handler directly:

  • handle(rawBody, signatureHeader) returns the parsed delivery (WebhookDelivery), or null if it was a duplicate.
  • constructEvent(rawBody, signatureHeader) verifies and parses without de-duplicating or dispatching, if you want to handle routing yourself. It also returns a WebhookDelivery; narrow it with isTestDelivery before reading data.

Server client (GoBTCPayServer)

GoBTCPayServer is the client for a shop's backend: create a payment for an order, track it, register webhook endpoints, and verify webhooks. It speaks API v1.2 (exported as SERVER_API_VERSION) and sends the merchant secret key as a Bearer token in the Authorization header.

TypeScript
import { GoBTCPayServer } from "@gobtcpay/pos-api-sdk";

const gobtcpay = new GoBTCPayServer({ apiKey: process.env.GOBTCPAY_SECRET_KEY });

const payment = await gobtcpay.payments.create({
  amount: 49.99,
  currency: "USD",
  externalId: `order-${order.id}`,
});
redirect(payment.checkoutUrl);
Warning

Only a secret key is accepted. The constructor throws a GoBTCPayError for a key that starts with pk_live_ (a publishable key), and for any key that does not start with sk_live_. Keep the secret key server-side.

OptionRequiredDefaultDescription
apiKeyyesnoneMerchant secret key, sk_live_….
baseUrlnohttps://api.gobtcpay.com/public/api/v1.2Override for staging or dev environments.
timeoutMsno30000Per-request timeout.
maxRetriesno2Retries after the first attempt. 0 disables retries.
fetchnoglobal fetchCustom fetch implementation.
onEventnononeCalled once per request attempt, for logging and metrics. An exception it throws is ignored.
MethodSignatureDescription
payments.createcreate({ amount, currency, externalId?, description?, ttlSeconds? }, options?)Create a payment. Retried only when externalId is set (see Retries).
payments.getget({ paymentId }, options?)Fetch the current state of a payment.
payments.cancelcancel({ paymentId }, options?)Cancel a payment.
payments.listlist({ status?, externalId?, dateRange?, limit? }, options?)Async iterator over payments; fetches pages of limit rows (default 50, at most 100) as you iterate.
payments.listPagelistPage({ status?, externalId?, dateRange?, limit?, skip? }, options?)One page: { items, totalCount }, for driving paging yourself.
watchPaymentwatchPayment({ paymentId, ... })The same PaymentPoller as above, polling payments.get. See Auto-polling.
webhookswebhooks({ signingSecret, toleranceSeconds?, dedupeCacheSize? })The same WebhookHandler as above. See Webhooks.
webhookEndpoints.createcreate({ url, scope?, events?, label? }, options?)Register a webhook endpoint. Returns a WebhookEndpointWithSecret. Never retried automatically. See Managing webhook endpoints.
webhookEndpoints.listlist({ status?, limit?, skip? }, options?)One page of endpoints: { items, totalCount }. limit is clamped to 1–100, default 50. Elements carry no signing secret.
webhookEndpoints.testtest({ webhookId }, options?)Ask the platform to send a test delivery to the endpoint. Resolves with no value. Never retried automatically.
webhookEndpoints.findByUrlfindByUrl(url, options?)Find the endpoint registered on exactly this URL. Returns a WebhookEndpoint or null.
TypeScript
for await (const p of gobtcpay.payments.list({ externalId: `order-${order.id}` })) {
  await reconcile(p);
}

Every payments.* and webhookEndpoints.* method takes an optional last argument options: { signal?: AbortSignal; retry?: boolean }. signal aborts the request and any pending retry.

Retries

GoBTCPayServer retries a request after a NetworkError (including a timeout) or an HTTP 429 or 5xx response, up to maxRetries times. Before a retry it waits for the Retry-After time of a 429 when the API sends one, otherwise a random delay between 0 and 250 ms × 2^n (n = 0 before the first retry), capped at 4 seconds. Other errors are thrown at once.

payments.get, payments.cancel, payments.list, payments.listPage, webhookEndpoints.list and the page requests of webhookEndpoints.findByUrl are retried. webhookEndpoints.create and webhookEndpoints.test are not. payments.create is retried only when you pass externalId; without it, a failed create is thrown on the first attempt and is not repeated. According to the package's own documentation, externalId makes create idempotent on the API side, so a retry returns the payment already created for that order instead of a second one. The retry request option overrides the SDK's choice for any payments.* or webhookEndpoints.* call; with maxRetries: 0 nothing is retried.

Managing webhook endpoints

webhookEndpoints registers your webhook endpoints from code. It is separate from webhooks(), which checks the deliveries that reach your server. webhookEndpoints exists only on GoBTCPayServer, not on the POS client, and its requests go to the v1.2 API with the secret key.

TypeScript
import { isTestDelivery } from "@gobtcpay/pos-api-sdk";

const url = "https://shop.example.com/webhooks/gobtcpay";

// Throws WebhookSearchLimitError when the answer is unknown: do not create then.
const existing = await gobtcpay.webhookEndpoints.findByUrl(url);

if (existing === null) {
  const endpoint = await gobtcpay.webhookEndpoints.create({ url });
  // The only time the secret is returned. Store it now.
  await saveSigningSecret(shop.id, endpoint.signingSecret);
}

const webhooks = gobtcpay.webhooks({ signingSecret: await loadSigningSecret(shop.id) });
webhooks.on("payment.status.updated", async (delivery) => {
  if (isTestDelivery(delivery)) return; // acknowledged; there is no payment in it
  await markOrderPaid(delivery.data.paymentId);
});
  • Call findByUrl first and call create only when it returns null. create is never retried and does not check for an existing endpoint on the same URL. If findByUrl throws WebhookSearchLimitError, do not create blindly: the endpoint may already exist.
  • scope is optional and defaults to { type: "merchant" }, all deliveries of the merchant. It decides which deliveries the endpoint receives; it is not a restriction on your API key.
  • signingSecret is returned only by create. list and findByUrl do not return it, and it cannot be read again. A lost secret can only be replaced by rotating it, which invalidates the previous one. The SDK has no method to rotate the secret, or to update or delete an endpoint.
  • A URL the platform refuses throws ValidationError, and err.type is the webhook_url_* code, for example webhook_url_private_host.
  • create is never retried automatically: the platform has no idempotency key for it. After a NetworkError or a timeout the outcome is unknown. If the response does not contain the endpoint, create throws a GoBTCPayError, and the endpoint may still have been created. In both cases look the URL up with findByUrl before creating again.
  • test resolving means only that the platform queued a test delivery. Whether the endpoint works is known only when that delivery reaches your handler with test: true. If the platform does not confirm the queueing, test throws a GoBTCPayError.
  • findByUrl scans the listing itself: 100 endpoints per page, at most 20 pages (2000 endpoints), comparing URLs by exact string equality. It returns the endpoint if found, and null if the whole listing was scanned without a match. It throws WebhookSearchLimitError when the 20th page is still full: the answer is then unknown, and you should not create an endpoint on the strength of it. The scan stops on a page shorter than 100, not on the server's totalCount.
Warning

A store-scoped key is refused. An API key limited to one store gets HTTP 403 (AuthError, err.type === "permission_error") on create, list and test, and therefore on findByUrl, whatever scope you pass. Nothing in the error tells this refusal apart from a revoked or unknown key except the message text, which is not part of the contract. Treat any AuthError here as "this key cannot do this" and use a merchant-level secret key.

The SDK does not prevent duplicate endpoints. "Find, then create" is two requests with nothing atomic between them. Two processes connecting the same shop at the same time can both find nothing and both create, leaving two endpoints on one URL. Serialize connecting yourself, for example with a lock held for the duration of the connect.

Examples

Server (Node.js / Bun / Deno)

TypeScript
import { GoBTCPay, FINAL_STATUSES } from "@gobtcpay/pos-api-sdk";

const btcPay = new GoBTCPay({
  apiKey: process.env.POS_API_KEY,
  posTerminalId: process.env.POS_TERMINAL_ID,
});

// Create a payment and show the QR to the customer.
const payment = await btcPay.createPayment({
  amount: 10,
  currency: "USD",
  description: "Order #1024",
});
console.log(payment.paymentId, payment.qrString);

// Wait for the outcome, without stopping at `expired`.
const final = await btcPay
  .watchPayment({
    paymentId: payment.paymentId,
    until: FINAL_STATUSES.filter((s) => s !== "expired"),
    timeoutMs: 60 * 60 * 1000,
  })
  .done();
console.log(final.status);

A common pattern is to keep the secret on your server and expose thin endpoints (POST /payments, GET /payments/:id) that the browser calls.

Browser (with a bundler: Vite, webpack, Next.js)

TypeScript
import { GoBTCPay } from "@gobtcpay/pos-api-sdk";

const btcPay = new GoBTCPay({ apiKey, posTerminalId });

const payment = await btcPay.createPayment({ amount: 10, currency: "USD" });

// Watch for the result and update the UI live.
const watcher = btcPay.watchPayment({ paymentId: payment.paymentId });
watcher.onChange((s) => (statusEl.textContent = s.status));
watcher.onPaid(() => showPaidScreen());

Plain HTML (no build step)

Load the ESM build from a CDN inside <script type="module">. The @2 in the URL keeps the page on the 2.x line of the package. Use this only on a test or controlled device, because the key is a secret.

html
<!doctype html>
<html>
  <body>
    <pre id="out">creating...</pre>
    <script type="module">
      import { GoBTCPay } from "https://esm.sh/@gobtcpay/pos-api-sdk@2";

      const btcPay = new GoBTCPay({
        apiKey: "YOUR_TERMINAL_KEY", // secret: test or controlled device only
        posTerminalId: "YOUR_TERMINAL_ID",
      });

      const out = document.getElementById("out");
      try {
        const payment = await btcPay.createPayment({ amount: 1, currency: "USD" });
        out.textContent = `paymentId: ${payment.paymentId}\nqr: ${payment.qrString}`;

        const watcher = btcPay.watchPayment({ paymentId: payment.paymentId });
        watcher.onChange((s) => (out.textContent += `\nstatus: ${s.status}`));
      } catch (err) {
        out.textContent = "error: " + err.message;
      }
    </script>
  </body>
</html>
Note

The browser must be allowed to reach the API origin (CORS). If you hit a Failed to fetch or CORS error, proxy the requests through your backend and point baseUrl at that proxy.

Versioning

Each client pins the API version it speaks inside the package:

ClientConstantAPI version
GoBTCPayAPI_VERSIONv1.1
GoBTCPayServerSERVER_API_VERSIONv1.2

The package version tracks the API version via semver:

API versionSDK version
v1.x0.x / 1.x / 2.x
  • Non-breaking API changes (v1.1 → v1.2) ship as a minor SDK release.
  • A breaking API version (v2) ships as a new major SDK release that points at the new base URL internally.
  • While the SDK itself is 0.x, a breaking change to the contract it declares also ships as a minor release, per the semver convention for 0.x — 0.4.0 changed every satoshi field from number to string. Read the changelog before bumping a minor.

2.0.0 is a major release because of a breaking change in the SDK's own types, not because of a new API version: webhook listeners now receive, and handle(), constructEvent() and verifyRequest() now return, a WebhookDelivery (which may be a test delivery) instead of a WebhookEvent. Both clients still speak the API versions in the table above.