Card Payments

Please note: If you want to move money between a consumer’s Upward DDA and a debit card they hold at another bank, you’re in the right spot.

Overview

Card Payments let a consumer link an external debit card and then move money to or from it in near real time. There are two directions:

DirectionWhat it doesMoney flow
OCT — Original Credit TransactionPush funds out to the consumer’s external debit cardUpward DDA → external card
AFT — Account Funding TransactionPull funds in from the consumer’s external debit cardExternal card → Upward DDA

Both directions reuse the same registered card, so a consumer links their card once and can then be paid out (OCT) or top up their account (AFT).

The integration has two halves:

  1. Card registration happens in the browser, inside an Upward embedded component. Card numbers are collected in a PCI-compliant iframe hosted by the card processor, so the PAN never touches your systems or Upward’s.
  2. Moving money happens server-to-server, from your backend, using a Partner API Token.

The Card Payments flow

[Consumer's browser] [Your backend] [Upward]
| | |
| 1. you render the component <-------| mint Customer Access Token
| | |
| 2. consumer enters card details ---------------------------> card processor
| | |
| 3. component emits component.closed | |
|-------------------------------------->| |
| | 4. GET external cards -->
| | <-- external_id, pushEnabled, pullEnabled
| | |
| | 5. POST /v2/payments/oct/ (push)
| | POST /v2/payments/aft/ (pull)
| | |
| | <-- 6. Payment.Oct.* / Payment.Aft.* webhooks

Prerequisites

  • A Partner API Token with api:write and api:read, used for all server-side calls. Never expose it to the browser.
  • A Customer Access Token for the consumer, used to load the embedded component. See Token Exchange.
  • An onboarded, active consumer with a payment card and a funded DDA.
  • A registered webhook endpoint. See Registration.

A consumer must be active to create an OCT or AFT. Requests for an inactive consumer are rejected with 422 consumer_inactive.

Base URLs

EnvironmentAPIEmbedded components
Sandboxhttps://api-sandbox.upwardli.comhttps://component-embedded-sandbox.upwardli.com
Productionhttps://api.upwardli.comhttps://component-embedded.upwardli.com

Part 1 — Register the card

1

Mint a Customer Access Token

Exchange your Partner API Token for a consumer-scoped token. The card-registration component needs these scopes:

ScopeWhy the component needs it
api:card-management:readRead the consumer’s cards, including the external card once it is registered
api:card-management:writeGenerate the card-registration iframe URL

See the Token Exchange endpoint, and Scopes for the full matrix.

Customer Access Tokens expire after 1 hour. Mint one immediately before rendering the component rather than reusing a stored token.

2

Display the card-registration component

The simplest integration is the drop-in component. Point an iframe (or web view) at the external-card route with the consumer’s token:

https://component-embedded-sandbox.upwardli.com/external-card/?access_token={customer_access_token}
1<iframe
2 src="https://component-embedded-sandbox.upwardli.com/external-card/?access_token=CUSTOMER_ACCESS_TOKEN"
3 width="100%"
4 height="700"
5 frameborder="0"
6></iframe>

The component renders the card-entry form, submits it to the card processor, and shows a success screen when the card is saved.

Card entry must happen inside the Upward component. You cannot build your own card-entry screen — collecting card details yourself would put your application inside the PCI boundary, which this integration is designed to avoid.

Displaying the component on your own domain. What you can change is where the component is hosted. Call the iframe endpoint with a domain and the returned URL is issued for that parent domain, so you can surface the flow anywhere you need it.

POST /v2/consumers/{consumer_id}/payment-cards/external/

Requires api:card-management:write on a Customer Access Token, or api:write on a Partner API Token.

The body is optional — omit it entirely to use the domain configured for your partner:

1{
2 "domain": "https://app.example.com"
3}
FieldTypeRequiredDescription
domainstringNoParent domain that will host the iframe. Defaults to the Upward domain configured for your environment. Must be allow-listed before use — contact Upward to add one.

Response:

1{
2 "iframe_url": "https://card-registration.example-bank.com/?otcId=8f2c1b0a"
3}

Render iframe_url in an iframe. It carries a short-lived registration session, so request it when the consumer reaches the card-entry screen rather than ahead of time.

The card-registration template itself is configured by Upward against your partner record. It is not a request parameter and does not need to be supplied.

3

Listen for the completion message

The embedded component posts messages to the parent window. Listen for them to know when the consumer is finished:

1window.addEventListener("message", (event) => {
2 const { Event, body } = event.data;
3
4 if (Event === "component.closed") {
5 // The consumer finished the card-registration flow.
6 // Ask your backend to confirm the card and refresh your UI.
7 }
8}, false);

See Component Messaging for the full event list.

Treat the message as a prompt to go check, not as proof of success. Always confirm the card server-side in the next step before moving money.

4

Confirm the card was saved

From your backend, list the consumer’s external cards:

GET /v2/consumers/{consumer_id}/payment-cards/external/
Authorization: Bearer {partner_api_token}

Requires api:card-management:read on a Customer Access Token, or api:read on a Partner API Token. If you are reading the card from a consumer-facing surface that already holds a profile-scoped token, api:consumer-profile:read is also accepted.

1{
2 "count": 1,
3 "next": null,
4 "previous": null,
5 "results": [
6 {
7 "external_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
8 "card_token": "9f8b7c6d5e4f3a2b1c0d",
9 "last_four": "4242",
10 "expiry_month": "11",
11 "expiry_year": "2029",
12 "status": "active",
13 "deactivated_at": null,
14 "rail": "visa_direct",
15 "pushEnabled": true,
16 "pullEnabled": true,
17 "card_company": "Visa"
18 }
19 ]
20}

The card does not appear instantly. It is saved when the card processor confirms the registration, which lands shortly after the consumer finishes the form. An empty results array immediately after component.closed means not yet, not failed. Poll every couple of seconds for up to about 30 seconds before surfacing an error.

Before moving money, check three things:

  • status is active
  • pushEnabled is true if you intend to send an OCT
  • pullEnabled is true if you intend to send an AFT

Not every debit card supports both directions. A card can be perfectly valid for pull and still reject push, so check the flag that matches the direction you need. Both flags are resolved from the card processor’s capability lookup at registration time, and either one is false when that capability is not reported — so read them rather than assuming a card that registered successfully can do both.

Store external_id — every payment call references it as external_payment_card_id.

List consumer external payment cards →


Part 2 — Move money

Both payment endpoints take the same shape: which consumer, which registered card, how much, and an optional description. Both require api:write or api:purchase:write, and both read endpoints require api:read or api:purchase:read.

Push funds to the card (OCT)

POST /v2/payments/oct/
Authorization: Bearer {partner_api_token}
Content-Type: application/json
1{
2 "consumer_id": "7cb94a21-1a3b-4891-9e12-6d1f5a2bc099",
3 "external_payment_card_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
4 "amount": 100.00,
5 "description": "Payout for January"
6}
FieldTypeRequiredDescription
consumer_idstring (uuid)YesThe consumer’s external ID
external_payment_card_idstring (uuid)Yesexternal_id of the registered card. Must belong to consumer_id and have pushEnabled: true.
amountdecimalYesGross amount in USD. Minimum 1.00, maximum is your configured OCT limit.
descriptionstringNoFree text. Defaults to Instant Funds Transfer Out.

Response — 200 OK:

1{
2 "id": "a1b2c3d4-0000-4000-8000-000000000002",
3 "consumer_id": "7cb94a21-1a3b-4891-9e12-6d1f5a2bc099",
4 "external_payment_card_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
5 "amount": 100.00,
6 "fee_amount": 2.50,
7 "description": "Payout for January",
8 "direction": "Push",
9 "status": "pending"
10}

Create OCT payment →

Pull funds from the card (AFT)

POST /v2/payments/aft/
Authorization: Bearer {partner_api_token}
Content-Type: application/json
1{
2 "consumer_id": "7cb94a21-1a3b-4891-9e12-6d1f5a2bc099",
3 "external_payment_card_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
4 "amount": 50.00,
5 "description": "Wallet top-up"
6}
FieldTypeRequiredDescription
consumer_idstring (uuid)YesThe consumer’s external ID
external_payment_card_idstring (uuid)Yesexternal_id of the registered card. Must belong to consumer_id and have pullEnabled: true.
amountdecimalYesGross amount in USD. Minimum 1.00, maximum is your configured AFT limit.
descriptionstringNoFree text. Defaults to Instant Funds Transfer In.

Response — 200 OK:

1{
2 "id": "a1b2c3d4-0000-4000-8000-000000000001",
3 "consumer_id": "7cb94a21-1a3b-4891-9e12-6d1f5a2bc099",
4 "external_payment_card_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
5 "amount": 50.00,
6 "fee_amount": 1.50,
7 "description": "Wallet top-up",
8 "direction": "Pull",
9 "status": "pending"
10}

Create AFT payment →

A 200 means the payment was accepted and submitted to the card network — not that it settled. Settlement is reported asynchronously by webhook.


Amounts and fees

amount is always the gross figure, and the fee comes out of it:

  • OCTamount is debited from the consumer’s DDA. The card receives amount - fee_amount.
  • AFTamount is debited from the card. The DDA is credited amount - fee_amount.

Fees are configured per partner as a flat fee plus a percentage, capped at a maximum. The fee_amount on the response tells you exactly what was applied. If you need the consumer to receive an exact net figure, gross it up before you call.

For OCT, the consumer’s available DDA balance must cover the full amount.


Payment statuses

StatusMeaning
pendingCreated and submitted to the card network. This is what you get back at creation.
queuedAccepted and waiting to be submitted.
postedFunds have settled. This is the terminal success state.
pending_reviewHeld for compliance review.
errorSubmission failed.
rejectedThe card network or issuer declined the payment.
returnedThe payment settled and was later reversed.
canceledCanceled before settlement.

posted — not completed — is the settled state. Do not release goods or credit a ledger on pending.


Webhooks

Subscribe to these events rather than polling. Every event carries a resources array with the URL of the payment to fetch.

EventSent when
Payment.Oct.Created / Payment.Aft.CreatedThe payment record was created
Payment.Oct.Sent / Payment.Aft.SentThe payment was submitted to the card network
Payment.Oct.Completed / Payment.Aft.CompletedFunds settled
Payment.Oct.Failed / Payment.Aft.FailedThe payment failed
Payment.Oct.Canceled / Payment.Aft.CanceledThe payment was canceled
1{
2 "id": "ea04f0a8-b005-47cd-ba33-b02a00c0c426",
3 "created_at": "2026-01-14T07:41:50.45-04:00",
4 "event_name": "payment.oct.completed",
5 "partner_id": "2f221c90-b82d-4e12-9bfd-ae8301097de3",
6 "resources": [
7 "https://api-sandbox.upwardli.com/v2/payments/oct/a1b2c3d4-0000-4000-8000-000000000002"
8 ],
9 "last_attempted_at": "2026-01-14T07:41:50.45-04:00"
10}

See the full Event Catalog and Webhook Security.


Errors

Failures use the standard Upward error envelope:

1{
2 "error": "Validation failed.",
3 "error_code": "insufficient_balance",
4 "message": ["Insufficient DDA balance for this payment"]
5}
Statuserror_codeCause
404not_foundThe consumer does not exist, or the card is unknown or belongs to another consumer
422invalid_amountamount is missing or below the 1.00 minimum
422amount_over_limitamount exceeds your configured OCT or AFT limit
422insufficient_balanceThe consumer’s DDA does not cover the OCT amount, or OCT capacity is temporarily unavailable
422consumer_inactiveThe consumer is not active
422fbo_account_not_configuredThe consumer’s product configuration is missing its FBO account — contact Upward support
422fbo_account_not_foundThe configured FBO account could not be resolved — contact Upward support
422validation_errorThe request body failed schema validation

Retrieving payments

Fetch a single payment by the id returned at creation:

GET /v2/payments/oct/{id}/
GET /v2/payments/aft/{id}/

Or list them for your partner, with page and page_size (max 50):

GET /v2/payments/oct/
GET /v2/payments/aft/

Both return 404 for payments belonging to another partner’s consumers.


Endpoint summary

Scopes are alternates — a token needs any one of the scopes listed for that endpoint, not all of them. Partner API Tokens generally carry the broad api:read / api:write; Customer Access Tokens carry the granular ones.

PurposeMethod and pathAccepted scopes
Generate card-registration iframePOST /v2/consumers/{consumer_id}/payment-cards/external/api:write, api:card-management:write
List a consumer’s external cardsGET /v2/consumers/{consumer_id}/payment-cards/external/api:read, api:card-management:read, api:consumer-profile:read
List all external cardsGET /v2/payment-cards/external/api:read, api:card-management:read, api:consumer-profile:read
Get one external cardGET /v2/payment-cards/external/{id}/api:read, api:card-management:read, api:consumer-profile:read
Push funds to cardPOST /v2/payments/oct/api:write, api:purchase:write
Get an OCT paymentGET /v2/payments/oct/{id}/api:read, api:purchase:read
List OCT paymentsGET /v2/payments/oct/api:read, api:purchase:read
Pull funds from cardPOST /v2/payments/aft/api:write, api:purchase:write
Get an AFT paymentGET /v2/payments/aft/{id}/api:read, api:purchase:read
List AFT paymentsGET /v2/payments/aft/api:read, api:purchase:read

End-to-end example

$# 1. Confirm the consumer has a usable card after they finish the component
$curl -s "https://api-sandbox.upwardli.com/v2/consumers/$CONSUMER_ID/payment-cards/external/" \
> -H "Authorization: Bearer $PARTNER_TOKEN"
$
$# 2. Push $100 to that card
$curl -s -X POST "https://api-sandbox.upwardli.com/v2/payments/oct/" \
> -H "Authorization: Bearer $PARTNER_TOKEN" \
> -H "Content-Type: application/json" \
> -d '{
> "consumer_id": "'"$CONSUMER_ID"'",
> "external_payment_card_id": "'"$CARD_ID"'",
> "amount": 100.00,
> "description": "Payout for January"
> }'
$
$# 3. Check where it landed (or wait for Payment.Oct.Completed)
$curl -s "https://api-sandbox.upwardli.com/v2/payments/oct/$PAYMENT_ID/" \
> -H "Authorization: Bearer $PARTNER_TOKEN"

Testing in sandbox

Sandbox is fully isolated and performs no live financial activity. Use it to exercise the whole path: mint a Customer Access Token, load the component, register a test card, then run an OCT and an AFT against it and confirm your webhook handler moves the payment through pending to posted.

See Sandbox Testing for test data and simulation endpoints.