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.apiKeyspolicy allows it (off by default); your platform doesn't need them. - A webhook endpoint, before your first seller:
member.joinedtells 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
terminal
# Nothing to install: cURL comes with macOS, Linux and Windows.curl --versionnpm install qbitflowpip install "qbitflow>=3,<4"go get github.com/QBitFlow/qbitflow-go-sdk/v3composer require qbitflow/qbitflow-php:^3.0Create a client
One client per key, shared by your whole program. Its first call asks what the key is: a good start-up check.
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.
// 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:
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`);webhook_endpoints.py
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 wantshttps://(port 443, 8443 or 80); test takes any port. Neverlocalhostor a private network: develop through a tunnel with a publichttps://URL.- The answer carries the signing secret (
whsec_…), only there: store it asQBITFLOW_WEBHOOK_SECRET. Dashboard/Settings/Webhooks can also add an endpoint, and show its secret again. - At most 10 per space and mode (
409 conflictpast 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/
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.
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"])webhook_handler/main.go
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
Do
Give the router the raw bytes
Deduplicate, and answer fast
- At least once. An event can come twice (a lost
2xx, a resend). Deduplicate on itsid(also theQBitFlow-Event-Idheader): 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
2xxcomes 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
2xxto the ones you don't know. - Order holds within a batch (
subscription.created, then its firstsubscription.billed). Across events, rely oncreatedAtand 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:
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 itwebhook_verify/main.go
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 tooIt 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.
| Event | When |
|---|---|
member.joined, member.removed | a seller joined or left (your own events, the seller in data) |
payment.completed | a customer paid one of a seller's checkouts |
checkout.expired | a 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.rejected | a refund of a seller's payment |
heldFunds.released | you 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).