Borderless Payments — Partner API

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_key is returned exactly once. Store it in your secret manager — it can only be revoked, never retrieved again. Afterwards only key_prefix is 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 a reference the user must include in their transfer.
  • pm_open_banking → a short-lived (~10 min) redirect_url the user opens to authorise the payment in their banking app; the bank fields are null.

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 the id to 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:

  1. Verify the signature before trusting anything.
  2. Respond 2xx within 5 seconds — acknowledge first, process async.
  3. Deduplicate on Borderlesspayments-Event-Id — retries and replays redeliver the same id.
  4. Not assume ordering — use the status in 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 statusesPENDING, PENDING_RECONCILIATION (do not retry), AWAITING_PAYMENT, PAYMENT_VERIFYING, PROCESSING, then terminal: COMPLETED, FAILED, CANCELLED, EXPIRED, REFUNDED.

KYC statusesNOT_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.