Use Transfers V2

Charge a card, push a payout, or move funds between wallets with POST/v2/transfers. This guide walks the three types, the shared write body, and the payment actions (confirm, capture, refund, void). Field-level rules live on that endpoint; the examples here are the shapes that succeed.

Every request is Authorization: Bearer <secret_key> and Content-Type: application/json. Amounts are integer minor units:

{ "value": 1000, "currency": "usd" }

value must be a JSON integer from 1 through 2147483647. Strings ("400"), floats (10.5), and objects are 422. Currency is uppercased before it is stored.

Create returns 201. Confirm, capture, refund, void, and update return 200. Validation failures return 422.

Send an Idempotency-Key on create

POST/v2/transfers requires an Idempotency-Key header. Send one key per transfer you intend to create, and send that same key again on every retry of that request. Confirm, capture, refund, void, update, and read do not use this header.

The key is a string of 1 to 255 characters. Spaces and symbols are allowed (order 42 / retry #1 is valid). A UUID per checkout attempt is the right default. The key is scoped to the authenticated merchant and to the environment of the API key, so the same string in sandbox and live creates two different transfers.

The header is required on the first request, not only on a retry. It is checked before the body is validated.

curl --request POST \
  --url https://api.framepayments.com/v2/transfers \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header "Idempotency-Key: 7f3c1a2e-9b04-4d11-8c6a-2e5b0f91d4aa" \
  --header "Content-Type: application/json" \
  --data '{ ... }'
SituationStatuscodeWhat happened
Header missing or blank400idempotency_key_required"An idempotency key is required." No transfer is created.
Longer than 255 characters400idempotency_key_invalid"The idempotency key must be a string of at most 255 characters."
Same key, different parameters, within 24 hours of a completed response400idempotency_key_reused"This idempotency key was already used with different request parameters." No second transfer.
Same key, another request still running409idempotency_in_progress"Another request with this idempotency key is still in progress." Retry-After: 1.
The original request lost its lock409idempotency_lease_lost"The idempotency lease is no longer held by this run." Retry-After: 1.

Error bodies look like:

{ "status": 400, "error": "An idempotency key is required.", "code": "idempotency_key_required" }

What a retry returns

A completed request is saved for 24 hours, measured from when that response was saved. A retry with the same key and the same parameters returns the original status and the original body, byte for byte, and sets Idempotent-Replay: true. The first response does not have that header. Rate-limit headers are still present.

That saved response includes:

  • A created transfer (201), including one whose payment failed. A processor decline or a blocklisted card is a 201 with payment.status of failed. Retrying does not charge again.
  • A saved validation failure (422), such as an unknown currency.
  • A saved blocked failure (422), such as a restricted customer account.

These are not saved. A retry runs the request again, and the response has no Idempotent-Replay header:

  • 422 with code external_api_error (the processor timed out or was unavailable). The retry continues the same transfer and does not create a second one. The successful retry is a fresh 201, not a replay.
  • Merchant processing paused (422, code blocked, message "Transfers are currently unavailable for this account. Please contact support for assistance."). The key stays open, so a later retry tries again.
  • A 500 from a crash. The lock is released and the same key can be retried immediately. If the process died while holding the lock, other retries get 409 until the lock expires (90 seconds, refreshed as the request progresses), then the retry continues the same transfer.
  • Amount shape errors (amount.value not an integer, not greater than 0, or above 2147483647). Those are rejected before the key is recorded, so they do not consume it.

A key that never completed does not expire into a new transfer. A retry still attaches to the original one, even after 24 hours. Once a response has been saved, the key can be reused after 24 hours, and that reuse creates a new transfer.

What counts as the same request

The comparison is HTTP method, path, and body. Key order does not matter. These fields are ignored, because card data is re-encrypted on every retry:

  • card_number, cvc, cvv, number, and account_number on source.payment_method and destination.payment_method
  • cryptogram on payment_method_options.card.external_3ds
  • Any other string that starts with ev: (Evervault ciphertext)

Changing the amount, currency, payment method id, expiry, metadata, or any other field is a different request and returns idempotency_key_reused. Use a new key when the request is intentionally different.

Client behavior

  • Generate one key when the customer submits, and store it with that attempt.
  • Send it on the first POST /v2/transfers and on every retry of that attempt.
  • On a network error, 409, 500, or 422 external_api_error, retry the same key and the same body. Honor Retry-After on a 409.
  • On Idempotent-Replay: true, use that body as the result. Do not create again.
  • On idempotency_key_reused, stop. The body changed under a key that already completed.

See idempotency for how this sits next to V1 transfers (no header) and billing events (reference).

Prerequisites

RequirementDetails
Secret keysk_sandbox_... for the examples below.
Roster accountCard charges belong to a roster account on the merchant, not the operational owner. Nested card/ACH sources send that account_id.
Wallet + KYC (payouts)Dots platforms (PayPal, Venmo, Cash App, Payoneer, Airtm) need an individual account with KYC and a Dots identity (phone, date of birth, SSN, address). ACH uses the same request shape.

How a request is classified

A slot is a payment method if payment_method_id or payment_method is set, otherwise a wallet if wallet_id is set, otherwise an account if account_id is set. A slot may contain only one of those.

SourceDestinationType
payment method, or accountomitted, or an accountpayment
wallet, or accountpayment methodpayout
account, or walletwalletaccount_transfer

POST /v2/transfers, PATCH /v2/transfers/:id, and POST /v2/transfers/:id/confirm all permit the same body. Which keys are required, and which are applied, depends on the action and on that pair.

What to read on the response:

FieldMeaning
typepayment, payout, or account_transfer
statusThe transfer record: pending, completed, failed, canceled, reversed
payment.statusCard/ACH lifecycle. Branch on this. Transfer status stays pending through authorize, capture, and refund.
payout.statuspending or processing right after create, then succeeded, failed, canceled, or returned
payout.railwallet for PayPal, Venmo, Cash App, Payoneer, and Airtm. ach for an ACH destination (speed is standard unless you set it).
reference_idYour reference from the write body

payment is present only on charges. payout is present only on payouts.

1. Charge a card

A card charge needs confirm (true or false) and authorization_mode (automatic or manual). automatic authorizes and captures in one step. manual authorizes only — capture or void later.

Sandbox success cards are 4111111111111111 and 4242424242424242. Nested source.payment_method requires account_id, type: card, card_number, exp_month, exp_year, cvc, and billing (line_1, city, state, postal_code, country). type: ach takes account_number, routing_number, and account_type (checking or savings) instead of the card fields.

source.account_id (with no payment method) charges that account's default payment method, or the account itself when it has none. A payment-method destination is rejected; destination.account_id is optional.

REQUEST — charge now
curl --request POST \
  --url https://api.framepayments.com/v2/transfers \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header "Idempotency-Key: 7f3c1a2e-9b04-4d11-8c6a-2e5b0f91d4aa" \
  --header "Content-Type: application/json" \
  --data '{
    "amount": { "value": 1000, "currency": "usd" },
    "source": {
      "payment_method": {
        "account_id": "acct_...",
        "type": "card",
        "card_number": "4111111111111111",
        "exp_month": "12",
        "exp_year": "30",
        "cvc": "123",
        "billing": {
          "line_1": "600 California St",
          "city": "San Francisco",
          "state": "CA",
          "postal_code": "94108",
          "country": "US"
        }
      }
    },
    "confirm": true,
    "authorization_mode": "automatic",
    "description": "Order 1042"
  }'

