Checkout API
The Checkout API lets any website or app take a payment through Paybrain. You create a checkout with the amount and the buyer's email, send the buyer to the checkoutUrl Paybrain gives back, and Paybrain hosts the payment page. When the payment goes through, Paybrain posts a signed webhook to your server and sends the buyer back to your site.
Use it when there's no plugin for your store. For WooCommerce, use the WooCommerce plugin instead.
Before you start
- Your Paybrain account is on Premium.
- Stripe is connected. Without it, creating a checkout fails.
- Your
returnUrl,cancelUrlandwebhookUrluse HTTPS. - Amounts are in cents, and the currency is USD. Other currencies are rejected.
Create an API key
Open API Keys
On the web, go to Settings›API Keys and click Manage Keys.
Create the key
Click Create API Key, give it a Key Name you'll recognize, such as "Production API", and click Create Key.
Copy it now
The full key is shown once. Copy it and store it on your server, for example in an environment variable. Keys start with
pbk_.
The list shows each key's last four characters, when it was created and when it was last used. Use the switch to deactivate a key without deleting it, or delete it to revoke it for good. If a key is lost, create a new one.
Authentication
Send the key in the Authorization header on every request.
Authorization: Bearer pbk_…
Content-Type: application/json
All endpoints are on https://api.paybrain.io. The checkoutUrl you send buyers to is on paybrain.io.
Create a checkout
POST /v1/checkouts
curl -X POST https://api.paybrain.io/v1/checkouts \
-H "Authorization: Bearer $PAYBRAIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"returnUrl": "https://shop.example.com/order/complete",
"customer": { "email": "buyer@example.com", "name": "Jane Buyer" },
"description": "Order #1234",
"webhookUrl": "https://shop.example.com/webhooks/paybrain",
"metadata": { "orderId": "1234" }
}'
Request body
| Field | Type | Required | What it does |
|---|---|---|---|
amount | integer | Yes | The subtotal in cents. Must be more than 0. Tax is added on top. |
currency | string | No | Defaults to USD. Only USD is accepted. |
returnUrl | string | Yes | HTTPS URL the buyer comes back to after paying. |
cancelUrl | string | No | HTTPS URL for a cancelled payment. Defaults to returnUrl. |
customer.email | string | Yes | The buyer's email. The receipt goes here. |
customer.name | string | No | The buyer's name. |
customer.phone | string | No | The buyer's phone number. |
customer.billingAddress | address | No | See Addresses. |
description | string | No | The line shown on the payment page. Defaults to "Order from" and your username. |
subtitle | string | No | A second line under description on the payment page, up to 200 characters. |
shippingAddress | address | No | Where the order ships. Adds a fulfillment panel to the order in Paybrain. |
shippingAmount | integer | No | How much of amount is shipping, in cents. Shown as its own Shipping line. Must be less than amount. The total doesn't change. |
shareShippingWithStripe | boolean | No | Defaults to true. Attaches the shipping address, with customer.name and customer.phone, to the Stripe payment, which helps with disputes and fraud checks. Only happens when you send customer.name. Set false to keep the address in Paybrain only. |
lineItems | array | No | Itemized lines for the receipt. See Receipt lines. |
receiptNote | string | No | A note printed on the receipt and in its email, up to 280 characters. |
webhookUrl | string | No | HTTPS URL that gets a signed event when this checkout is paid or fails. |
metadata | object | No | Anything you want back, such as your order ID. Returned on lookups and webhooks. |
Addresses
{
"line1": "456 Oak Ave",
"line2": "Apt 4B",
"city": "Austin",
"state": "TX",
"postalCode": "78704",
"country": "US"
}
Every field is required except line2. country is a two-letter code.
Receipt lines
Each line has a name (up to 120 characters), a whole-number quantity from 1 to 999, a unitAmount in cents (0 or more) and an optional description. Send 1 to 50 lines.
The lines must add up to amount exactly (quantity × unitAmount, summed), or the request is rejected. Put shipping in as its own line. A line can't be negative, so price items after any discount. Without lineItems, the receipt shows your description as one line.
Response
{
"shortId": "abc123xyz",
"listingId": "65f…",
"checkoutUrl": "https://paybrain.io/pay/redirect?shortId=abc123xyz&…",
"expiresAt": "2026-10-19T12:00:00.000Z",
"amount": 4999,
"currency": "USD",
"taxAmount": 412,
"totalAmount": 5411,
"webhookSecret": "pbwh_…"
}
| Field | What it is |
|---|---|
shortId | The checkout's ID. Use it to look the checkout up. |
listingId | Paybrain's internal ID, useful when you contact support. |
checkoutUrl | Redirect the buyer here. |
amount | The subtotal you sent, in cents. |
taxAmount | What Paybrain added on top of amount, in cents: sales tax and, if you surcharge, the card surcharge. |
totalAmount | What the buyer will be charged, in cents. |
webhookSecret | Only when you sent a webhookUrl. Returned once. Save it to verify webhooks. |
Tax and surcharges
Checkouts follow the same settings as your other sales. If sales tax is on for your account, it's added to amount. If you add a card surcharge, the buyer sees it as its own line on the payment page.
What reaches Stripe
Stripe gets the buyer's email, your description and subtitle, and the shipping address with the buyer's name and phone unless shareShippingWithStripe is false. lineItems, receiptNote and metadata stay in Paybrain. If item names are sensitive, put them in lineItems and keep description generic, such as "Order #1234".
Look up a checkout
GET /v1/checkouts/:shortId
Use this to confirm a payment before you fulfill an order, or if you missed a webhook.
curl https://api.paybrain.io/v1/checkouts/abc123xyz \
-H "Authorization: Bearer $PAYBRAIN_API_KEY"
{
"shortId": "abc123xyz",
"listingId": "65f…",
"status": "paid",
"paymentIntentId": "pi_…",
"amount": 4999,
"currency": "USD",
"taxAmount": 412,
"totalAmount": 5411,
"customer": { "email": "buyer@example.com", "name": "Jane Buyer" },
"shippingAddress": { "line1": "456 Oak Ave", "city": "Austin", "state": "TX", "postalCode": "78704", "country": "US" },
"description": "Order #1234",
"createdAt": "2026-10-18T11:00:00.000Z",
"paidAt": "2026-10-18T11:03:42.000Z",
"metadata": { "orderId": "1234" }
}
status | Meaning |
|---|---|
pending | Not paid yet |
paid | Paid. A partly refunded checkout stays paid. |
failed | The payment failed |
refunded | Fully refunded |
shippingAddress is only there if you sent one. paidAt is only there once the checkout is paid.
Count paid checkouts
GET /v1/checkouts/summary?metadataKey=…&metadataValue=…
Counts your paid checkouts whose metadata[metadataKey] equals metadataValue, and adds up a numeric metadata.units on each. Use it to cap how many of something you sell, such as seats in a class or a limited batch.
curl "https://api.paybrain.io/v1/checkouts/summary?metadataKey=batch&metadataValue=2026-11" \
-H "Authorization: Bearer $PAYBRAIN_API_KEY"
{ "paidCheckouts": 7, "paidUnits": 9 }
metadataKey can use letters, numbers and _, up to 64 characters. metadataValue is matched exactly, as text.
Webhooks
If you send a webhookUrl, Paybrain posts JSON to it when the payment resolves.
| Event | When |
|---|---|
checkout.paid | The buyer's payment succeeded |
checkout.failed | The payment attempt failed |
{
"type": "checkout.paid",
"shortId": "abc123xyz",
"listingId": "65f…",
"paymentIntentId": "pi_…",
"amount": 4999,
"taxAmount": 412,
"totalAmount": 5411,
"currency": "USD",
"occurredAt": "2026-10-18T11:03:42.000Z",
"metadata": { "orderId": "1234" }
}
Paybrain waits up to 8 seconds for your reply. Failed or non-2xx deliveries are not retried, so if you don't receive one, look the checkout up with GET /v1/checkouts/:shortId. Make your handler safe to run twice for the same shortId and type.
Verify the signature
Each delivery has a Paybrain-Signature header:
Paybrain-Signature: t=1760785422000,v1=5f2b…
t is the time it was signed, in Unix milliseconds. v1 is the hex HMAC-SHA256 of `${t}.${rawBody}`, keyed with that checkout's webhookSecret. Compute it over the raw request body. Parsing and re-serializing the JSON changes the bytes and breaks the match.
// Node.js
const crypto = require('crypto');
function verifyPaybrainSignature(rawBody, header, secret, toleranceMs = 5 * 60 * 1000) {
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
const t = parseInt(parts.t, 10);
if (!t || Math.abs(Date.now() - t) > toleranceMs) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 || '', 'hex');
return given.length === 32 && crypto.timingSafeEqual(Buffer.from(expected, 'hex'), given);
}
# Python
import hashlib, hmac, time
def verify_paybrain_signature(raw_body: bytes, header: str, secret: str, tolerance_ms: int = 5 * 60 * 1000) -> bool:
parts = dict(p.strip().split('=', 1) for p in header.split(','))
try:
t = int(parts['t'])
except (KeyError, ValueError):
return False
if abs(int(time.time() * 1000) - t) > tolerance_ms:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get('v1', ''))
The 5-minute tolerance is a suggestion. Pick what suits you.
The buyer's return
After paying, the buyer goes back to your returnUrl through a one-time Paybrain link, so a success URL from browser history can't be replayed. Paybrain adds two query parameters:
| Parameter | Value |
|---|---|
paybrain_return | 1 |
paybrain_status | paid once Paybrain has seen the payment, otherwise unpaid |
Errors
| Status | When |
|---|---|
400 | The request was invalid, for example a missing customer.email, a non-HTTPS URL, a currency other than USD, or lineItems that don't add up to amount. Stripe not connected also returns 400. |
404 | No checkout with that shortId on your account |
410 | The key is missing, malformed, inactive or deleted, or the account is on Basic. The body is a message saying which. |
500 | Something went wrong on our side. Try again. |