Skip to content

Client libraries

Two clients, one server, and one file that stops the three of them disagreeing. The request layer of each is generated from the committed OpenAPI document and regenerated by CI, which fails on any difference — so a route change forces both clients to change in the same pull request. Around it, about 300 lines per language do the two things code generation cannot: an Idempotency-Key minted per logical call and reused on every retry of it, and webhook verification over the raw body bytes in constant time.

Neither package is published yet. Until they are, the SDK README lists the two registry accounts that have to exist first, and either client can be installed from a checkout.

The two sections below are the packages' own READMEs — the text a user reads on PyPI and npm — included here rather than rewritten, so there is only ever one copy to correct. Edit them at sdks/python/README.md and sdks/typescript/README.md.


Python — crypto-processing-client

Python client for crypto-processing-api — per-user custodial BTC and USDT balances on top of your own BTCPay Server.

This is the client. The service it talks to is self-hosted; there is no hosted API to sign up for.

pip install crypto-processing-client

Five minutes

from crypto_processing_client import CryptoProcessingClient

client = CryptoProcessingClient("https://pay.example.com", api_key="cpk_live_...")

deposit = client.create_deposit(external_user_id="user-42", asset="BTC")
print(deposit.address, deposit.checkout_link)

# ... the user pays, and you poll or wait for a webhook ...

balances = client.get_user_balances("user-42")
for balance in balances.balances:
    print(balance.asset, balance.available)

withdrawal = client.create_withdrawal(
    external_user_id="user-42",
    asset="BTC",
    amount="25000000",  # gross, integer smallest units, a string
    destination_address="bc1q...",
)
print(withdrawal.status)  # pending_approval, or already moving

Every amount is a string, in both directions. amount and expected_amount are integer numbers of the asset's smallest unit ("25000000" is 0.25 BTC). Everything the server returns is a decimal string ("0.25000000"). Do not put either through float.

Every timestamp is an ISO 8601 string with a literal +00:00. They are strings here too, not datetimes, because the client must not re-render bytes an integrator may be comparing.

Idempotency, which you get for free

Every mutating call carries an Idempotency-Key. One is minted per call, and the same one is reused on every retry of that call — a retry with a new key is a second deposit, not a retry. Pass your own when your system already has an id for the operation:

client.create_deposit(external_user_id="user-42", asset="BTC", idempotency_key=f"order-{order.id}")

The client retries a 503 always, a 409 when the server sent a Retry-After (which is how "your earlier attempt is still running" is told apart from "this transition is illegal"), and a dropped connection — which is safe precisely because the key is pinned. Tune or switch it off:

from crypto_processing_client import RetryPolicy

client = CryptoProcessingClient(url, key, retry=RetryPolicy(attempts=1))

Webhooks

from crypto_processing_client import parse_event, UnknownEventTypeError, WebhookVerificationError


@app.post("/platform-webhook")
async def platform_webhook(request: Request):
    body = await request.body()  # the raw bytes, never the parsed JSON
    try:
        event = parse_event(body, request.headers, secret=WEBHOOK_SECRET)
    except WebhookVerificationError:
        return Response(status_code=401)
    except UnknownEventTypeError:
        return Response(status_code=200)  # a newer server sent a type you do not know

    if already_handled(event["id"]):  # the same evt_ id may arrive twice
        return Response(status_code=200)

    if event["type"] == "deposit.settled":
        deposit = client.get_deposit(event["data"]["deposit_id"])
        credit(deposit)  # act on the GET, not on the webhook
    return Response(status_code=200)

parse_event checks the signature over the raw body bytes, in constant time, inside a 300-second window — and refuses to give you an event if any of that fails. The verifier is checked against sdks/signature-vectors.json, the same vectors the server and the TypeScript client assert against, so the three cannot quietly disagree.

Step 5 is the whole contract: a webhook tells you something changed; the GET tells you what is true.

Errors

Every refusal raises. The split is by what you can do next:

Raised Status What to do
BadRequestError 400 fix the request
AuthenticationError 401 fix the key
PermissionDeniedError 403 the key lacks the scope
NotFoundError 404 no such deposit, withdrawal, user or asset
ConflictError 409 an illegal transition, if it reached you
ValidationError 422 a rule refused it; see .field_errors
UpstreamRefusedError 502 BTCPay said no; the intent is dead
ServiceUnavailableError 503 temporary; already retried, see .retry_after
ServerError other 5xx temporary
TransportError never got an answer

All of them derive from CryptoProcessingError.

Versions

client 0.N.x supports server 0.N.y. See docs/reference/versioning.md.

What is generated and what is not

crypto_processing_client._generated comes from the server's committed OpenAPI document and is regenerated in CI, which fails on any difference — so it cannot describe a server that does not exist. Everything else in this package is handwritten: the idempotency and retry behaviour, the error classes, and webhook verification. Those are the things codegen cannot produce.

MIT licensed, like the service.


TypeScript — @oliverd25/crypto-processing-client

TypeScript client for crypto-processing-api — per-user custodial BTC and USDT balances on top of your own BTCPay Server.

This is the client. The service it talks to is self-hosted; there is no hosted API to sign up for.

npm install @oliverd25/crypto-processing-client