payment.status is succeeded. amount_authorized and amount_captured both equal the amount. After create, source.payment_method.id is the saved method. Later charges can send that id:

{
  "amount": { "value": 1000, "currency": "usd" },
  "source": { "payment_method_id": "pm_..." },
  "confirm": true,
  "authorization_mode": "automatic"
}

Optional on create and applied for payments: description, reference, metadata (a cart_data key inside it is stripped and used as cart data), receipt_email, statement_descriptor (5–22 characters; defaults to the merchant descriptor), cart_data (at most 32 KB), product_id, payment_link_id, subscription_id, invoice_id, sonar_session_id, shipping, and payment_method_options.card.external_3ds. Those record ids must belong to this merchant and environment. Shipping, when present, requires line_1, city, state, postal_code, and country; if carrier is set, tracking_number is required. External 3DS requires version, transaction_id, cryptogram, and electronic_commerce_indicator.

Authorize now, capture later

{
  "amount": { "value": 1000, "currency": "usd" },
  "source": { "payment_method_id": "pm_..." },
  "confirm": true,
  "authorization_mode": "manual"
}

payment.status is requires_capture. amount_authorized is 1000, amount_captured is 0.

Capture the full authorization by omitting amount.value:

curl --request POST \
  --url https://api.framepayments.com/v2/transfers/$TRANSFER_ID/capture \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header "Content-Type: application/json"

Or capture part, then the rest. Capture and refund accept only amount.value (optional):

{ "amount": { "value": 400 } }

The first call leaves payment.status at requires_capture with amount_captured 400. A second capture with no body takes the remainder and moves the payment to succeeded.

Create now, confirm later

