OplaTeam · Card Issuing API

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

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": "..."}

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:

endpointwith a user token
GET /v1/cardslists only the token's cards
GET /v1/cards/{id}the card
GET /v1/cards/{id}/transactionsthe card's transactions
POST /v1/cards/{id}/reveala reveal link, see Show card details below
POST /v1/cards/{id}/freezefreeze the card
POST /v1/cards/{id}/unfreezeunfreeze 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.

KindBefore issuanceTypical use
non_kycNothing. A name is enoughLow-limit spending, fast onboarding
kycThe cardholder must reach a verification levelHigher 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.

LevelProvesWho completes it
1 emailA working email addressYour user, directly
2 phoneA working phone numberYour user, directly
3 documentAn identity documentHosted form
4 livenessA real, present personHosted form, needs a camera
5 selfie_with_documentThe person matches the documentHosted 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.

EventFires when
card.issued / card.issue_failedIssuance settles
card.funded / card.funding_failedA top-up settles
card.state_changedFreeze, unfreeze, terminate
card.transactionA purchase, refund or decline
deposit.creditedYour balance is topped up
balance.lowYour balance crosses your threshold
cardholder.created plannedA cardholder is created
cardholder.verification_updated plannedA level is granted or a session changes
cardholder.verification_approved plannedThe requested level is reached
cardholder.verification_rejected plannedA session is refused, with reasons
card.wallet_code plannedA 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:

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"}}
codemeaning
unauthorized / forbiddenbad key / key lacks access
validation_errormalformed request — also the shape of every 422; param names the offending field
invalid_cursormalformed or incompatible pagination cursor
idempotency_conflictsame key, different payload
insufficient_partner_balancetop-up exceeds your balance
card_terminated / card_not_activecard state refuses the operation
product_not_available plannedproduct is not enabled for your account
verification_required plannedthis product needs a verified cardholder
verification_level_insufficient plannedcardholder has not reached the required level
secret_key_in_browseran sk_ key was sent from a browser; use a user token
provider_error / provider_capacity_unavailableupstream 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.

Complete endpoint reference →