Set up, as the organization

One organization key and one webhook endpoint, in test mode. They invite, act for and hear every seller.

You set up as the organization, in your own space (not a seller's):

  • A wallet on each chain your sellers sell on. Your commission and held sales are paid there, so sellers sell only where you have one: removing your wallet of a chain removes that chain's currencies from every seller. A checkout already open then refuses them (409 currency_not_accepted: the customer picks another); subscriptions already signed keep billing, without your commission. While you hold sellers' live funds on that chain, the wallet can't be removed (409 held_funds_pending).
  • An organization key, created by the owner or an admin. It acts as an admin. Sellers may have keys of their own if your members.apiKeys policy allows it (off by default); your platform doesn't need them.
  • A webhook endpoint, before your first seller: member.joined tells you they accepted.

Create your account

Sign up. Your account comes with an organization: where your products, payments, keys and webhook endpoints live. Nothing to deposit: payments go wallet to wallet (how the money moves).

Switch to test mode

Pick Test in the dashboard's mode switch. Test mode has its own networks, faucet tokens, keys, wallets, products and endpoints, and a test checkout is at most $5, fees included (test mode). Nothing carries over to live: the guide's last step redoes what matters.

Add a wallet

In Dashboard/Settings/Wallets, add a wallet you own and choose the currencies it accepts. Payments land there.

Watch out

Add it before your first checkout. A space (your organization's data in one mode: spaces) whose wallets accept nothing can't open one: the API answers 409 merchant_not_ready.

Create an API key

In Dashboard/Settings/API keys, choose Create an API key. It's shown once: store it as QBITFLOW_API_KEY, which every example reads. A test key (sk_test_…) only ever sees test data.

It acts as an admin of your organization: keep it on your server, in an environment variable, never in a browser, an app or a repository. Leaked? Revoke it on the same page: it stops working at once.

Install the SDK

Install the SDK

terminal

# Nothing to install: cURL comes with macOS, Linux and Windows.curl --version
Every request takes your key from QBITFLOW_API_KEY
npm install qbitflow
qbitflow-js-sdk 3.1.1 · Requires Node.js 20 or later.
pip install "qbitflow>=3,<4"
qbitflow-python-sdk 3.1.1 · Requires Python 3.10 or later.
go get github.com/QBitFlow/qbitflow-go-sdk/v3
qbitflow-go-sdk 3.1.1 · Requires Go 1.27 or later.
composer require qbitflow/qbitflow-php:^3.0
qbitflow-php-sdk 3.1.1 · Requires PHP 8.2.35, 8.3.36, 8.4.27, 8.5.12 or later in its branch.

Create a client

One client per key, shared by your whole program. Its first call asks what the key is: a good start-up check.

Create a client

client-init.sh

# Every request sends your API key. The start-up check: what is this key, and which mode is it in?curl https://api.qbitflow.app/v2/me \  -H "X-API-Key: $QBITFLOW_API_KEY"# 200: { "credential": "apiKey", "role": "admin", "space": { "test": true, … }, … }
// One client per API key, shared by the whole program. A blank key, one not starting with// sk_, or one with a space or a control character, throws a ValidationError (nothing is sent).const client = new QBitFlow(process.env.QBITFLOW_API_KEY ?? ''); // The recommended start-up check: what is this key, and which mode is it in? An// AuthenticationError for an unknown or revoked key.const me = await client.me();console.log(`${me.role} key, test mode: ${me.space?.test}`);
# One client per API key, shared by the whole program. ValidationError: not an sk_… key.client = qbitflow.QBitFlow(os.environ["QBITFLOW_API_KEY"]) # The recommended start-up check: what is this key, and which mode is it in?me = client.me()  # AuthenticationError for an unknown or revoked keyif me.space is not None:    print(f"{me.space.organization_name}, role {me.role}, test mode {me.space.test}")
// One client per API key, shared by the whole program: it is safe for concurrent use.client, err := qbitflow.NewClient(os.Getenv("QBITFLOW_API_KEY"))if err != nil {	log.Fatal(err) // a *qbitflow.ValidationError: no key, or not an sk_… key} // The recommended start-up check: what is this key, and which mode is it in?me, err := client.Me(ctx)if err != nil {	log.Fatal(err) // a *qbitflow.AuthenticationError for an unknown or revoked key}if me.Space != nil {	fmt.Printf("%s, role %s, test mode %v\n", me.Space.OrganizationName, me.Role, me.Space.Test)}
// One client per API key, shared by the whole application.$client = new \QBitFlow\QBitFlow(apiKey: (string) getenv('QBITFLOW_API_KEY')); // The recommended start-up check: which space and mode is this key in?$me = $client->me(); // an AuthenticationException for an unknown or revoked keyprintf("%s, role %s, %s mode\n", $me->space?->organizationName ?? '?', $me->role ?? '?', $me->space?->test ? 'test' : 'live');

Or read everything from the environment

The SDKs can build the client from QBITFLOW_API_KEY alone, plus the optional settings each one names below. Explicit options win.

Configure from the environment
// QBITFLOW_API_KEY (required), and QBITFLOW_BASE_URL and QBITFLOW_ON_BEHALF_OF when set.const client = QBitFlow.fromEnv(); // Explicit options override the environment and add the others.const patient = QBitFlow.fromEnv({ timeout: 60_000, maxRetries: 5 });
# QBITFLOW_API_KEY (required), QBITFLOW_BASE_URL and QBITFLOW_ON_BEHALF_OF when set.client = qbitflow.QBitFlow.from_env(timeout=10)  # explicit options override the environment
// QBITFLOW_API_KEY (required), QBITFLOW_BASE_URL and QBITFLOW_ON_BEHALF_OF (when set);// the options given here override them.client, err := qbitflow.NewClientFromEnv(qbitflow.WithTimeout(10 * time.Second))if err != nil {	log.Fatal(err) // a *qbitflow.ValidationError naming QBITFLOW_API_KEY when it is not set}
// QBITFLOW_API_KEY (required), and QBITFLOW_BASE_URL and QBITFLOW_ON_BEHALF_OF when set, read from// getenv(), $_ENV or $_SERVER (a .env loaded by phpdotenv works).$client = \QBitFlow\QBitFlow::fromEnv(); // Named arguments override the environment and set the other options.$client = \QBitFlow\QBitFlow::fromEnv(timeout: 10.0, maxRetries: 5);

Create an endpoint

The URL QBitFlow posts events to. One per mode:

Create a webhook endpoint

webhook-endpoint-create.sh

# Left out, "events" means every type, including the ones added later. The whsec_… signing secret# is returned only this once: store it now (QBITFLOW_WEBHOOK_SECRET).curl https://api.qbitflow.app/v2/webhooks/endpoints \  -H "X-API-Key: $QBITFLOW_API_KEY" \  -H "Content-Type: application/json" \  -H "Idempotency-Key: $(uuidgen)" \  -d '{    "url": "https://shop.example.com/webhooks/qbitflow",    "events": ["payment.completed", "checkout.expired", "subscription.statusChanged"],    "description": "Order fulfilment"  }'# 201: { "uuid": "…", "url": "https://shop.example.com/webhooks/qbitflow", "secret": "whsec_…", … }
const endpoint = await client.webhooks.endpoints.create({	url: 'https://shop.example.com/webhooks/qbitflow',	// Left out: every type, including the ones added later.	events: [		EventType.PaymentCompleted,		EventType.CheckoutExpired,		EventType.SubscriptionStatusChanged,	],	description: 'Order fulfilment',});// The whsec_… signing secret is returned only this once: store it now.await storeSecret('QBITFLOW_WEBHOOK_SECRET', endpoint.secret);console.log(`endpoint ${endpoint.uuid} created`);
created = client.webhooks.endpoints.create(    url="https://shop.example.com/webhooks/qbitflow",    events=[  # None: every type, including the ones added later        qbitflow.EventType.PAYMENT_COMPLETED,        qbitflow.EventType.CHECKOUT_EXPIRED,        qbitflow.EventType.SUBSCRIPTION_STATUS_CHANGED,    ],    description="Order fulfilment",)# The whsec_… secret is returned only this once: put it in your secret store now# (QBITFLOW_WEBHOOK_SECRET for your webhook handler).print(created.uuid, created.secret)
created, err := client.Webhooks.Endpoints.Create(ctx, &qbitflow.CreateWebhookEndpointParams{	URL: "https://shop.example.com/webhooks/qbitflow",	Events: []qbitflow.EventType{ // nil: every type, including the ones added later		qbitflow.EventTypePaymentCompleted,		qbitflow.EventTypeCheckoutExpired,		qbitflow.EventTypeSubscriptionStatusChanged,	},})if err != nil {	log.Fatal(err)}// The whsec_… secret is returned only this once: put it in your secret store now, and give// it to your webhook handler as QBITFLOW_WEBHOOK_SECRET.fmt.Println("endpoint", created.UUID, "secret", created.Secret)
$endpoint = $client->webhooks->endpoints->create(new \QBitFlow\Params\CreateWebhookEndpointParams(	url: 'https://shop.example.com/webhooks/qbitflow',	events: [ // null: every type, including the ones added later		\QBitFlow\Enums\EventType::PAYMENT_COMPLETED,		\QBitFlow\Enums\EventType::CHECKOUT_EXPIRED,		\QBitFlow\Enums\EventType::SUBSCRIPTION_STATUS_CHANGED,	],));// The whsec_… secret is returned only this once: put it in your secret store now.echo "Endpoint {$endpoint->uuid}: QBITFLOW_WEBHOOK_SECRET={$endpoint->secret}\n";
  • events: this example lists three types, and its endpoint receives only those. Delete the line to receive every type, future ones included: the rest of the guides assume it.
  • url: public. Live wants https:// (port 443, 8443 or 80); test takes any port. Never localhost or a private network: develop through a tunnel with a public https:// URL.
  • The answer carries the signing secret (whsec_…), only there: store it as QBITFLOW_WEBHOOK_SECRET. Dashboard/Settings/Webhooks can also add an endpoint, and show its secret again.
  • At most 10 per space and mode (409 conflict past it). One is enough.

Widen an endpoint to every type

Created one with a short list? Send an empty events list; what you don't send (URL, secret) stays.

Shell

curl -X PATCH "https://api.qbitflow.app/v2/webhooks/endpoints/$ENDPOINT_UUID" \  -H "X-API-Key: $QBITFLOW_API_KEY" \  -H "Content-Type: application/json" \  -d '{    "events": []  }'

$ENDPOINT_UUID comes from its creation or GET /webhooks/endpoints. The dashboard's endpoint page does the same.

Handle the events

The SDK's webhook router reads the raw body, checks the QBitFlow-Signature header with your secret, and runs your handler for the event's type. It answers 200 (also for types you don't handle), 400 for a bad signature, and 500 when your handler throws, so the event comes again.

Handle webhooks
const router = webhooks	.router(process.env.QBITFLOW_WEBHOOK_SECRET!, {		// Every 400 (bad signature or body: event null) and 500 (a handler threw). The router		// answers without the details: log them here.		onError: (event, err) => console.error('webhook rejected:', event?.id, err.message),	})	.on('payment.completed', async (payment, event) => {		await fulfilOrder(payment.reference, event.id); // idempotent: deduplicates on event.id	}); // nodeHandler() reads the raw body itself (at most 1 MiB): mount it before any body parser.const handleWebhook = router.nodeHandler();createServer((req, res) => {	if (req.url === '/webhooks/qbitflow') void handleWebhook(req, res);	else res.writeHead(404).end();}).listen(8080);
router = qbitflow.WebhookRouter(os.environ["QBITFLOW_WEBHOOK_SECRET"])  @router.on("payment.completed")  # the handler gets the typed data, then the eventdef fulfil(data: qbitflow.PaymentCompleted, event: qbitflow.Event) -> None:    # At least once: the same event can arrive twice, deduplicate on event.id.    mark_order_paid(data.reference or data.uuid, event_id=event.id)  app = FastAPI()# Verifies the QBitFlow-Signature header on the raw body, parses the event, runs the handler# for its type, and answers the status QBitFlow expects (200, 400 or 500).app.add_api_route("/webhooks/qbitflow", router.fastapi_endpoint(), methods=["POST"])
var fulfilled sync.Map // stands for your database: deliveries are at least oncerouter := qbitflow.NewWebhookRouter(os.Getenv("QBITFLOW_WEBHOOK_SECRET"),	qbitflow.WithWebhookErrorHandler(func(event *qbitflow.Event, err error) {		log.Printf("webhook refused or failed: %v", err) // event is nil when the body could not be parsed	}),).	OnPaymentCompleted(func(ctx context.Context, payment *qbitflow.PaymentCompleted, event *qbitflow.Event) error {		if _, done := fulfilled.LoadOrStore(event.ID, true); done {			return nil // a retry of an event already handled		}		log.Printf("fulfil order %q: %s USD (%s, %v)", payment.Reference, payment.AmountUSD, payment.UUID, payment.Metadata)		return nil // an error answers 500, and QBitFlow delivers the event again	}).	OnCheckoutExpired(func(ctx context.Context, expired *qbitflow.CheckoutExpired, event *qbitflow.Event) error {		if expired.Payment != nil {			log.Printf("release order %q", expired.Payment.Reference)		}		return nil	}) // The router is an http.Handler: it verifies the QBitFlow-Signature header on the raw body,// parses the event, runs the handlers of its type and answers 200, 400 or 500.http.Handle("POST /webhooks/qbitflow", router)
$router = (new \QBitFlow\Webhooks\WebhookRouter((string) getenv('QBITFLOW_WEBHOOK_SECRET')))	->on(\QBitFlow\Enums\EventType::PAYMENT_COMPLETED, function (		\QBitFlow\Models\PaymentCompleted $payment,		\QBitFlow\Events\Event $event,	): void {		// Deliveries are at least once: skip an $event->id you already processed.		error_log("event {$event->id}: fulfil order {$payment->reference} ({$payment->uuid}), {$payment->amountUsd} USD");	})	->on(\QBitFlow\Enums\EventType::SUBSCRIPTION_STATUS_CHANGED, function (		\QBitFlow\Models\SubscriptionStatusChanged $subscription,	): void {		error_log("{$subscription->uuid} is {$subscription->status}, access: " . ($subscription->hasAccess() ? 'yes' : 'no'));	})	->onError(fn (?\QBitFlow\Events\Event $event, \Throwable $error) => error_log("webhook refused or failed: {$error->getMessage()}")); // Reads php://input and the QBitFlow-Signature header, runs the handler of the event's type, and// answers 200 (ignored types too), 400 (bad signature) or 500 (a handler threw: QBitFlow retries).$router->handleGlobals();

Avoid

Parse the JSON first

A body parser hands on a re-serialized copy, and every signature fails.

Do

Give the router the raw bytes

Mount it before any body parser: the signature covers the body exactly as sent.

Deduplicate, and answer fast

  • At least once. An event can come twice (a lost 2xx, a resend). Deduplicate on its id (also the QBitFlow-Event-Id header): it never changes across retries.
  • Within 30 seconds. Later counts as a failure. Store the event, answer 2xx, then do the slow work in the background.
  • Retries. Anything but a 2xx comes again. Live: 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours after the failed attempt, then every 12 hours, for 3 days. Test: 3 retries, 5 minutes, 30 minutes and 2 hours after the failed attempt. A live endpoint that keeps failing is turned off after 3 days (delivery): turn it back on in Dashboard/Settings/Webhooks.
  • New types come over time: answer 2xx to the ones you don't know.
  • Order holds within a batch (subscription.created, then its first subscription.billed). Across events, rely on createdAt and the resources' own dates.

Verify by hand, when you need to

Only when your framework isn't one the router wraps, or a queue hands you the raw body later:

Verify and parse a webhook (lower level)
const secret = process.env.QBITFLOW_WEBHOOK_SECRET!;try {	// rawBody: the body exactly as received; signatureHeader: its QBitFlow-Signature header.	const event = webhooks.constructEvent(rawBody, signatureHeader, secret);	if (event.type === 'payment.completed') {		console.log(`fulfil order ${event.data.reference}`); // event.data is a PaymentCompleted	}	return 200; // acknowledge every event, the types you ignore included} catch (err) {	if (err instanceof WebhookSignatureError) {		console.log(`rejected: ${err.reason}`); // e.g. noMatchingSignature		return 400;	}	if (err instanceof ValidationError) {		console.log(`not a v2 event: ${err.message}`);		return 400;	}	throw err;}
try:    event = qbitflow.webhooks.construct_event(        raw_body,  # the raw bytes as received, never re-serialized        signature_header,  # the QBitFlow-Signature header        os.environ["QBITFLOW_WEBHOOK_SECRET"],    )except qbitflow.WebhookSignatureError as exc:    print("rejected:", exc.reason)  # e.g. noMatchingSignature, timestampOutsideTolerance    return 400except qbitflow.ValidationError:    return 400  # not a v2 eventif isinstance(event, qbitflow.PaymentCompletedEvent):  # narrow the union by class    print("fulfil order", event.data.reference)return 200  # acknowledge every other type too, or QBitFlow retries it
func handleWebhook(w http.ResponseWriter, r *http.Request) {	// The raw body, exactly as received: the signature covers its bytes.	rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))	if err != nil {		http.Error(w, "cannot read the body", http.StatusBadRequest)		return	}	event, err := qbitflow.ConstructEvent(rawBody, r.Header.Get(qbitflow.SignatureHeader), os.Getenv("QBITFLOW_WEBHOOK_SECRET"))	var sigErr *qbitflow.WebhookSignatureError	if errors.As(err, &sigErr) {		log.Printf("rejected: %s", sigErr.Reason) // e.g. noMatchingSignature, timestampOutsideTolerance		http.Error(w, "invalid signature", http.StatusBadRequest)		return	}	if err != nil {		http.Error(w, "invalid event", http.StatusBadRequest) // not a v2 event		return	} 	if event.Type == qbitflow.EventTypePaymentCompleted {		payment, err := event.PaymentCompleted()		if err != nil {			http.Error(w, "invalid event", http.StatusBadRequest)			return		}		// Deliveries are at least once: fulfil the order once per event.ID.		log.Printf("event %s: order %q paid", event.ID, payment.Reference)	}	w.WriteHeader(http.StatusOK) // to the types you ignore too}
$rawBody = (string) file_get_contents('php://input'); // the bytes as received: never re-serializetry {	$event = \QBitFlow\Webhooks\Webhook::constructEvent(		$rawBody,		$_SERVER['HTTP_QBITFLOW_SIGNATURE'] ?? '',		(string) getenv('QBITFLOW_WEBHOOK_SECRET'),	);} catch (\QBitFlow\Exceptions\WebhookSignatureException $e) {	http_response_code(400); // $e->reason: noMatchingSignature, timestampOutsideTolerance, …	exit;} catch (\QBitFlow\Exceptions\ValidationException) {	http_response_code(400); // not a v2 event (unsupported payload version), or not JSON	exit;} if ($event instanceof \QBitFlow\Events\PaymentCompletedEvent) {	// Deliveries are at least once: skip an $event->id you already processed.	error_log("fulfil order {$event->data->reference} ({$event->data->uuid})");}http_response_code(200); // to every type, the ignored ones too

It checks that the header's t is within 5 minutes of your clock, and that the HMAC-SHA256 of t + "." + rawBody with your secret matches one of its v1 values (the rule, step by step).

One endpoint hears every seller

An endpoint created with your organization key also receives every seller's events (includeMembers is true unless you send false). The envelope's userUuid names the seller; your own events have none.

EventWhen
member.joined, member.removeda seller joined or left (your own events, the seller in data)
payment.completeda customer paid one of a seller's checkouts
checkout.expireda seller's checkout expired unpaid
subscription.*a seller's subscription started, was billed, failed a bill, changed status or asks its customer to act
refund.requested, refund.approved, refund.rejecteda refund of a seller's payment
heldFunds.releasedyou paid out what you held for a seller

To read a seller's payment, send the envelope's userUuid as On-Behalf-Of (step 4): your plain key reads your own space only, where the seller's payment answers 404. Or, without the header, add includeMembers=true to GET /payments/:uuid, /subscriptions/:uuid or /bills/:uuid (read only); GET /refunds/:uuid reads a seller's refund without it. A seller's own endpoints are separate, and exist only if your members.webhooks policy allows them (off by default).

Check it

The client's first call answers the role admin, no onBehalfOfUserUuid, and test mode (space.test is true); made for a seller (step 4), it answers user with the seller in onBehalfOfUserUuid. A 401 means a mistyped, revoked or expired key. Then, in Dashboard/Settings/Webhooks, Send a test event: webhook.test gets 200 (a 400: check the secret and the raw body).