{
  "amount": { "value": 1000, "currency": "usd" },
  "source": { "payment_method_id": "pm_..." },
  "confirm": false,
  "authorization_mode": "automatic"
}

payment.status is requires_confirmation. Nothing is authorized or captured.

Patch is payments only, and only while the payment is incomplete, requires_account, requires_payment_method, requires_confirmation, or requires_3d_secure. Applied fields: amount, description, reference, metadata (merged; cart_data inside it is ignored), receipt_email, statement_descriptor, cart_data, authorization_mode, shipping, and source.payment_method_id or source.payment_method. Changing the source moves the payment back to requires_confirmation when that transition is allowed. confirm, destination, and the record ids are permitted and not applied.

curl --request PATCH \
  --url https://api.framepayments.com/v2/transfers/$TRANSFER_ID \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header "Content-Type: application/json" \
  --data '{
    "description": "updated lifecycle",
    "amount": { "value": 1000, "currency": "usd" }
  }'

Then confirm. Confirm uses the same body as update. Blank and null values are dropped, then the remaining fields are updated, then the payment is confirmed. confirm itself is ignored. An empty body just confirms.

curl --request POST \
  --url https://api.framepayments.com/v2/transfers/$TRANSFER_ID/confirm \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header "Content-Type: application/json"

automatic confirms through to succeeded. The same create with authorization_mode: "manual" confirms to requires_capture.

Confirm can replace the card. The new method is what gets charged:

{
  "source": {
    "payment_method": {
      "account_id": "acct_...",
      "type": "card",
      "card_number": "4242424242424242",
      "exp_month": "12",
      "exp_year": "30",
      "cvc": "123",
      "billing": {
        "line_1": "600 California St",
        "city": "San Francisco",
        "state": "CA",
        "postal_code": "94108",
        "country": "US"
      }
    }
  }
}

Release an authorization

Only a manual authorization can be voided, and only while some of it is still uncaptured. No body.

curl --request POST \
  --url https://api.framepayments.com/v2/transfers/$TRANSFER_ID/void \
  --header "Authorization: Bearer $FRAME_SECRET_KEY"
Before the voidAfter
Authorized, nothing capturedpayment.status canceled, captured 0
Partial capture, remainder still openpayment.status succeeded, captured amount stays at the partial

A second void is 422.

Refund captured funds

Refunds apply to captured funds. Only amount.value is accepted. Omit it to refund everything still captured.

curl --request POST \
  --url https://api.framepayments.com/v2/transfers/$TRANSFER_ID/refund \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header "Content-Type: application/json" \
  --data '{ "amount": { "value": 400 } }'

A partial refund leaves payment.status at succeeded and sets amount_refunded. Refunding the remainder (no body) moves it to refunded. A second full refund, a refund larger than what was captured, or a refund before confirmation or capture is 422. Same after an automatic capture and after a manual capture.

Payment status by scenario

ScenarioCreateNext callspayment.status
Charge immediatelyconfirm: true, automatic—succeeded
Confirm laterconfirm: false, automaticPOST .../confirmrequires_confirmation → succeeded
Edit, then confirmconfirm: false, automaticPATCH, then confirmrequires_confirmation → succeeded
Auth onlyconfirm: true, manual—requires_capture
Auth laterconfirm: false, manualconfirmrequires_confirmation → requires_capture
Capture allmanual authcapturesucceeded
Capture in partsmanual authcapture {400}, then capturerequires_capture → succeeded
Void allmanual authvoidcanceled
Capture some, void the restmanual authcapture {400}, then voidsucceeded (captured 400)
Full refundcaptured paymentrefundrefunded
Partial, then the restcaptured paymentrefund {400}, then refundsucceeded → refunded
Saved cardsource.payment_method_id—same as a nested card
Swap card at confirmconfirm: falseconfirm with a new sourcesucceeded on the new card

Calls that return 422

CallWhy
Create with no sourceSource is required
{ "type": "card", "account_id": "..." } and no card fieldsCard number, expiry, CVC, and billing are required
capture or void on an automatic chargeThose actions are manual-only. The payment stays succeeded
capture before confirmStill requires_confirmation
refund before confirm, or before any captureNothing has been captured
PATCH after authorizeUpdates are only allowed in the setup states listed above
capture or refund above the remaining amountCapture cannot exceed the authorization. Refund cannot exceed the unrefunded capture
Second confirm, second full refund, or second voidThe transfer is already in a terminal step for that action

Sandbox declines

confirm: true still creates the transfer (201). The payment lands on failed with amount_captured 0 and payment.failure_code set. Use the success-card shape and swap card_number:

card_numberpayment.failure_code
4726848138487043generic_decline
4726846912498186insufficient_funds
4726840306461785expired_card

More test cards live in testing.

2. Pay out from a wallet

A payout pushes funds from a wallet to a payout method. amount.currency is USD only. Source is wallet_id, or account_id when the destination is a payment method — not both. Destination is required: exactly one of account_id, payment_method_id, payment_method. A destination.account_id must be the owner of the source wallet.

confirm, authorization_mode, shipping, cart data, and the payment-only ids are permitted and ignored. source.rail or destination.rail is optional (ach, rtp, push_to_card, wallet); destination wins over source; defaults to ach. A wallet destination (paypal, venmo, cash_app, payoneer, airtm) forces wallet. speed (standard or same_day) is only stored for ach and defaults to standard.

Create returns 201. type is payout. payout.status is pending or processing. There is no confirm, capture, or void step.

REQUEST — Venmo payout
curl --request POST \
  --url https://api.framepayments.com/v2/transfers \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header "Idempotency-Key: 7f3c1a2e-9b04-4d11-8c6a-2e5b0f91d4aa" \
  --header "Content-Type: application/json" \
  --data '{
    "amount": { "value": 1000, "currency": "usd" },
    "source": { "wallet_id": "wallet_..." },
    "destination": {
      "payment_method": {
        "type": "venmo",
        "account_id": "acct_...",
        "handle": "@adaframedest"
      }
    },
    "description": "venmo payout",
    "reference": "venmo_payout_1710000000"
  }'

reference is your own id, stored as reference_id. Nested destination type and required fields:

typeRequired fields
achrouting_number, account_number
cash_approuting_number, account_number, cash_tag
paypalemail
airtmemail
payoneeremail, routing_number, account_number
venmophone_number or handle

Also permitted and optional: account_id, account_type, and billing. Card fields (card_number, exp_month, exp_year, cvc) are rejected.

PayPal (payout.rail is wallet):

{ "type": "paypal", "account_id": "acct_...", "email": "ada@example.com" }

Venmo — handle, or phone when you have it:

{ "type": "venmo", "account_id": "acct_...", "handle": "@adaframedest" }
{ "type": "venmo", "account_id": "acct_...", "phone_number": "4155550212" }

Cash App:

{
  "type": "cash_app",
  "account_id": "acct_...",
  "routing_number": "021000021",
  "account_number": "987654321",
  "cash_tag": "$adaframe"
}

ACH — payout.rail is ach, payout.speed is standard unless you set it. 110000000 / 000123456789 are the sandbox ACH numbers:

{
  "type": "ach",
  "account_id": "acct_...",
  "routing_number": "110000000",
  "account_number": "000123456789",
  "account_type": "checking"
}

Payoneer (sandbox routing 021000021 / account 987654321):

{
  "type": "payoneer",
  "account_id": "acct_...",
  "email": "ada@example.com",
  "routing_number": "021000021",
  "account_number": "987654321"
}

Airtm:

{ "type": "airtm", "account_id": "acct_...", "email": "ada.airtm@example.com" }

The response echoes the destination under destination.payment_method, including the platform object (venmo.handle, paypal.email, and so on), plus destination.account. The source comes back as source.wallet (id, provider, chain, token) and source.account. A saved destination.payment_method_id is accepted; nesting a new method is the usual path.

3. Move funds to a wallet

Account transfers require amount (USD only), source (account_id or wallet_id, not both), and destination.wallet_id. account_id → wallet_id funds that account's own wallet: the account must own the destination wallet. wallet_id → wallet_id moves to the parent account's wallet. rail and speed are not applied.

curl --request POST \
  --url https://api.framepayments.com/v2/transfers \
  --header "Authorization: Bearer $FRAME_SECRET_KEY" \
  --header "Idempotency-Key: 7f3c1a2e-9b04-4d11-8c6a-2e5b0f91d4aa" \
  --header "Content-Type: application/json" \
  --data '{
    "amount": { "value": 1000, "currency": "usd" },
    "source": { "account_id": "acct_..." },
    "destination": { "wallet_id": "wallet_..." }
  }'

List and retrieve

GET/v2/transfers takes page (default 1) and per_page (default 10, max 100). GET/v2/transfers/{id} takes the transfer id in the path — the same id on update, confirm, capture, refund, and void.

curl --request GET \
  --url "https://api.framepayments.com/v2/transfers?page=1&per_page=10" \
  --header "Authorization: Bearer $FRAME_SECRET_KEY"
Frame Assistant

Ask anything about Frame's APIs and products