Issue and operate cards, white-label, over one REST API
Create cardholders, verify them, issue cards, fund them, read transactions, show card details, and receive signed webhooks — under your own brand.
Full API reference →Sections marked planned are specified here and are not on the API yet. They are listed so you can design your integration against the finished contract. Everything unmarked is live today and appears in the reference. We do not publish an endpoint to the reference before it answers.
Base URL and authentication
https://api.oplateam.com
Every request carries your API key as a bearer token. Keys are shown once at
creation: sk_test_… for the sandbox, sk_live_… for production.
curl https://api.oplateam.com/v1/balance \
-H "Authorization: Bearer sk_test_..."
Money conventions
- Amounts are strings with two decimals (
"100.00"), never JSON numbers. - Displayed balances round down; your ledger is authoritative at 6 decimal places.
- Mutating operations require an
Idempotency-Keyheader — retries are safe.
User tokens: call us from your app and website
Your sk_ key must stay on your server. When your mobile app or website
needs to show a user their card, your backend exchanges the key for a
user token: a short-lived credential that works only for the cards
you name. Your client then calls us directly with it.
sequenceDiagram
participant C as Your app or website
participant Y as Your backend
participant O as OplaTeam
C->>Y: user opens the card screen
Y->>O: POST /v1/user-tokens (sk_ key)
O-->>Y: ut_... token, expires_at
Y-->>C: ut_... token
C->>O: GET /v1/cards/{id} (ut_ token)
O-->>C: the card
Y->>O: DELETE /v1/user-tokens/{id} (on logout)
Mint a token
curl -X POST https://api.oplateam.com/v1/user-tokens \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{"card_ids": ["crd_..."], "ttl_seconds": 900}'
{"object": "user_token", "id": "utk_...", "token": "ut_test_...",
"card_ids": ["crd_..."], "expires_at": "..."}
card_ids: 1 to 20 of your cards. If any id is not yours, the whole request answers404and no token is created.ttl_seconds: 60 to 3600, default 900 (15 minutes). Mint a fresh token when it expires; there is no refresh.tokenis shown once. Storeidif you want to revoke it later.- A token minted with
sk_test_isut_test_, withsk_live_it isut_live_.
Use it
curl https://api.oplateam.com/v1/cards/crd_... \
-H "Authorization: Bearer ut_test_..."
A user token works on exactly these endpoints, and only for its own cards:
| endpoint | with a user token |
|---|---|
GET /v1/cards | lists only the token's cards |
GET /v1/cards/{id} | the card |
GET /v1/cards/{id}/transactions | the card's transactions |
POST /v1/cards/{id}/reveal | a reveal link, see Show card details below |
POST /v1/cards/{id}/freeze | freeze the card |
POST /v1/cards/{id}/unfreeze | unfreeze the card |
A card outside the token answers 404, the same as a card that does not
exist. Every other endpoint answers 401 to a user token: issuing,
top-ups, closing a card, spend controls, your balance, deposits, events and webhooks
stay on your server with your sk_ key.
Freeze and unfreeze still take an Idempotency-Key (up to 96 characters
with a user token). Keys sent with a user token never collide with the keys your
backend uses.
Revoke it
curl -X DELETE https://api.oplateam.com/v1/user-tokens/utk_... \
-H "Authorization: Bearer sk_test_..."
The token stops working immediately. Revoking twice, or revoking an expired token,
is harmless. Revoking the sk_ key that minted a token ends that token too.
From a browser
Browsers only let your website read our responses if we know its address. Send us
the exact origins your site runs on (for example https://app.example.com,
up to 10). http://localhost is accepted for development until your first
live key. Responses are readable only from your own origins, never from another
partner's.
A request that carries an sk_ key from a browser is refused with
secret_key_in_browser. Secret keys belong on your server only.
Two kinds of card planned
Every product is one of two kinds, and the kind decides what you must do before a card can be issued.
| Kind | Before issuance | Typical use |
|---|---|---|
non_kyc | Nothing. A name is enough | Low-limit spending, fast onboarding |
kyc | The cardholder must reach a verification level | Higher limits, wallet provisioning, long-lived cards |
You see only the products enabled for your account. Each carries your own effective fees, so the list is also your price list.
curl https://api.oplateam.com/v1/products \
-H "Authorization: Bearer sk_test_..."
{"object": "list", "data": [
{"object": "product", "id": "prd_...", "name": "Virtual USD card",
"kind": "kyc", "currency": "USD", "form_factor": "virtual",
"verification_level_required": 3,
"issuance_fee_usd": "5.00", "topup_fee_percent": "1.5",
"min_topup_usd": "3.00"}
], "has_more": false, "next_cursor": null}
Create a cardholder planned
A cardholder is your end user. Cards belong to a cardholder, and one cardholder can hold several cards.
curl -X POST https://api.oplateam.com/v1/cardholders \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: your-unique-key" \
-H "Content-Type: application/json" \
-d '{"partner_ref": "your-user-42",
"first_name": "Ada", "last_name": "Lovelace",
"email": "ada@example.com", "phone": "+15551234567"}'
{"object": "cardholder", "id": "chl_...", "partner_ref": "your-user-42",
"first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com",
"phone": "+15551234567",
"verification": {"status": "unverified", "level": 0, "next_step": "email"},
"created_at": "..."}
partner_ref is your own user id and is unique within your account.
Sending it twice returns the cardholder you already created, never a second one.
phone is optional here and required before a kyc card is issued.
We store a name, a contact address and a verification level. We never store identity documents. Files go from your user's browser to the verification provider and are never held by us or by you.
Verification planned
Verification is a ladder. Each level is proved once and unlocks the next; levels
cannot be skipped. A product declares the level it needs as
verification_level_required.
| Level | Proves | Who completes it |
|---|---|---|
1 email | A working email address | Your user, directly |
2 phone | A working phone number | Your user, directly |
3 document | An identity document | Hosted form |
4 liveness | A real, present person | Hosted form, needs a camera |
5 selfie_with_document | The person matches the document | Hosted form, needs a camera |
Open a session for the next level and you get back a URL to put in a frame.
Read embeddable before you frame it: when it is false,
send your user to the URL as a full-page navigation instead. Levels 4 and 5 use
the camera, so your own frame must delegate that permission with
allow="camera".
curl -X POST https://api.oplateam.com/v1/cardholders/chl_.../verification-sessions \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: your-unique-key" \
-H "Content-Type: application/json" \
-d '{"target_level": 3}'
{"object": "verification_session", "id": "vfs_...", "status": "pending",
"url": "https://api.oplateam.com/v/vfs_...", "embeddable": true,
"target_level": 3, "documents_required": ["passport"],
"expires_at": "...", "rejection_reasons": null}
The verdict arrives as a webhook. You can also read it at any time:
curl https://api.oplateam.com/v1/cardholders/chl_.../verification \
-H "Authorization: Bearer sk_test_..."
sequenceDiagram
participant Y as Your backend
participant O as OplaTeam
participant U as Your user
Y->>O: POST /v1/cardholders
O-->>Y: chl_... level 0
Y->>O: POST /v1/cardholders/{id}/verification-sessions
O-->>Y: url + embeddable
Y->>U: frame the url
U->>O: completes the hosted form
O-->>Y: cardholder.verification_approved
Y->>O: POST /v1/cards
Levels 1 and 2 have no API. Email and phone are confirmed by
your user directly and cannot be driven or replayed through this API today. We are
resolving how a partner-led integration completes them, and this page will say so
when it changes. Plan for it: a cardholder who has not confirmed both cannot reach
level 3, and therefore cannot be issued a kyc card.
Issue a card
curl -X POST https://api.oplateam.com/v1/cards \
-H "Authorization: Bearer sk_test_..." \
-H "Idempotency-Key: your-unique-key" \
-H "Content-Type: application/json" \
-d '{"cardholder_id": "chl_...", "product_id": "prd_..."}'
Issuance is asynchronous: you get 202 with a pending card and an operation id.
Poll GET /v1/operations/{id}, or just wait for the card.issued webhook.
Cards are issued empty and funded with a top-up, so issuing and funding are always two separate, separately idempotent calls.
Changing. Issuance takes partner_ref,
first_name and last_name today. Those fields move to the
cardholder, and this body becomes cardholder_id plus
product_id. The reference shows the current shape until it changes.
Card lifecycle
stateDiagram-v2 [*] --> pending: POST /v1/cards pending --> active: issuance succeeds pending --> failed: issuance fails active --> frozen: freeze frozen --> active: unfreeze active --> terminated: terminate frozen --> terminated: terminate failed --> [*] terminated --> [*]
failed and terminated are terminal. Top-ups,
card details, and spend controls are accepted only while a card is
active or frozen; state changes are limited to the
arrows above. Repeating a transition already completed is idempotent.
Pagination
Every list returns has_more and an opaque next_cursor;
pass it back as cursor to fetch the next page. A malformed cursor is a
400 invalid_cursor. /v1/transactions and
/v1/events page oldest-first on an append-only sequence and
always return next_cursor on a non-empty page — it is
your resume checkpoint for the next poll. /v1/cards and
/v1/deposits page newest-first and return next_cursor: null
on the last page, so a while next_cursor loop terminates.
Webhooks or polling?
Both ship today — pick whichever suits your infrastructure.
Webhooks push each event to you as it happens; GET /v1/events lets you
pull the same events on your own schedule; and you can always poll a single
resource (GET /v1/cards/{id}, GET /v1/operations/{id}) to
follow one call to its end. Registering a webhook does not disable the event feed,
and using the feed does not require a webhook.
Webhooks
Register an endpoint and we push signed events. Payload schemas for every event are in the reference under Webhooks.
| Event | Fires when |
|---|---|
card.issued / card.issue_failed | Issuance settles |
card.funded / card.funding_failed | A top-up settles |
card.state_changed | Freeze, unfreeze, terminate |
card.transaction | A purchase, refund or decline |
deposit.credited | Your balance is topped up |
balance.low | Your balance crosses your threshold |
cardholder.created planned | A cardholder is created |
cardholder.verification_updated planned | A level is granted or a session changes |
cardholder.verification_approved planned | The requested level is reached |
cardholder.verification_rejected planned | A session is refused, with reasons |
card.wallet_code planned | A one-time wallet code must reach your user |
Every event is also readable from GET /v1/events, so a missed
delivery is never lost.
curl -X PUT https://api.oplateam.com/v1/webhook-endpoint \
-H "Authorization: Bearer sk_test_..." \
-H "Content-Type: application/json" \
-d '{"url": "https://your-app.example/hooks/oplateam"}'
# → returns your signing secret (whsec_...) exactly once
Verify the X-Oplateam-Signature: t=<unix>,v1=<hex> header:
# Python
import hashlib, hmac, time
def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
try:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts["t"])
signature = parts["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time()) - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(),
f"{timestamp}.".encode() + body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Deduplicate on the event id; order by seq. Delivery is
at-least-once with retries over ~21 hours.
Show card details to your user
curl -X POST https://api.oplateam.com/v1/cards/{card_id}/reveal \
-H "Authorization: Bearer sk_test_..."
{"object": "reveal_link", "url": "https://api.oplateam.com/r/rvl_...",
"expires_at": "...", "embeddable": true}
The link opens a page showing the full number, expiry and security code, with no OplaTeam or third-party branding. It is single-use and expires in minutes. The card number is rendered in your user's browser and never passes through your servers, which keeps this flow outside your card-data compliance scope.
Deliver it on an explicit user action, never in an email body — link scanners follow URLs and will consume a single-use link before your user sees it.
To embed it, give us the origins you will frame it from and we allow exactly those. Without that list the page refuses to be framed at all. The hosted page is outside the partner REST API; the API contract ends with the reveal-link response above.
Wallet one-time codes planned
When your user adds a card to Apple Pay or Google Pay, the issuer sends a
one-time code to confirm it. We relay that code to you as
card.wallet_code. Getting it to your user is your job, and there
is no other route to them: we send no messages to your users on your behalf.
Three properties matter, and all three are unusual:
- The code lives for minutes. Treat delivery as urgent and do not batch it.
- It cannot be read back. No endpoint returns it, ever.
- It is redacted from
GET /v1/events. The event appears in your replay log without the code, because a short-lived secret must not become a permanent row. The webhook delivery is the only place it exists.
If your endpoint is down when a code arrives, that provisioning attempt fails and your user retries from their wallet. Nothing else is affected.
Online payment confirmation
Card payments that require confirmation are handled by the issuer directly with your user. There is no API for it here, and no callback, because the underlying card platform does not expose one. If your product depends on intercepting or presenting that step yourself, tell us before you build: it is a platform capability question, not a configuration one.
This is separate from wallet codes above, which are about adding a card to a phone wallet, not about approving a purchase.
Errors
One envelope everywhere:
{"error": {"type": "invalid_request_error", "code": "card_not_active",
"message": "Card crd_... is not active"}}
| code | meaning |
|---|---|
unauthorized / forbidden | bad key / key lacks access |
validation_error | malformed request — also the shape of every 422; param names the offending field |
invalid_cursor | malformed or incompatible pagination cursor |
idempotency_conflict | same key, different payload |
insufficient_partner_balance | top-up exceeds your balance |
card_terminated / card_not_active | card state refuses the operation |
product_not_available planned | product is not enabled for your account |
verification_required planned | this product needs a verified cardholder |
verification_level_insufficient planned | cardholder has not reached the required level |
secret_key_in_browser | an sk_ key was sent from a browser; use a user token |
provider_error / provider_capacity_unavailable | upstream issue — retry later |
Each endpoint's reference entry declares exactly which failure statuses it can produce, all in this envelope.
Rate limits
None are currently enforced. HTTP 429 (envelope type:
rate_limit_error) is reserved — treat it as retryable
with backoff from day one and a future limit will not break your integration.
Test mode
Everything above works with a sk_test_ key. No real money moves and
the wire shapes are identical to production. Events in test mode currently carry
"mode": "live" — a documented limitation, changing with the persistent
sandbox.