P2BPay2Bridge

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.

Keys are stored hashed. We show a key once, at the moment it is created, and never again. If a key is lost, revoke it and issue a new one — revocation takes effect immediately. Never put a live key in browser or mobile code.

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."
  }
}
StatusTypeWhat it means
400invalid_requestA parameter is missing or malformed.
401authentication_errorThe key is missing, wrong or revoked.
403permission_errorThe key is valid but not for this resource.
404not_foundNo such object on your account.
409invalid_stateThe object cannot move to that state — refunding an unpaid payment, for instance.
500api_errorSomething 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"}'

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.

POST/v1/payment_links
ParameterType
titlestringShown to the customer at the top of the page.
amountintegerSmallest currency unit. Omit it and the customer types their own amount.
currencystringUSD or CAD.
methodsarrayAny of card, crypto, etransfer. Defaults to ["card"].
descriptionstringOptional 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.

GET/v1/payment_links
PATCH/v1/payment_links/:id

Pause or resume a link with {"active": false}, or change its title, amount or methods.

DELETE/v1/payment_links/:id

Payments

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.

POST/v1/payments
ParameterType
amountintegerRequired. Smallest currency unit. Minimum 50.
currencystringUSD or CAD. Defaults to USD.
methodstringcard, crypto or etransfer. Defaults to card.
descriptionstringShown to you, not the customer.
referencestringYour own order identifier. Echoed back on every event.
customer_namestringOptional. Required for crypto.
customer_emailstringOptional, but recommended — used for receipts. Required for crypto.
customer_dobstringYYYY-MM-DD. Required for crypto. The conversion venue will not open a deposit address without a date of birth.
return_urlstringWhere to send the customer after a card payment.
metadataobjectAny 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"
  }
}
Crypto orders carry an age check. The conversion venue requires the customer's name, email and date of birth before it will open a deposit address, and rejects anyone under 18. Collect those three fields on your side and pass them through — a crypto payment without them returns 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.

GET/v1/payments/:id

Retrieve a single payment. Poll this, or subscribe to webhooks and stop polling.

GET/v1/payments

List payments, newest first. Query parameters: limit (max 100, default 25) and status.

POST/v1/payments/:id/cancel

Cancel a payment that has not been paid. A paid payment must be refunded instead.

Refunds

POST/v1/payments/:id/refund

Request 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"}'
A refund is created as 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.
GET/v1/refunds

List refunds on your account.

Balance

GET/v1/balance
{
  "object": "balance",
  "currency": "USD",
  "lifetime_volume": 4820000,
  "month_volume": 611500,
  "fees_accrued": 236180,
  "net": 4583820,
  "pending_payments": 3
}

Account

GET/v1/account

Your business record — legal name, status, platform fee and the payout destinations on file.

Payment methods

GET/v1/processors

Live 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.

Why there is no setup step. Stripe's hosted checkout performs Apple's merchant validation on your behalf, and domain registration is only required for Elements or the embedded form — not for the redirect flow that Pay2Bridge uses.

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.

POST/v1/webhook_endpoints
curl 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.

GET/v1/webhook_endpoints
DELETE/v1/webhook_endpoints/:id

Verifying 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

TypeSent when
payment.createdA payment is created through the API or your checkout.
payment.succeededFunds are confirmed on the rail. This is the one to fulfil on.
payment.failedThe payment cannot complete.
payment.cancelledCancelled before payment.
payment.refundedThe full amount has gone back.
refund.createdA refund has been requested.
refund.updatedA 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, … }
}
Deliveries are logged. You can see every attempt, the HTTP status we got back and any error, under Event log in the dashboard.

Payment statuses

Status
requires_paymentCreated, not yet routed to a rail.
awaiting_paymentWaiting on the customer. next_action tells you what they need to do.
paidFunds confirmed. Fulfil here, not before.
failedDeclined, expired or the rail rejected it.
cancelledCancelled before payment.
refundedFully refunded.
disputedA chargeback has been raised.
Never fulfil on 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.