Pay2Bridge API
One REST API for cards, USDC and Interac e-Transfer. Every merchant processes under its own corporation — its own MID, its own wallet, its own payee address. Pay2Bridge never pools funds.
Introduction
The API is HTTPS-only and speaks JSON in both directions. The base URL is:
https://pay2bridge.com/v1
Amounts are always integers in the smallest currency unit — 1000 is $10.00. Supported currencies are USD and CAD. Timestamps are ISO 8601 in UTC.
Check you can reach us, no key needed:
curl https://pay2bridge.com/v1/ping
Authentication
Every request outside /v1/ping needs your API key. Send it as a bearer token:
curl https://pay2bridge.com/v1/account \
-H "Authorization: Bearer p2b_live_..."
The header x-p2b-key is accepted as an alternative if a bearer token is awkward in your stack.
Keys come in two modes. A p2b_live_ key moves real money. A p2b_test_ key exercises the same API surface without touching a rail. Create, label and revoke keys from the dashboard.
Errors
We use conventional HTTP status codes. Any 4xx or 5xx response carries an error object.
{
"error": {
"type": "invalid_request",
"message": "amount must be an integer number of cents, at least 50."
}
}
| Status | Type | What it means |
|---|---|---|
| 400 | invalid_request | A parameter is missing or malformed. |
| 401 | authentication_error | The key is missing, wrong or revoked. |
| 403 | permission_error | The key is valid but not for this resource. |
| 404 | not_found | No such object on your account. |
| 409 | invalid_state | The object cannot move to that state — refunding an unpaid payment, for instance. |
| 500 | api_error | Something broke on our side. Retry is safe if you sent an idempotency key. |
Idempotency
Send an Idempotency-Key header on any POST /v1/payments. If the same key arrives twice on the same account, we return the original payment instead of creating a second one, with Idempotent-Replay: true on the response. Use a UUID per logical operation — your own order ID works well.
curl https://pay2bridge.com/v1/payments \
-H "Authorization: Bearer p2b_live_..." \
-H "Idempotency-Key: order_88213" \
-H "Content-Type: application/json" \
-d '{"amount": 12900, "currency": "USD", "method": "card"}'
Payment links
A payment link is a hosted checkout page we host for you. Create one, send the URL, get paid — there is nothing to build and no page to write. The customer picks from the methods you enable, and everything lands in your dashboard like any other payment.
/v1/payment_links| Parameter | Type | |
|---|---|---|
title | string | Shown to the customer at the top of the page. |
amount | integer | Smallest currency unit. Omit it and the customer types their own amount. |
currency | string | USD or CAD. |
methods | array | Any of card, crypto, etransfer. Defaults to ["card"]. |
description | string | Optional line under the title. |
curl https://pay2bridge.com/v1/payment_links \
-H "Authorization: Bearer p2b_live_..." \
-H "Content-Type: application/json" \
-d '{"title":"Order 88213","amount":12900,"currency":"USD","methods":["card","crypto"]}'
{
"id": "lnk_c950a9d381f0",
"object": "payment_link",
"title": "Order 88213",
"amount": 12900,
"currency": "USD",
"methods": ["card", "crypto"],
"active": true,
"uses": 0,
"url": "https://pay2bridge.com/c/lnk_c950a9d381f0"
}
The url is live immediately. A link only offers a method that is actually connected on your account, so a rail you have not switched on never appears to a customer.
/v1/payment_links/v1/payment_links/:idPause or resume a link with {"active": false}, or change its title, amount or methods.
/v1/payment_links/:idPayments
A payment is a single amount you want to collect. Create one, then send the customer to next_action — a hosted checkout for cards, a deposit address for USDC, a payee address for e-Transfer.
/v1/payments| Parameter | Type | |
|---|---|---|
amount | integer | Required. Smallest currency unit. Minimum 50. |
currency | string | USD or CAD. Defaults to USD. |
method | string | card, crypto or etransfer. Defaults to card. |
description | string | Shown to you, not the customer. |
reference | string | Your own order identifier. Echoed back on every event. |
customer_name | string | Optional. Required for crypto. |
customer_email | string | Optional, but recommended — used for receipts. Required for crypto. |
customer_dob | string | YYYY-MM-DD. Required for crypto. The conversion venue will not open a deposit address without a date of birth. |
return_url | string | Where to send the customer after a card payment. |
metadata | object | Any keys you like. Returned unchanged. |
curl https://pay2bridge.com/v1/payments \
-H "Authorization: Bearer p2b_live_..." \
-H "Content-Type: application/json" \
-d '{
"amount": 12900,
"currency": "USD",
"method": "crypto",
"reference": "order_88213",
"customer_name": "Alex Buyer",
"customer_email": "buyer@example.com",
"customer_dob": "1990-01-15"
}'
{
"id": "pay_9f2c41ab77e3",
"object": "payment",
"created": "2026-09-09T14:02:11.004Z",
"status": "awaiting_payment",
"amount": 12900,
"currency": "USD",
"method": "crypto",
"feePercent": 4.9,
"feeAmount": 632.1,
"reference": "order_88213",
"processor": "crypto",
"next_action": {
"type": "crypto_transfer",
"asset": "USDC",
"network": "polygon",
"address": "0x…",
"amount": "129.00"
}
}
400 invalid_request.For method: "card" the response instead carries a checkout_url and a next_action of type redirect_to_checkout. For method: "etransfer" it carries the payee address registered to your business and the reference to put in the message field.
/v1/payments/:idRetrieve a single payment. Poll this, or subscribe to webhooks and stop polling.
/v1/paymentsList payments, newest first. Query parameters: limit (max 100, default 25) and status.
/v1/payments/:id/cancelCancel a payment that has not been paid. A paid payment must be refunded instead.
Refunds
/v1/payments/:id/refundRequest a full or partial refund. Send amount to refund part of it, and an optional reason.
curl https://pay2bridge.com/v1/payments/pay_9f2c41ab77e3/refund \
-H "Authorization: Bearer p2b_live_..." \
-H "Content-Type: application/json" \
-d '{"amount": 5000, "reason": "partial return"}'
pending and confirmed once the money has actually moved on the underlying rail. You get a refund.updated event when that happens. Crypto refunds settle back to the address the customer paid from only where the rail supports it — otherwise we contact you for a destination./v1/refundsList refunds on your account.
Balance
/v1/balance{
"object": "balance",
"currency": "USD",
"lifetime_volume": 4820000,
"month_volume": 611500,
"fees_accrued": 236180,
"net": 4583820,
"pending_payments": 3
}
Account
/v1/accountYour business record — legal name, status, platform fee and the payout destinations on file.
Payment methods
/v1/processorsLive status of every rail on your account. Each entry reports whether it is connected right now and why not, if not. This is a live check, not a cached value — use it to decide which methods to show at checkout.
Apple Pay, Google Pay and BNPL
Apple Pay and Google Pay are not separate payment methods to integrate — they are card wallets, and the transaction settles on the same merchant account as any other card. You do not need an Apple Merchant ID, a payment processing certificate, or a domain-association file.
Create a payment with method: "card" as normal. When the account behind it is a Stripe account, the hosted checkout shows Apple Pay on Safari and iOS, and Google Pay on Chrome and Android, automatically, to any shopper whose device has a card in the wallet. Nothing changes in your request and nothing changes in the response — the payment comes back as a card payment.
Buy now, pay later — Afterpay, Klarna and Affirm — also appears on the hosted checkout, but only for providers enabled on the underlying account, and each one underwrites the merchant separately. Most of them restrict supplements, nutraceuticals and anything presented as a medical or pharmaceutical product, so approval is not a given. Treat BNPL as a separate application, not a switch.
Webhook endpoints
Register an HTTPS URL and we POST a signed JSON event to it whenever something changes. Respond 2xx within 20 seconds.
/v1/webhook_endpointscurl https://pay2bridge.com/v1/webhook_endpoints \
-H "Authorization: Bearer p2b_live_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://yoursite.com/hooks/pay2bridge"}'
The response contains a secret beginning whsec_. Store it — it is what you verify signatures with.
/v1/webhook_endpoints/v1/webhook_endpoints/:idVerifying signatures
Every delivery carries a P2B-Signature header:
P2B-Signature: t=1789123456,v1=5257a869e7ecebeda32affa62cdca3fa…
The signature is HMAC-SHA256 of "{timestamp}.{raw request body}", keyed with your endpoint secret. Verify against the raw body, before any JSON parsing.
// Node.js — Express
import crypto from "node:crypto";
app.post("/hooks/pay2bridge",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("P2B-Signature") || "";
const parts = Object.fromEntries(
header.split(",").map(p => p.split("="))
);
const expected = crypto
.createHmac("sha256", process.env.P2B_WEBHOOK_SECRET)
.update(`${parts.t}.${req.body}`)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1 || "")
);
if (!ok) return res.status(400).send("bad signature");
// Reject anything older than five minutes.
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300)
return res.status(400).send("stale");
const event = JSON.parse(req.body);
// handle event.type …
res.sendStatus(200);
}
);
# Python — Flask
import hmac, hashlib, time, json
from flask import request, abort
@app.post("/hooks/pay2bridge")
def hook():
header = request.headers.get("P2B-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(","))
raw = request.get_data()
expected = hmac.new(
SECRET.encode(),
f"{parts['t']}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
if abs(time.time() - int(parts["t"])) > 300:
abort(400)
event = json.loads(raw)
return "", 200
Event types
| Type | Sent when |
|---|---|
payment.created | A payment is created through the API or your checkout. |
payment.succeeded | Funds are confirmed on the rail. This is the one to fulfil on. |
payment.failed | The payment cannot complete. |
payment.cancelled | Cancelled before payment. |
payment.refunded | The full amount has gone back. |
refund.created | A refund has been requested. |
refund.updated | A refund succeeded or failed. |
The event body is the object at the time of the change:
{
"id": "evt_3b81de55c204",
"type": "payment.succeeded",
"created": "2026-09-09T14:09:47.612Z",
"data": { "id": "pay_9f2c41ab77e3", "status": "paid", "amount": 12900, … }
}
Payment statuses
| Status | |
|---|---|
requires_payment | Created, not yet routed to a rail. |
awaiting_payment | Waiting on the customer. next_action tells you what they need to do. |
paid | Funds confirmed. Fulfil here, not before. |
failed | Declined, expired or the rail rejected it. |
cancelled | Cancelled before payment. |
refunded | Fully refunded. |
disputed | A chargeback has been raised. |
awaiting_payment. For crypto and e-Transfer that status only means we have handed the customer instructions — the money has not arrived. Wait for payment.succeeded, or for status: "paid" on a retrieve.Testing
Create a key with mode test from the dashboard. Test keys exercise the full API surface — objects, events, webhook signatures — without moving money. Point your integration at test first, confirm you handle payment.succeeded and signature verification correctly, then swap the key. Nothing else in your code changes.