ESM only, Node 20 or newer. The webhook helpers are built on WebCrypto, so they also run on Deno, Bun, Cloudflare Workers and in a browser.

Five minutes

import { CryptoProcessingClient } from '@oliverd25/crypto-processing-client';

const client = new CryptoProcessingClient({
  baseUrl: 'https://pay.example.com',
  apiKey: 'cpk_live_...',
});

const deposit = await client.createDeposit({ external_user_id: 'user-42', asset: 'BTC' });
console.log(deposit.address, deposit.checkout_link);

// ... the user pays, and you poll or wait for a webhook ...

const balances = await client.getUserBalances('user-42');
for (const balance of balances.balances) {
  console.log(balance.asset, balance.available);
}

const withdrawal = await client.createWithdrawal({
  external_user_id: 'user-42',
  asset: 'BTC',
  amount: '25000000', // gross, integer smallest units, a string
  destination_address: 'bc1q...',
});
console.log(withdrawal.status); // pending_approval, or already moving

Every amount is a string, in both directions. amount and expected_amount are integer numbers of the asset's smallest unit ('25000000' is 0.25 BTC). Everything the server returns is a decimal string ('0.25000000'). Number() on either loses money: 21 million BTC in satoshis is past JavaScript's safe integer range.

Every timestamp is an ISO 8601 string with a literal +00:00. They stay strings here, not Dates, because the client must not re-render bytes an integrator may be comparing.

Request bodies use the API's own field names (external_user_id, not externalUserId) so there is no mapping layer between this package and the generated types that could drift.

Idempotency, which you get for free

Every mutating call carries an Idempotency-Key. One is minted per call, and the same one is reused on every retry of that call — a retry with a new key is a second deposit, not a retry. Pass your own when your system already has an id for the operation:

await client.createDeposit(
  { external_user_id: 'user-42', asset: 'BTC' },
  { idempotencyKey: `order-${order.id}` },
);

The client retries a 503 always, a 409 when the server sent a Retry-After (which is how "your earlier attempt is still running" is told apart from "this transition is illegal"), and a dropped connection — which is safe precisely because the key is pinned. Tune or switch it off with retry: { attempts: 1 }.

Webhooks

import express from 'express';
import { parseEvent, UnknownEventTypeError, WebhookVerificationError }
  from '@oliverd25/crypto-processing-client';

// The raw bytes, not express.json(). Re-serializing the parsed object changes
// the whitespace and the signature can never match.
app.post('/platform-webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = await parseEvent(req.body, req.headers, process.env.WEBHOOK_SECRET!);
  } catch (error) {
    if (error instanceof UnknownEventTypeError) return res.sendStatus(200);
    if (error instanceof WebhookVerificationError) return res.sendStatus(401);
    throw error;
  }

  if (await alreadyHandled(event.id)) return res.sendStatus(200); // evt_ ids repeat
  res.sendStatus(200);                                            // acknowledge first

  if (event.type === 'deposit.settled') {
    const deposit = await client.getDeposit(event.data.deposit_id);
    await credit(deposit); // act on the GET, not on the webhook
  }
});

parseEvent checks the signature over the raw body bytes, in constant time, inside a 300-second window — and refuses to give you an event if any of that fails. The verifier is checked against sdks/signature-vectors.json, the same vectors the server and the Python client assert against, so the three cannot quietly disagree.

event.type is a literal, so TypeScript narrows event.data for you inside each branch.

Two Node-specific traps this helper removes: express.json() gives you an object rather than bytes, and Node's timingSafeEqual throws when the two buffers differ in length — the comparison here checks length first and returns false instead of raising into a 500 that looks like an outage.

Errors

Every refusal throws. The split is by what you can do next:

Thrown Status What to do
BadRequestError 400 fix the request
AuthenticationError 401 fix the key
PermissionDeniedError 403 the key lacks the scope
NotFoundError 404 no such deposit, withdrawal, user or asset
ConflictError 409 an illegal transition, if it reached you
ValidationError 422 a rule refused it; see .fieldErrors
UpstreamRefusedError 502 BTCPay said no; the intent is dead
ServiceUnavailableError 503 temporary; already retried, see .retryAfter
ServerError other 5xx temporary
TransportError never got an answer

All of them extend CryptoProcessingError.

Versions

client 0.N.x supports server 0.N.y. See docs/reference/versioning.md.

What is generated and what is not

src/generated comes from the server's committed OpenAPI document and is regenerated in CI, which fails on any difference — so it cannot describe a server that does not exist. Everything else is handwritten: the idempotency and retry behaviour, the error classes, and webhook verification. Those are the things codegen cannot produce.

The package has no runtime dependencies.

MIT licensed, like the service.


If you are on another stack

There is nothing in either client you cannot do with an HTTP library. Integrating is the same contract written out by hand: the lifecycles, the idempotency rule, the five-step webhook handler, and a signature verifier in Python and Node with no dependencies.

The cases both clients and the server's own verifier are checked against are published as sdks/signature-vectors.json. Assert your verifier against them too. An HMAC scheme with several implementations drifts in silence, because each one passes its own tests and the disagreement only shows up as your endpoint rejecting real traffic.