> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://developers.upwardli.com/products/card-payments/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developers.upwardli.com/_mcp/server. # Card Payments > **Info** > > **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: | Direction | What it does | Money flow | | ------------------------------------- | ----------------------------------------------------- | -------------------------- | | **OCT** — Original Credit Transaction | Push funds out to the consumer's external debit card | Upward DDA → external card | | **AFT** — Account Funding Transaction | Pull funds in from the consumer's external debit card | External 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](/api-access/api-reference/auth/create-token-exchange). * An onboarded, active consumer with a payment card and a funded DDA. * A registered webhook endpoint. See [Registration](/concepts/webhooks/registration). > **Info** > > A consumer must be **active** to create an OCT or AFT. Requests for an > inactive consumer are rejected with `422 consumer_inactive`. ### Base URLs | Environment | API | Embedded components | | ----------- | ---------------------------------- | ------------------------------------------------- | | Sandbox | `https://api-sandbox.upwardli.com` | `https://component-embedded-sandbox.upwardli.com` | | Production | `https://api.upwardli.com` | `https://component-embedded.upwardli.com` | --- # Part 1 — Register the card ### Mint a Customer Access Token Exchange your Partner API Token for a consumer-scoped token. The card-registration component needs these scopes: | Scope | Why the component needs it | | --------------------------- | ---------------------------------------------------------------------------- | | `api:card-management:read` | Read the consumer's cards, including the external card once it is registered | | `api:card-management:write` | Generate the card-registration iframe URL | See the [Token Exchange](/api-access/api-reference/auth/create-token-exchange) endpoint, and [Scopes](/products/credit-builder-card-embedded-documentation) for the full matrix. > **Info** > > Customer Access Tokens expire after **1 hour**. Mint one immediately before > rendering the component rather than reusing a stored token. ### 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} ``` ```html ``` The component renders the card-entry form, submits it to the card processor, and shows a success screen when the card is saved. > **Info** > > 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: ```json { "domain": "https://app.example.com" } ``` | Field | Type | Required | Description | | -------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `domain` | string | No | Parent 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: ```json { "iframe_url": "https://card-registration.example-bank.com/?otcId=8f2c1b0a" } ``` 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. ### Listen for the completion message The embedded component posts messages to the parent window. Listen for them to know when the consumer is finished: ```js window.addEventListener("message", (event) => { const { Event, body } = event.data; if (Event === "component.closed") { // The consumer finished the card-registration flow. // Ask your backend to confirm the card and refresh your UI. } }, false); ``` See [Component Messaging](/ui-components/component-messaging) for the full event list. > **Info** > > 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. ### 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. ```json { "count": 1, "next": null, "previous": null, "results": [ { "external_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "card_token": "9f8b7c6d5e4f3a2b1c0d", "last_four": "4242", "expiry_month": "11", "expiry_year": "2029", "status": "active", "deactivated_at": null, "rail": "visa_direct", "pushEnabled": true, "pullEnabled": true, "card_company": "Visa" } ] } ``` > **Info** > > **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 →](/api-access/api-reference/consumers/payment-cards/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 ``` ```json { "consumer_id": "7cb94a21-1a3b-4891-9e12-6d1f5a2bc099", "external_payment_card_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "amount": 100.00, "description": "Payout for January" } ``` | Field | Type | Required | Description | | -------------------------- | ------------- | -------- | ------------------------------------------------------------------------------------------------ | | `consumer_id` | string (uuid) | Yes | The consumer's external ID | | `external_payment_card_id` | string (uuid) | Yes | `external_id` of the registered card. Must belong to `consumer_id` and have `pushEnabled: true`. | | `amount` | decimal | Yes | Gross amount in USD. Minimum `1.00`, maximum is your configured OCT limit. | | `description` | string | No | Free text. Defaults to `Instant Funds Transfer Out`. | Response — `200 OK`: ```json { "id": "a1b2c3d4-0000-4000-8000-000000000002", "consumer_id": "7cb94a21-1a3b-4891-9e12-6d1f5a2bc099", "external_payment_card_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "amount": 100.00, "fee_amount": 2.50, "description": "Payout for January", "direction": "Push", "status": "pending" } ``` [Create OCT payment →](/api-access/api-reference/payments/oct/create-create-card-payments-request) ## Pull funds from the card (AFT) ``` POST /v2/payments/aft/ Authorization: Bearer {partner_api_token} Content-Type: application/json ``` ```json { "consumer_id": "7cb94a21-1a3b-4891-9e12-6d1f5a2bc099", "external_payment_card_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "amount": 50.00, "description": "Wallet top-up" } ``` | Field | Type | Required | Description | | -------------------------- | ------------- | -------- | ------------------------------------------------------------------------------------------------ | | `consumer_id` | string (uuid) | Yes | The consumer's external ID | | `external_payment_card_id` | string (uuid) | Yes | `external_id` of the registered card. Must belong to `consumer_id` and have `pullEnabled: true`. | | `amount` | decimal | Yes | Gross amount in USD. Minimum `1.00`, maximum is your configured AFT limit. | | `description` | string | No | Free text. Defaults to `Instant Funds Transfer In`. | Response — `200 OK`: ```json { "id": "a1b2c3d4-0000-4000-8000-000000000001", "consumer_id": "7cb94a21-1a3b-4891-9e12-6d1f5a2bc099", "external_payment_card_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "amount": 50.00, "fee_amount": 1.50, "description": "Wallet top-up", "direction": "Pull", "status": "pending" } ``` [Create AFT payment →](/api-access/api-reference/payments/aft/create-aft-payment-request) > **Info** > > 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: * **OCT** — `amount` is debited from the consumer's DDA. The card receives `amount - fee_amount`. * **AFT** — `amount` 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 | Status | Meaning | | ---------------- | --------------------------------------------------------------------------------- | | `pending` | Created and submitted to the card network. This is what you get back at creation. | | `queued` | Accepted and waiting to be submitted. | | `posted` | Funds have settled. This is the terminal success state. | | `pending_review` | Held for compliance review. | | `error` | Submission failed. | | `rejected` | The card network or issuer declined the payment. | | `returned` | The payment settled and was later reversed. | | `canceled` | Canceled before settlement. | > **Info** > > `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. | Event | Sent when | | ------------------------------------------------- | --------------------------------------------- | | `Payment.Oct.Created` / `Payment.Aft.Created` | The payment record was created | | `Payment.Oct.Sent` / `Payment.Aft.Sent` | The payment was submitted to the card network | | `Payment.Oct.Completed` / `Payment.Aft.Completed` | Funds settled | | `Payment.Oct.Failed` / `Payment.Aft.Failed` | The payment failed | | `Payment.Oct.Canceled` / `Payment.Aft.Canceled` | The payment was canceled | ```json { "id": "ea04f0a8-b005-47cd-ba33-b02a00c0c426", "created_at": "2026-01-14T07:41:50.45-04:00", "event_name": "payment.oct.completed", "partner_id": "2f221c90-b82d-4e12-9bfd-ae8301097de3", "resources": [ "https://api-sandbox.upwardli.com/v2/payments/oct/a1b2c3d4-0000-4000-8000-000000000002" ], "last_attempted_at": "2026-01-14T07:41:50.45-04:00" } ``` See the full [Event Catalog](/concepts/webhooks/event-catalog#aft--oct-payment-webhooks) and [Webhook Security](/concepts/webhooks/security). --- ## Errors Failures use the standard Upward error envelope: ```json { "error": "Validation failed.", "error_code": "insufficient_balance", "message": ["Insufficient DDA balance for this payment"] } ``` | Status | `error_code` | Cause | | ------ | ---------------------------- | -------------------------------------------------------------------------------------------- | | 404 | `not_found` | The consumer does not exist, or the card is unknown or belongs to another consumer | | 422 | `invalid_amount` | `amount` is missing or below the `1.00` minimum | | 422 | `amount_over_limit` | `amount` exceeds your configured OCT or AFT limit | | 422 | `insufficient_balance` | The consumer's DDA does not cover the OCT amount, or OCT capacity is temporarily unavailable | | 422 | `consumer_inactive` | The consumer is not active | | 422 | `fbo_account_not_configured` | The consumer's product configuration is missing its FBO account — contact Upward support | | 422 | `fbo_account_not_found` | The configured FBO account could not be resolved — contact Upward support | | 422 | `validation_error` | The 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. | Purpose | Method and path | Accepted scopes | | --------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------- | | Generate card-registration iframe | `POST /v2/consumers/{consumer_id}/payment-cards/external/` | `api:write`, `api:card-management:write` | | List a consumer's external cards | `GET /v2/consumers/{consumer_id}/payment-cards/external/` | `api:read`, `api:card-management:read`, `api:consumer-profile:read` | | List all external cards | `GET /v2/payment-cards/external/` | `api:read`, `api:card-management:read`, `api:consumer-profile:read` | | Get one external card | `GET /v2/payment-cards/external/{id}/` | `api:read`, `api:card-management:read`, `api:consumer-profile:read` | | Push funds to card | `POST /v2/payments/oct/` | `api:write`, `api:purchase:write` | | Get an OCT payment | `GET /v2/payments/oct/{id}/` | `api:read`, `api:purchase:read` | | List OCT payments | `GET /v2/payments/oct/` | `api:read`, `api:purchase:read` | | Pull funds from card | `POST /v2/payments/aft/` | `api:write`, `api:purchase:write` | | Get an AFT payment | `GET /v2/payments/aft/{id}/` | `api:read`, `api:purchase:read` | | List AFT payments | `GET /v2/payments/aft/` | `api:read`, `api:purchase:read` | --- ## End-to-end example ```bash # 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](/concepts/working-in-sandbox/sandbox-testing) for test data and simulation endpoints.