Borderless Payments Partner API#
Offer your users a white-labelled EUR → crypto on-ramp through one API. Your users pay EUR by SEPA transfer or Open Banking and receive USDC on Polygon or Stellar at a wallet you specify. We handle KYC, quoting, execution, and settlement — you own the user experience.
Base URL
https://api.borderlesspayments.xyz
All requests and responses are JSON. Authenticate with Authorization: Bearer <credential>.
Two credentials, two jobs:
- API key (
sk_test_…for TEST,sk_…for LIVE) — used by your backend for the integration flow: users, KYC, quotes, orders, webhooks. - Teammate JWT (from
POST /v1/auth/login) — used by humans for account management: teammates, API keys, partner settings.
Every workspace has isolated TEST and LIVE modes. Build against TEST (sk_test_… keys, no real money); we enable LIVE for your account when you're ready to go to production.
Integration steps#
The full journey is create user → KYC → quote → order → webhooks. The steps below take you from your invite email to a completed test order; each one shows the request and the shape of the response you'll get back.
Step 0 — Get your workspace#
You don't sign up. We (Borderless Payments admins) create your partner workspace and invite you as its first teammate (the OWNER) by email. The invite email contains a one-time token — exchange it for an active account by setting a password (min 12 characters):
curl -X POST https://api.borderlesspayments.xyz/api/v1/auth/accept-invite \
-H "Content-Type: application/json" \
-d '{"token": "<invite_token>", "password": "correct-horse-battery-staple"}'
Then log in to get a JWT pair:
curl -X POST https://api.borderlesspayments.xyz/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "dev@acme.example", "password": "correct-horse-battery-staple"}'
{
"access_token": "<access_token>",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "<refresh_token>",
"refresh_expires_in": 1209600,
"teammate": { "id": "…", "email": "dev@acme.example", "role": "OWNER", "status": "ACTIVE" }
}
The access_token lives 15 minutes; the refresh_token lives 14 days and is single-use — exchange it at POST /v1/auth/refresh for a fresh pair, and always store the new refresh token from each refresh.
Step 1 — Invite your team#
As OWNER you can invite teammates so your colleagues get their own logins instead of sharing yours:
curl -X POST https://api.borderlesspayments.xyz/api/v1/teammates \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"email": "ops@acme.example", "display_name": "Sam Ops", "role": "OPERATIONS"}'
{
"teammate": { "id": "…", "email": "ops@acme.example", "role": "OPERATIONS", "status": "INVITED" },
"invite_token": "<invite_token>",
"invite_expires_at": "2026-06-18T11:45:00+00:00"
}
Each invitee receives the same accept-invite email flow as in Step 0. Three roles:
| Role | Can do |
|---|---|
OWNER |
Everything, including managing teammates, partner settings, and the audit log |
DEVELOPER |
Full integration access: users, orders, KYC, webhooks, API keys |
OPERATIONS |
Read-only: view users, orders, KYC status, webhook deliveries |
Step 2 — Create an API key#
Your backend authenticates with an API key, not a JWT. Create one in TEST mode:
curl -X POST https://api.borderlesspayments.xyz/api/v1/api-keys \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"mode": "TEST", "label": "backend-staging"}'
{
"api_key": {
"id": "<api_key_id>",
"mode": "TEST",
"key_prefix": "sk_test_…",
"label": "backend-staging",
"scopes": ["users:read", "users:write", "kyc:read", "kyc:write", "quotes:read", "orders:read", "orders:write", "webhook_configs:manage", "webhook_deliveries:read", "webhook_deliveries:replay", "api_keys:manage"],
"created_at": "2026-06-11T09:00:00+00:00"
},
"plaintext_key": "<plaintext_api_key>"
}
plaintext_keyis returned exactly once. Store it in your secret manager — it can only be revoked, never retrieved again. Afterwards onlykey_prefixis visible.
Verify it works (also tells you your mode and permission scopes):
curl https://api.borderlesspayments.xyz/api/v1/context \
-H "Authorization: Bearer $API_KEY"
{
"partner_id": "<partner_id>",
"partner_slug": "acme-fintech",
"mode": "TEST",
"scopes": ["users:read", "users:write", "..."],
"api_key_id": "<api_key_id>",
"api_key_prefix": "sk_test_…",
"api_key_label": "backend-staging"
}
Step 3 — Register your webhook endpoint#
All asynchronous progress (KYC decisions, payment detection, order completion) is pushed to you as webhooks, so register your endpoint before creating users. It must be a publicly reachable HTTPS URL:
curl -X PUT https://api.borderlesspayments.xyz/api/v1/webhook-configs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook_url": "https://api.acme.example/borderless/webhooks"}'
{
"webhook_url": "https://api.acme.example/borderless/webhooks",
"mode": "TEST",
"enabled": true,
"webhook_signing_version": "v1",
"webhook_secret_prefix": "whsec_…",
"subscribed_event_types": ["*"],
"plaintext_secret": "<webhook_signing_secret>"
}
Save
plaintext_secret— it appears only on this first PUT. You'll use it to verify webhook signatures (see Webhooks). Later PUTs update the URL but keep the secret.
Step 4 — Create an end user#
Register your customer. Only name, email, and mobile are required — date of birth, country, and address are backfilled automatically from the user's verified identity document after KYC, so you don't need to collect them yourself.
curl -X POST https://api.borderlesspayments.xyz/api/v1/users \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"partner_user_ref": "acme-user-1042",
"email": "maria.garcia@example.com",
"first_name": "Maria",
"last_name": "Garcia",
"mobile": "+34685555555"
}'
{
"id": "<user_id>",
"partner_user_ref": "acme-user-1042",
"email": "maria.garcia@example.com",
"first_name": "Maria",
"last_name": "Garcia",
"mobile": "+34685555555",
"dob": null,
"country_code": null,
"kyc_status": "NOT_STARTED",
"created_at": "2026-06-11T09:14:22+00:00"
}
Save the returned id — every later call uses it. partner_user_ref is your own identifier, echoed back on the user and in webhooks so you can correlate without storing our UUIDs. The null PII fields fill in once KYC completes. (See the user object for the full field list.)
Step 5 — KYC#
Start a verification session and hand the returned verify_url to your user (link, email, in-app). The hosted page does document, selfie, and liveness checks — nothing to build on your side.
curl -X POST https://api.borderlesspayments.xyz/api/v1/users/<user_id>/kyc/start \
-H "Authorization: Bearer $API_KEY"
{
"session_id": "<kyc_session_id>",
"verify_url": "https://verify.kyc-provider.example/session/<kyc_session_id>",
"kyc_workflow_id": "standard-eu"
}
Wait for the user.kyc.approved webhook (or poll GET /v1/users/<user_id>/kyc). Status flow:
NOT_STARTED → INIT → SUBMITTED → PENDING → APPROVED | REJECTED | EXPIRED
On approval the user's missing PII is backfilled and they are automatically registered with the execution venue — at that point they can place orders.
User PII is editable via
PATCH /v1/users/{id}only until KYC starts — after that it's locked (409) because the data has entered regulated review.
Step 6 — Quote#
Fetch a fresh price with a full fee breakdown to display to your user:
curl -G https://api.borderlesspayments.xyz/api/v1/quotes \
-H "Authorization: Bearer $API_KEY" \
--data-urlencode "fiat_currency=EUR" \
--data-urlencode "fiat_amount=100.00" \
--data-urlencode "crypto_currency=USDC" \
--data-urlencode "crypto_network=polygon" \
--data-urlencode "country=ES" \
--data-urlencode "user_id=<user_id>"
{
"quote_id": "<quote_id>",
"fiat_currency": "EUR",
"fiat_amount": "100.00",
"crypto_currency": "USDC",
"crypto_network": "polygon",
"crypto_amount": "97.832041",
"conversion_price": "0.991245",
"payment_instrument": "sepa_bank_transfer",
"fees": {"total": "1.99", "network": "0.05", "partner": "1.00", "processing": "0.94"},
"total_fiat_amount": "100.00"
}
user_id is optional for display-only quotes (price tickers). Pass it once the user is APPROVED — the quote is then valid for an order for ~10 minutes.
Step 7 — Create the order#
curl -X POST https://api.borderlesspayments.xyz/api/v1/orders \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"user_id": "<user_id>",
"wallet_address": "<wallet_address>",
"fiat_amount": "100.00",
"fiat_currency": "EUR",
"crypto_currency": "USDC",
"crypto_network": "polygon",
"payment_instrument": "sepa_bank_transfer",
"partner_order_ref": "acme-order-58231"
}'
201 Created:
{
"id": "<order_id>",
"status": "AWAITING_PAYMENT",
"partner_order_ref": "acme-order-58231",
"fiat_currency": "EUR",
"fiat_amount": "100.00",
"crypto_currency": "USDC",
"crypto_network": "polygon",
"crypto_amount": "97.832041",
"wallet_address": "<wallet_address>",
"payment_instrument": "sepa_bank_transfer",
"payment_instructions": {
"iban": "<iban>",
"bic": "<bic>",
"beneficiary_name": "Example Beneficiary Ltd",
"bank_name": "Example Bank",
"reference": "<payment_reference>",
"amount": "100.00",
"currency": "EUR",
"due_by": "2026-06-13T09:20:00+00:00",
"redirect_url": null
},
"created_at": "2026-06-11T10:20:14+00:00"
}
Show payment_instructions to your user. Their shape depends on payment_instrument:
sepa_bank_transfer→ IBAN, BIC, beneficiary, amount, and areferencethe user must include in their transfer.pm_open_banking→ a short-lived (~10 min)redirect_urlthe user opens to authorise the payment in their banking app; the bank fields arenull.
For Stellar deposits to exchange-hosted accounts, also pass wallet_address_memo.
If you get HTTP 202 instead of 201, do not retry. The order is in
PENDING_RECONCILIATION— an upstream call ended in an unknown state and is being resolved. Retrying creates duplicate orders (and duplicate user payments). The 202 body is a normal order object, so you have theidto track; show "processing" and wait for the webhook (usually minutes).
Step 8 — Payment and completion#
For SEPA orders, tell us when the user says they've sent the transfer (idempotent; never call it for Open Banking orders — confirmation there is automatic):
curl -X POST https://api.borderlesspayments.xyz/api/v1/orders/<order_id>/confirm-payment \
-H "Authorization: Bearer $API_KEY"
{
"order_id": "<order_id>",
"status": "AWAITING_PAYMENT",
"confirmed_now": true,
"partner_payment_confirmed_at": "2026-06-11T10:41:03+00:00",
"provider_payment_confirmed_at": "2026-06-11T10:41:03+00:00"
}
The status itself moves only when we detect and verify the funds. Track it via webhooks:
PENDING → AWAITING_PAYMENT → PAYMENT_VERIFYING → PROCESSING → COMPLETED
Any non-terminal state can also end in FAILED, CANCELLED, EXPIRED, or REFUNDED (the webhook carries a reason_code). The final crypto_amount may be refined when the conversion executes — read it from the order.completed event or a fresh GET /v1/orders/{id}.
Going live#
When your TEST integration works end to end, ask your account manager to enable LIVE mode. Then create a LIVE API key (sk_…), register your production webhook URL with it, and switch your backend's key. Until LIVE is enabled, LIVE requests fail with 403 live_mode_not_enabled_for_partner. TEST and LIVE data are fully isolated — users, orders, webhook config, and idempotency keys don't cross over.
Webhooks#
Each event is POSTed to your URL as JSON:
{
"id": "<event_id>",
"type": "order.completed",
"data": {
"id": "<order_id>",
"partner_order_ref": "acme-order-58231",
"status": "COMPLETED",
"fiat_amount": "100.00",
"crypto_amount": "97.832041"
},
"created_at": "2026-06-11T11:03:42.118547+00:00"
}
data is an order summary for order.* events (plus a reason_code on order.failed / order.cancelled / order.expired / order.refunded) and a user summary (id, partner_user_ref, email, kyc_status) for user.kyc.* events.
| Header | Content |
|---|---|
Borderlesspayments-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
Borderlesspayments-Event-Id |
Event UUID — dedupe on this |
Borderlesspayments-Event-Type |
Same as type in the body |
Borderlesspayments-Delivery-Attempt |
1, incrementing on retries |
Event types: order.created, order.awaiting_payment, order.payment_verifying, order.processing, order.completed, order.failed, order.cancelled, order.expired, order.refunded, user.kyc.submitted, user.kyc.approved, user.kyc.rejected, user.kyc.expired, user.kyc.order_provider_rejected.
Order payloads intentionally omit payment_instructions (bank details shouldn't transit more systems than necessary) — fetch them with GET /v1/orders/{id} when you receive order.awaiting_payment.
Your receiver must:
- Verify the signature before trusting anything.
- Respond 2xx within 5 seconds — acknowledge first, process async.
- Deduplicate on
Borderlesspayments-Event-Id— retries and replays redeliver the same id. - Not assume ordering — use the
statusin the payload, not the arrival sequence.
Verifying signatures#
Compute HMAC_SHA256(secret, "{t}.{raw_body}") over the exact raw request bytes and compare with v1 in constant time. Reject timestamps older than 5 minutes. (The scheme is Stripe-compatible — existing verifiers port with a header rename.)
import hashlib, hmac, time
def verify(header: str, secret: str, raw_body: bytes) -> bool:
parts = dict(seg.strip().split("=", 1) for seg in header.split(","))
ts, expected = int(parts["t"]), parts["v1"]
if abs(time.time() - ts) > 300:
return False
signed = f"{ts}.{raw_body.decode('utf-8')}".encode("utf-8")
digest = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(digest, expected)
Retries#
Failed deliveries (non-2xx or >5 s) are retried on a backoff schedule — 30 s, 2 min, 10 min, 1 h, 6 h, 24 h, 72 h — then dead-lettered. Inspect failures with GET /v1/webhook-deliveries?status=dead_lettered and re-queue with POST /v1/webhook-deliveries/{id}/replay after fixing your endpoint.
Conventions#
Errors share one envelope. Branch on the stable error.code, never on message. request_id identifies the request in our logs — include it in support tickets:
{
"error": {"code": "validation_error", "message": "Request validation failed", "details": {}},
"request_id": "<request_id>"
}
| HTTP | error.code |
Meaning |
|---|---|---|
| 400 | mode_required |
JWT call missing the X-Borderlesspayments-Mode header |
| 401 | authentication_failed |
Missing/invalid/revoked credential |
| 403 | forbidden |
Credential lacks a required scope |
| 403 | live_mode_not_enabled_for_partner |
LIVE not yet enabled for your account |
| 404 | not_found |
Doesn't exist (or belongs to another partner/mode) |
| 409 | conflict |
State conflict (duplicate email, PII locked, terminal order, …) |
| 422 | validation_error |
Schema validation failed (see details.errors) |
| 422 | idempotency_key_reuse_mismatch |
Same Idempotency-Key, different body |
| 502 | upstream_error |
Upstream provider failed — safe to retry later |
Idempotency. Mutating calls on users, KYC, quotes, and orders accept an Idempotency-Key header (fresh UUIDv4 per logical operation). A retry with the same key and body replays the stored response instead of duplicating the action; responses are stored 24 h. Always send one on POST /v1/orders and POST /v1/users.
Pagination. List endpoints take limit/offset and return:
{"items": [ { "...": "..." } ], "total": 137, "limit": 20, "offset": 0}
offset + limit < total means there are more pages. Most lists support filters (status, q, created_after/created_before) and sort_by/sort_order.
Data types. Amounts are decimal strings ("100.00" — never parse as float). Timestamps are ISO-8601 UTC. Fiat currencies uppercase (EUR), crypto uppercase (USDC), networks lowercase (polygon, stellar), countries ISO-3166 alpha-2 (ES). Unknown request fields are rejected with 422.
JWT calls to integration endpoints (users, KYC, quotes, orders, webhooks) need an extra header naming the mode, since JWTs don't encode one: X-Borderlesspayments-Mode: TEST or LIVE. API keys don't need it (mode comes from the key prefix); account endpoints (auth, me, teammates, api-keys, partner) don't either.
API reference#
Each resource below lists its endpoints, the object it returns, and one representative request. $API_KEY = an API key (or a JWT + mode header); $JWT = a teammate access token. Integration endpoints accept either credential; account endpoints require a JWT. List endpoints return the pagination envelope with items of the resource's object.
Context#
| Endpoint | Description | Returns |
|---|---|---|
GET /api/v1/context |
Who am I: partner, mode, effective scopes, key metadata | Context object |
curl https://api.borderlesspayments.xyz/api/v1/context \
-H "Authorization: Bearer $API_KEY"
{
"partner_id": "<partner_id>",
"partner_slug": "acme-fintech",
"mode": "TEST",
"scopes": ["users:read", "users:write", "..."],
"api_key_id": "<api_key_id>",
"api_key_prefix": "sk_test_…",
"api_key_label": "backend-staging"
}
On the JWT path the api_key_* fields are null and scopes reflects the teammate's role.
Users#
| Endpoint | Description | Returns |
|---|---|---|
POST /api/v1/users |
Create an end user (required: email, first_name, last_name, mobile E.164; optional: partner_user_ref, dob, country_code, address, kyc_level_name) |
User |
GET /api/v1/users |
List users — filters: kyc_status, q, country_code, disabled, date range |
User list |
GET /api/v1/users/{id} |
One user | User |
PATCH /api/v1/users/{id} |
Edit PII — any subset of the create fields; only before KYC starts (409 after) | User |
POST /api/v1/users/{id}/disable |
Block from ordering and KYC (optional {"reason": "…"}) |
User |
POST /api/v1/users/{id}/enable |
Unblock | User |
The user object:
{
"id": "<user_id>",
"partner_id": "<partner_id>",
"mode": "TEST",
"partner_user_ref": "acme-user-1042",
"email": "maria.garcia@example.com",
"first_name": "Maria",
"last_name": "Garcia",
"dob": "1992-04-17",
"mobile": "+34685555555",
"country_code": "ES",
"address_line1": "Calle Mayor 1",
"address_line2": null,
"address_city": "Madrid",
"address_state": "Madrid",
"address_postcode": "28013",
"kyc_level_name": "standard-eu",
"kyc_applicant_id": "<kyc_session_id>",
"kyc_status": "APPROVED",
"provider_registered_at": "2026-06-11T10:02:51+00:00",
"provider_kyc_rejected_at": null,
"disabled_at": null,
"disabled_reason": null,
"created_at": "2026-06-11T09:14:22+00:00",
"updated_at": "2026-06-11T10:02:51+00:00"
}
dob, country_code, and address_* are null until backfilled from the KYC document. provider_registered_at set means the user can place orders; provider_kyc_rejected_at set means the execution venue vetoed the identity (the user cannot order — direct them to support). email and partner_user_ref are unique per partner+mode (409 on duplicates — look the user up instead).
curl -X POST https://api.borderlesspayments.xyz/api/v1/users \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"email": "maria.garcia@example.com", "first_name": "Maria", "last_name": "Garcia", "mobile": "+34685555555"}'
KYC#
| Endpoint | Description | Returns |
|---|---|---|
POST /api/v1/users/{id}/kyc/start |
Create a verification session | {session_id, verify_url, kyc_workflow_id} |
GET /api/v1/users/{id}/kyc |
Current status + recent events | KYC status object |
POST /api/v1/users/{id}/kyc/refresh |
Re-pull provider status (missed webhook; never downgrades APPROVED) | {user_id, previous_status, current_status, changed} |
POST /api/v1/users/{id}/kyc/onboard-upstream |
Manually retry execution-venue registration (normally automatic; idempotent) | {onboarded, onboarded_at} |
POST /api/v1/users/{id}/kyc/purpose-of-usage |
Declare purpose of usage when the venue requests one ({"purposes": ["…"]}) |
{submitted, purposes} |
GET /api/v1/kyc-levels |
Verification workflows usable as kyc_level_name (omit on create for your default) |
KYC level list |
The KYC status object:
{
"user_id": "<user_id>",
"kyc_status": "APPROVED",
"kyc_applicant_id": "<kyc_session_id>",
"kyc_level_name": "standard-eu",
"provider_registered_at": "2026-06-11T10:02:51+00:00",
"last_event_at": "2026-06-11T10:02:51+00:00",
"recent_events": [
{
"id": "<kyc_event_id>",
"source": "kyc_provider",
"event_type": "status.updated",
"from_status": "PENDING",
"to_status": "APPROVED",
"created_at": "2026-06-11T10:02:51+00:00"
}
]
}
curl -X POST https://api.borderlesspayments.xyz/api/v1/users/<user_id>/kyc/start \
-H "Authorization: Bearer $API_KEY"
Quotes#
| Endpoint | Description | Returns |
|---|---|---|
GET /api/v1/quotes |
Fresh price — required: fiat_currency, fiat_amount, crypto_currency, crypto_network, country; optional: payment_instrument, user_id (order-grade for ~10 min for an APPROVED user) |
Quote |
GET /api/v1/quotes/catalogue |
Supported currencies/networks/instruments — read at runtime, don't hard-code | Catalogue |
The quote object:
{
"quote_id": "<quote_id>",
"fiat_currency": "EUR",
"fiat_amount": "100.00",
"crypto_currency": "USDC",
"crypto_network": "polygon",
"crypto_amount": "97.832041",
"conversion_price": "0.991245",
"payment_instrument": "sepa_bank_transfer",
"fees": {"total": "1.99", "network": "0.05", "partner": "1.00", "processing": "0.94"},
"total_fiat_amount": "100.00"
}
The catalogue object:
{
"fiat_currencies": ["EUR"],
"crypto_currencies": ["USDC"],
"crypto_networks": ["polygon", "stellar"],
"payment_instruments": ["pm_open_banking", "sepa_bank_transfer"]
}
curl -G https://api.borderlesspayments.xyz/api/v1/quotes \
-H "Authorization: Bearer $API_KEY" \
--data-urlencode "fiat_currency=EUR" \
--data-urlencode "fiat_amount=100.00" \
--data-urlencode "crypto_currency=USDC" \
--data-urlencode "crypto_network=polygon" \
--data-urlencode "country=ES"
Orders#
| Endpoint | Description | Returns |
|---|---|---|
POST /api/v1/orders |
Create — required: user_id (APPROVED, country_code on record), wallet_address, fiat_amount, crypto_currency, crypto_network; optional: fiat_currency (default EUR), payment_instrument, partner_order_ref, wallet_address_memo (Stellar exchange deposits). 202 = PENDING_RECONCILIATION: do not retry |
Order |
GET /api/v1/orders |
List — filters: status, user_id, q, currency/network/instrument, amount range, date range |
Order list |
GET /api/v1/orders/{id} |
One order — the canonical way to fetch payment_instructions after an order.awaiting_payment webhook |
Order |
POST /api/v1/orders/{id}/confirm-payment |
Mark the user's SEPA transfer as sent — idempotent; 409 for Open Banking or terminal orders | Confirmation object |
The order object:
{
"id": "<order_id>",
"partner_id": "<partner_id>",
"user_id": "<user_id>",
"mode": "TEST",
"partner_order_ref": "acme-order-58231",
"status": "AWAITING_PAYMENT",
"fiat_currency": "EUR",
"fiat_amount": "100.00",
"crypto_currency": "USDC",
"crypto_network": "polygon",
"crypto_amount": "97.832041",
"wallet_address": "<wallet_address>",
"wallet_address_memo": null,
"payment_instrument": "sepa_bank_transfer",
"quote_id": "<quote_id>",
"payment_instructions": {
"iban": "<iban>",
"bic": "<bic>",
"beneficiary_name": "Example Beneficiary Ltd",
"bank_name": "Example Bank",
"reference": "<payment_reference>",
"amount": "100.00",
"currency": "EUR",
"due_by": "2026-06-13T09:20:00+00:00",
"redirect_url": null
},
"created_at": "2026-06-11T10:20:14+00:00",
"updated_at": "2026-06-11T10:20:14+00:00",
"last_event_at": null,
"partner_payment_confirmed_at": null,
"provider_payment_confirmed_at": null
}
For Open Banking orders payment_instructions carries only redirect_url (the bank fields are null). The confirmation object returned by confirm-payment: {order_id, status, confirmed_now, partner_payment_confirmed_at, provider_payment_confirmed_at} — confirmed_now: false on repeat calls.
curl -X POST https://api.borderlesspayments.xyz/api/v1/orders \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"user_id": "<user_id>",
"wallet_address": "<wallet_address>",
"fiat_amount": "100.00",
"crypto_currency": "USDC",
"crypto_network": "polygon"
}'
Webhook configuration#
| Endpoint | Description | Returns |
|---|---|---|
PUT /api/v1/webhook-configs |
Set or change your HTTPS endpoint (re-enables a disabled config) | Config — plaintext_secret only on the first PUT per mode |
GET /api/v1/webhook-configs |
Current config (404 until one exists) | Config |
PUT /api/v1/webhook-configs/subscriptions |
Replace the event allowlist — *, prefix wildcards (order.*, user.kyc.*), or exact types; typos rejected with 422 |
Config |
GET /api/v1/webhook-configs/event-catalog |
Every emittable event type plus legal wildcards | {items: [{event_type, resource}], wildcards} |
POST /api/v1/webhook-configs/rotate-secret |
New signing secret, effective immediately (including retries) | {plaintext_secret, webhook_signing_version} — shown once |
The config object:
{
"webhook_url": "https://api.acme.example/borderless/webhooks",
"mode": "TEST",
"enabled": true,
"webhook_signing_version": "v1",
"webhook_secret_prefix": "whsec_…",
"subscribed_event_types": ["*"]
}
curl -X PUT https://api.borderlesspayments.xyz/api/v1/webhook-configs \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"webhook_url": "https://api.acme.example/borderless/webhooks"}'
Webhook deliveries#
| Endpoint | Description | Returns |
|---|---|---|
GET /api/v1/webhook-deliveries |
List outbound deliveries — filters: status (pending / delivered / dead_lettered), event_type, resource_id, last_status_code, min_attempts, date range |
Delivery list |
GET /api/v1/webhook-deliveries/{id} |
One delivery, with the exact payload and last signature header (debug verification) | Delivery |
POST /api/v1/webhook-deliveries/{id}/replay |
Re-queue for immediate redelivery — same event id, so dedupe | Delivery |
The delivery object:
{
"id": "<delivery_id>",
"event_id": "<event_id>",
"event_type": "order.completed",
"resource_type": "order",
"resource_id": "<order_id>",
"payload": { "...": "the exact data object that was sent" },
"target_url": "https://api.acme.example/borderless/webhooks",
"attempt_count": 8,
"max_attempts": 8,
"next_attempt_at": null,
"last_status_code": 503,
"last_error": "server error: 503",
"signature": "t=1781175822,v1=5257a869…",
"delivered_at": null,
"dead_lettered_at": "2026-06-14T11:03:42+00:00",
"created_at": "2026-06-11T11:03:42+00:00"
}
curl -G https://api.borderlesspayments.xyz/api/v1/webhook-deliveries \
-H "Authorization: Bearer $API_KEY" \
--data-urlencode "status=dead_lettered"
Auth#
| Endpoint | Description | Returns |
|---|---|---|
POST /api/v1/auth/accept-invite |
Activate an invited teammate — {token, password?} (password only for brand-new accounts) |
Teammate |
POST /api/v1/auth/login |
{email, password, partner_slug?} |
Token pair — or a workspace picker if you belong to several and omit partner_slug |
POST /api/v1/auth/select-partner |
Finish a picker login — {session_token, partner_slug} (single-use token) |
Token pair |
POST /api/v1/auth/refresh |
{refresh_token} — single-use rotation; store the new refresh token |
Token pair |
POST /api/v1/auth/logout |
{refresh_token} — revoke its session |
204 |
POST /api/v1/auth/change-password |
{current_password, new_password} (JWT; applies to all workspaces) |
{ok: true} |
POST /api/v1/auth/forgot-password |
{email} — always 200 (no enumeration); reset link valid 1 h |
{ok: true} |
POST /api/v1/auth/reset-password |
{token, new_password} from the reset email |
{ok: true} |
POST /api/v1/auth/verify-new-email |
{token} — completes an email change started at /v1/me/change-email |
{ok: true} |
GET /api/v1/auth/sessions |
Your active login sessions (JWT) | Session list |
DELETE /api/v1/auth/sessions/{id} |
Revoke one session (e.g. a lost laptop) | 204 |
The token pair (login / select-partner / refresh):
{
"access_token": "<access_token>",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "<refresh_token>",
"refresh_expires_in": 1209600,
"teammate": { "id": "…", "email": "…", "role": "OWNER", "status": "ACTIVE" }
}
The picker response (multi-workspace login without partner_slug) — discriminate on the presence of access_token:
{
"session_token": "<session_token>",
"token_type": "Bearer",
"expires_in": 300,
"workspaces": [
{"partner_slug": "acme-fintech", "partner_display_name": "Acme Fintech", "partner_status": "ACTIVE", "role": "OWNER"}
]
}
curl -X POST https://api.borderlesspayments.xyz/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "dev@acme.example", "password": "…"}'
Me and workspaces#
| Endpoint | Description | Returns |
|---|---|---|
GET /api/v1/me |
Your teammate profile + partner workspace | {teammate, partner} |
PATCH /api/v1/me |
Edit your display_name |
Teammate |
POST /api/v1/me/change-email |
{new_email, current_password} — verification link goes to the new address |
{ok: true} |
GET /api/v1/me/workspaces |
List your memberships | Workspace list |
POST /api/v1/me/switch-workspace |
{partner_slug} — hot-swap, no password challenge |
Token pair |
POST /api/v1/me/leave-workspace |
Leave the current workspace — revokes API keys you created; the last OWNER can't leave (409) | {ok: true} |
curl https://api.borderlesspayments.xyz/api/v1/me -H "Authorization: Bearer $JWT"
Partner#
| Endpoint | Description | Returns |
|---|---|---|
GET /api/v1/partner |
Your workspace profile | Partner |
PATCH /api/v1/partner |
Edit display_name, legal_name, primary_contact_email, primary_contact_name (OWNER only) |
Partner |
The partner object:
{
"id": "<partner_id>",
"slug": "acme-fintech",
"display_name": "Acme Fintech",
"legal_name": "Acme Fintech S.L.",
"status": "ACTIVE",
"live_mode_enabled": false,
"primary_contact_email": "contact@acme.example",
"primary_contact_name": "Alex Acme",
"company_country": "ES",
"default_kyc_level_name": "standard-eu",
"created_at": "2026-01-10T00:00:00+00:00",
"updated_at": "2026-06-01T00:00:00+00:00"
}
live_mode_enabled is read-only — switched on by Borderless Payments when your account is approved for production.
curl https://api.borderlesspayments.xyz/api/v1/partner -H "Authorization: Bearer $JWT"
Teammates#
All mutations require the OWNER role.
| Endpoint | Description | Returns |
|---|---|---|
POST /api/v1/teammates |
Invite — {email, display_name?, role?} (default DEVELOPER); token shown once and also emailed |
{teammate, invite_token, invite_expires_at} |
GET /api/v1/teammates |
List — filters: status, role, q, date range |
Teammate list |
GET /api/v1/teammates/{id} |
One teammate | Teammate |
PATCH /api/v1/teammates/{id} |
Change role / status — the last active OWNER can't be demoted (409) |
Teammate |
POST /api/v1/teammates/{id}/disable · …/enable |
Pause/unpause login — their API keys keep working, so pausing a person doesn't break your integration | Teammate |
DELETE /api/v1/teammates/{id} |
Remove from the workspace and revoke every API key they created — check key ownership first | Teammate |
POST /api/v1/teammates/{id}/resend-invite |
New invite token for a still-INVITED teammate |
{teammate, invite_token, invite_expires_at} |
The teammate object:
{
"id": "<teammate_id>",
"partner_id": "<partner_id>",
"email": "ops@acme.example",
"display_name": "Sam Ops",
"role": "OPERATIONS",
"status": "ACTIVE",
"last_login_at": "2026-06-11T08:00:12+00:00",
"invite_expires_at": null,
"created_at": "2026-06-11T11:45:00+00:00",
"updated_at": "2026-06-11T11:45:00+00:00"
}
curl -X POST https://api.borderlesspayments.xyz/api/v1/teammates \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"email": "ops@acme.example", "display_name": "Sam Ops", "role": "OPERATIONS"}'
API keys#
| Endpoint | Description | Returns |
|---|---|---|
POST /api/v1/api-keys |
Create — {mode, label?, scopes?}; scopes defaults to your role's full set, restrict it for production keys |
{api_key, plaintext_key} — plaintext shown once |
GET /api/v1/api-keys |
List — filters: mode, include_revoked, q, creator, date range |
Key list (never the plaintext) |
DELETE /api/v1/api-keys/{id} |
Revoke, effective immediately — rotate by creating the new key first, switching traffic, then revoking | Key |
The API key object:
{
"id": "<api_key_id>",
"mode": "TEST",
"key_prefix": "sk_test_…",
"label": "backend-staging",
"scopes": ["users:read", "users:write", "..."],
"last_used_at": null,
"expires_at": null,
"revoked_at": null,
"created_by_teammate_id": "<teammate_id>",
"created_at": "2026-06-11T09:00:00+00:00"
}
curl -X POST https://api.borderlesspayments.xyz/api/v1/api-keys \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{"mode": "TEST", "label": "backend-staging", "scopes": ["users:read", "users:write", "kyc:read", "kyc:write", "quotes:read", "orders:read", "orders:write"]}'
Audit log#
OWNER only. Every state-changing action by your teammates, API keys, or the platform is recorded — suitable for SOC 2 evidence.
| Endpoint | Description | Returns |
|---|---|---|
GET /api/v1/audit-events |
List — filters: actor_type, actor_id, resource_type, resource_id, action_prefix, date range |
Audit event list |
GET /api/v1/audit-events/{id} |
One event | Audit event |
The audit event object:
{
"id": "<audit_event_id>",
"actor_type": "PARTNER_TEAMMATE",
"actor_id": "<teammate_id>",
"actor_label": "api_key:sk_test_…",
"action": "order.created",
"resource_type": "order",
"resource_id": "<order_id>",
"outcome": "SUCCESS",
"failure_reason": null,
"created_at": "2026-06-11T10:20:14+00:00"
}
curl -G https://api.borderlesspayments.xyz/api/v1/audit-events \
-H "Authorization: Bearer $JWT" \
--data-urlencode "action_prefix=order."
Health#
| Endpoint | Description | Returns |
|---|---|---|
GET /healthz |
Liveness (unauthenticated, no /api/v1 prefix) |
{"status": "ok"} |
GET /readyz |
Readiness incl. dependencies — point your uptime monitoring here | {"status": "ok", "checks": {…}} or 503 |
curl https://api.borderlesspayments.xyz/readyz
Reference tables#
Order statuses — PENDING, PENDING_RECONCILIATION (do not retry), AWAITING_PAYMENT, PAYMENT_VERIFYING, PROCESSING, then terminal: COMPLETED, FAILED, CANCELLED, EXPIRED, REFUNDED.
KYC statuses — NOT_STARTED, INIT, SUBMITTED, PENDING, then APPROVED, REJECTED, or EXPIRED. Forward-only: APPROVED is never downgraded.
Supported assets (read GET /v1/quotes/catalogue at runtime — this matrix will grow):
| Dimension | Values |
|---|---|
| Fiat | EUR |
| Crypto | USDC |
| Networks | polygon, stellar |
| Payment instruments | sepa_bank_transfer, pm_open_banking |
Roles and key scopes. API-key scopes are frozen at creation and must be a subset of the creating teammate's role: OWNER has all 14 scopes; DEVELOPER has everything except teammates:manage, partner_settings:write, audit_log:read; OPERATIONS has users:read, orders:read, kyc:read, quotes:read, webhook_deliveries:read.
New optional fields and event types may be added over time — build parsers that ignore unknown fields and webhook consumers that skip unknown event types. Questions? Contact your account manager or include the request_id from any error response in a support ticket.