Pay a US bank account instantly by RTP
Save an RTP payee and send an instant USD payment from a bank-held USD account.
Use this recipe to save an RTP payee and send an instant USD payment from a bank-held USD account.
Who it is for. An on-demand staffing app that pays a shift worker the same minute it approves the shift.
Status. Available from bank-held USD accounts, if the receiving bank takes instant payments. Not supported from wallet-backed accounts.
Money example
- The staffing app's bank-held USD account holds 3,000.00 USD.
- The app pays 180.00 USD to Luis Ortega by RTP. In this example, the fee on its plan is 0.50 USD.
- Gravv debits the account 180.50 USD. Luis's bank credits 180.00 USD within seconds.
Before you start
- Everything in Pay a US bank account by ACH.
- The source must be a bank-held USD account. A wallet-backed account cannot pay by RTP.
Find out what is required
Save the payee with "payment_rail": "rtp". When you save the payee, Gravv checks the routing number against the instant-payments list. If the bank is not on the list, the API returns a 400 that tells you to use ACH or wire.
Steps
1. Save the RTP payee
curl --request POST \
--url https://api.gravv.xyz/v1/external-accounts \
--header 'Api-Key: <API_KEY>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: 81981a8a-21d2-4920-bdb4-5633cba433cd' \
--data '
{
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payee_type": "ach_wire",
"currency": "USD",
"account_name": "Luis Ortega",
"account_number": "7781203345",
"routing_number": "021000021",
"payment_rail": "rtp",
"bank_account_type": "checking",
"account_owner_type": "individual",
"bank_name": "JPMorgan Chase Bank",
"internal_account_id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
"address": {
"address_line1": "1450 Oak Street",
"city": "Chicago",
"state": "IL",
"postal_code": "60610",
"country": "US"
}
}
'
const response = await fetch("https://api.gravv.xyz/v1/external-accounts", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": "81981a8a-21d2-4920-bdb4-5633cba433cd",
},
body: JSON.stringify({
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payee_type": "ach_wire",
"currency": "USD",
"account_name": "Luis Ortega",
"account_number": "7781203345",
"routing_number": "021000021",
"payment_rail": "rtp",
"bank_account_type": "checking",
"account_owner_type": "individual",
"bank_name": "JPMorgan Chase Bank",
"internal_account_id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
"address": {
"address_line1": "1450 Oak Street",
"city": "Chicago",
"state": "IL",
"postal_code": "60610",
"country": "US"
}
}),
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://api.gravv.xyz/v1/external-accounts",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": "81981a8a-21d2-4920-bdb4-5633cba433cd",
},
json={
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payee_type": "ach_wire",
"currency": "USD",
"account_name": "Luis Ortega",
"account_number": "7781203345",
"routing_number": "021000021",
"payment_rail": "rtp",
"bank_account_type": "checking",
"account_owner_type": "individual",
"bank_name": "JPMorgan Chase Bank",
"internal_account_id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
"address": {
"address_line1": "1450 Oak Street",
"city": "Chicago",
"state": "IL",
"postal_code": "60610",
"country": "US",
},
},
)
print(response.status_code, response.json())
The response is HTTP 202 with status pending and the payee id, here 3d813cbb-47fb-42ba-91df-831e1593ac29. Wait for the external_account.active webhook.
2. Send the payment
curl --request POST \
--url https://api.gravv.xyz/v1/transfer \
--header 'Api-Key: <API_KEY>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: e6ecc4de-197e-45e9-9a97-7140e2a3338e' \
--data '
{
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538"
},
"destination": {
"destination_type": "external_account",
"id": "3d813cbb-47fb-42ba-91df-831e1593ac29",
"rail": "rtp"
},
"amount": "180.00",
"description": "Shift 88123 pay",
"client_reference": "shift-88123"
}
'
const response = await fetch("https://api.gravv.xyz/v1/transfer", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": "e6ecc4de-197e-45e9-9a97-7140e2a3338e",
},
body: JSON.stringify({
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538"
},
"destination": {
"destination_type": "external_account",
"id": "3d813cbb-47fb-42ba-91df-831e1593ac29",
"rail": "rtp"
},
"amount": "180.00",
"description": "Shift 88123 pay",
"client_reference": "shift-88123"
}),
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://api.gravv.xyz/v1/transfer",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
"Content-Type": "application/json",
"Idempotency-Key": "e6ecc4de-197e-45e9-9a97-7140e2a3338e",
},
json={
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
},
"destination": {
"destination_type": "external_account",
"id": "3d813cbb-47fb-42ba-91df-831e1593ac29",
"rail": "rtp",
},
"amount": "180.00",
"description": "Shift 88123 pay",
"client_reference": "shift-88123",
},
)
print(response.status_code, response.json())
{
"data": {
"amount": "180",
"amount_currency": "USD",
"reference": "9c2e4a6b-8d0f-4b2c-9e4a-6c8e0a2b4d34",
"date_created": "2026-10-13T16:45:10.311Z",
"transfer_status": "pending",
"transaction_id": "4b6d8f0a-2c4e-4d6f-8a0c-4e6a8c0e2f45",
"local_amount": null,
"local_currency": null,
"fee": "0.5",
"fee_currency": "USD",
"requires_approval": false
},
"error": null
}
RTP has no note for the receiving bank. The API accepts payment_rail_reference but does not send it.
3. Follow it to the end
Read GET /v1/transactions/4b6d8f0a-2c4e-4d6f-8a0c-4e6a8c0e2f45 (it shows channel rtp), or wait for transfer.status.completed.
What can go wrong
| Response | Why | Fix |
|---|---|---|
400 routing number 021000021 cannot receive instant payments; use ach or wire: invalid input |
The receiving bank is not on the instant-payments network | Save the payee for ach or wire instead, with a new key |
400 transfers to US/USD are not supported at this time |
The source is wallet-backed. RTP pays only from bank-held USD accounts | Use ACH or wire, or pay from a bank-held account |
400 transfers from this account to US/USD are not supported at this time |
The payee was registered against another bank-held account | Save the RTP payee with internal_account_id set to the source |
400 this payee is registered for ach transfers, not rtp — … |
You saved the payee for ACH | Save an RTP payee. See two rails |
| Every error in the ACH recipe also applies to RTP |
Webhooks you will receive
You receive the same webhooks as for ACH: external_account.created, then external_account.active, then transfer.status.pending, then transfer.status.completed.
Testing in sandbox
Sandbox refuses every routing number tried for RTP so far. 021000021, 026009593, 121000248 and 011000028 all return cannot receive instant payments; use ach or wire, so you cannot save an RTP payee in sandbox yet. You can still test the refusal and your fallback to ACH or wire. POST /v2/accounts/simulate/deposit accepts "rail": "rtp" to fund an account.