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 '{ ... }'
| Situation | Status | code | What happened |
|---|---|---|---|
| Header missing or blank | 400 | idempotency_key_required | "An idempotency key is required." No transfer is created. |
| Longer than 255 characters | 400 | idempotency_key_invalid | "The idempotency key must be a string of at most 255 characters." |
| Same key, different parameters, within 24 hours of a completed response | 400 | idempotency_key_reused | "This idempotency key was already used with different request parameters." No second transfer. |
| Same key, another request still running | 409 | idempotency_in_progress | "Another request with this idempotency key is still in progress." Retry-After: 1. |
| The original request lost its lock | 409 | idempotency_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 a201withpayment.statusoffailed. 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:
422withcodeexternal_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 fresh201, not a replay.- Merchant processing paused (
422,codeblocked, message "Transfers are currently unavailable for this account. Please contact support for assistance."). The key stays open, so a later retry tries again. - A
500from a crash. The lock is released and the same key can be retried immediately. If the process died while holding the lock, other retries get409until the lock expires (90 seconds, refreshed as the request progresses), then the retry continues the same transfer. - Amount shape errors (
amount.valuenot an integer, not greater than 0, or above2147483647). 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, andaccount_numberonsource.payment_methodanddestination.payment_methodcryptogramonpayment_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/transfersand on every retry of that attempt. - On a network error,
409,500, or422external_api_error, retry the same key and the same body. HonorRetry-Afteron a409. - 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
| Requirement | Details |
|---|---|
| Secret key | sk_sandbox_... for the examples below. |
| Roster account | Card 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.
| Source | Destination | Type |
|---|---|---|
| payment method, or account | omitted, or an account | payment |
| wallet, or account | payment method | payout |
| account, or wallet | wallet | account_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:
| Field | Meaning |
|---|---|
type | payment, payout, or account_transfer |
status | The transfer record: pending, completed, failed, canceled, reversed |
payment.status | Card/ACH lifecycle. Branch on this. Transfer status stays pending through authorize, capture, and refund. |
payout.status | pending or processing right after create, then succeeded, failed, canceled, or returned |
payout.rail | wallet for PayPal, Venmo, Cash App, Payoneer, and Airtm. ach for an ACH destination (speed is standard unless you set it). |
reference_id | Your 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.
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 void | After |
|---|---|
| Authorized, nothing captured | payment.status canceled, captured 0 |
| Partial capture, remainder still open | payment.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
| Scenario | Create | Next calls | payment.status |
|---|---|---|---|
| Charge immediately | confirm: true, automatic | — | succeeded |
| Confirm later | confirm: false, automatic | POST .../confirm | requires_confirmation → succeeded |
| Edit, then confirm | confirm: false, automatic | PATCH, then confirm | requires_confirmation → succeeded |
| Auth only | confirm: true, manual | — | requires_capture |
| Auth later | confirm: false, manual | confirm | requires_confirmation → requires_capture |
| Capture all | manual auth | capture | succeeded |
| Capture in parts | manual auth | capture {400}, then capture | requires_capture → succeeded |
| Void all | manual auth | void | canceled |
| Capture some, void the rest | manual auth | capture {400}, then void | succeeded (captured 400) |
| Full refund | captured payment | refund | refunded |
| Partial, then the rest | captured payment | refund {400}, then refund | succeeded → refunded |
| Saved card | source.payment_method_id | — | same as a nested card |
| Swap card at confirm | confirm: false | confirm with a new source | succeeded on the new card |
Calls that return 422
| Call | Why |
|---|---|
| Create with no source | Source is required |
{ "type": "card", "account_id": "..." } and no card fields | Card number, expiry, CVC, and billing are required |
| capture or void on an automatic charge | Those actions are manual-only. The payment stays succeeded |
| capture before confirm | Still requires_confirmation |
| refund before confirm, or before any capture | Nothing has been captured |
| PATCH after authorize | Updates are only allowed in the setup states listed above |
| capture or refund above the remaining amount | Capture cannot exceed the authorization. Refund cannot exceed the unrefunded capture |
| Second confirm, second full refund, or second void | The 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_number | payment.failure_code |
|---|---|
4726848138487043 | generic_decline |
4726846912498186 | insufficient_funds |
4726840306461785 | expired_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.
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:
type | Required fields |
|---|---|
ach | routing_number, account_number |
cash_app | routing_number, account_number, cash_tag |
paypal | email |
airtm | email |
payoneer | email, routing_number, account_number |
venmo | phone_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"