Pay a euro account by SEPA
Check an IBAN, save a SEPA payee and send a EUR payout from a wallet-backed account, converted at the current rate.
Use this recipe to check an IBAN, save a SEPA payee and send a EUR payout from a wallet-backed account. Gravv converts the amount at the current rate.
Who it is for. A US software company that pays a freelance designer's IBAN in Germany.
Status. Available from wallet-backed accounts to the 33 SEPA countries, if Gravv has enabled SEPA payouts for your business. Not supported from bank-held USD accounts.
Money example
- The company's wallet-backed USD account holds 5,000.00 USD.
- The company sends 1,000.00 USD to Lena Fischer's IBAN by SEPA.
- At an example rate of 0.885 EUR per USD, the response shows 885.00 EUR for Lena in
local_amount. Gravv debits the account 1,000.00 USD plus the fee on your plan.
Before you start
- A wallet-backed source account that is
activeand funded. Bank-held USD accounts cannot pay by SEPA. - SEPA payouts enabled for your business. If the first transfer returns
transfers to DE/EUR are not supported at this time, contact support. - The recipient's IBAN, full name and address, and whether it is a person or a company.
Find out what is required
- Payee rules. Set
payee_typetosepaandcurrencytoEUR, the only currency allowed. Sendiban,account_owner_type,bank_nameandaddress. Gravv verifies the IBAN's check digits.bicis optional, and Gravv checks its format when you send it. The IBAN's country must be a SEPA country, and Gravv takes the payee's country from the IBAN. - IBAN format.
POST /v1/external-accounts/verifychecks the format and returnsverification_sourcelocal. It does not confirm who holds the account. - Indicative rate. Call
GET /v1/transfer/rates?currency=EURand read the item withdirectionUSD_EUR. Match ondirection, not oncurrency_pair. The rate is not locked. Gravv prices the transfer when you send it, andlocal_amountin the transfer response is the amount priced for the recipient.
Steps
1. Check the IBAN (optional)
curl --request POST \
--url https://api.gravv.xyz/v1/external-accounts/verify \
--header 'Api-Key: <API_KEY>' \
--header 'Content-Type: application/json' \
--data '
{
"iban": "DE89370400440532013000"
}
'
const response = await fetch("https://api.gravv.xyz/v1/external-accounts/verify", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
"iban": "DE89370400440532013000"
}),
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://api.gravv.xyz/v1/external-accounts/verify",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
"Content-Type": "application/json",
},
json={
"iban": "DE89370400440532013000",
},
)
print(response.status_code, response.json())
{
"data": {
"verified": true,
"verification_source": "local"
},
"error": null
}
If a check digit is wrong, the response has verified: false and a message, such as IBAN checksum invalid (mod 97 = 28, expected 1).
2. Save the SEPA 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: fc5b1691-8679-46d8-b1bf-ae8a8109cc1a' \
--data '
{
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payee_type": "sepa",
"currency": "EUR",
"account_name": "Lena Fischer",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"account_owner_type": "individual",
"bank_name": "Commerzbank",
"address": {
"address_line1": "Torstrasse 101",
"city": "Berlin",
"postal_code": "10119",
"country": "DE"
}
}
'
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": "fc5b1691-8679-46d8-b1bf-ae8a8109cc1a",
},
body: JSON.stringify({
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payee_type": "sepa",
"currency": "EUR",
"account_name": "Lena Fischer",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"account_owner_type": "individual",
"bank_name": "Commerzbank",
"address": {
"address_line1": "Torstrasse 101",
"city": "Berlin",
"postal_code": "10119",
"country": "DE"
}
}),
});
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": "fc5b1691-8679-46d8-b1bf-ae8a8109cc1a",
},
json={
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"payee_type": "sepa",
"currency": "EUR",
"account_name": "Lena Fischer",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"account_owner_type": "individual",
"bank_name": "Commerzbank",
"address": {
"address_line1": "Torstrasse 101",
"city": "Berlin",
"postal_code": "10119",
"country": "DE",
},
},
)
print(response.status_code, response.json())
{
"data": {
"account_name": "LENA FISCHER",
"account_owner_type": "individual",
"address": {
"address_line1": "Torstrasse 101",
"city": "Berlin",
"country": "DE",
"postal_code": "10119"
},
"bic": "COBADEFFXXX",
"currency": "EUR",
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"date_created": "2026-10-13T22:41:24.032709Z",
"iban": "DE89370400440532013000",
"id": "0f8fad5b-d9cb-469f-a165-70867728950e",
"institution_country_iso_code": "DE",
"institution_name": "Commerzbank",
"payee_type": "sepa",
"status": "pending"
},
"error": null
}
The payee starts as pending. Wait for the external_account.active webhook before you send the payout.
3. Send the payout
Send amount in the source account's currency.
curl --request POST \
--url https://api.gravv.xyz/v1/transfer \
--header 'Api-Key: <API_KEY>' \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: 0f1a9b6f-5e85-427f-b824-5cb46e698d30' \
--data '
{
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "9b2d6f0e-3c1a-4e8b-a7d5-2f4c6e8a0b13"
},
"destination": {
"destination_type": "external_account",
"id": "0f8fad5b-d9cb-469f-a165-70867728950e",
"rail": "sepa"
},
"amount": "1000.00",
"description": "Design work October",
"client_reference": "sepa-lf-1013"
}
'
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": "0f1a9b6f-5e85-427f-b824-5cb46e698d30",
},
body: JSON.stringify({
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "9b2d6f0e-3c1a-4e8b-a7d5-2f4c6e8a0b13"
},
"destination": {
"destination_type": "external_account",
"id": "0f8fad5b-d9cb-469f-a165-70867728950e",
"rail": "sepa"
},
"amount": "1000.00",
"description": "Design work October",
"client_reference": "sepa-lf-1013"
}),
});
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": "0f1a9b6f-5e85-427f-b824-5cb46e698d30",
},
json={
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": {
"source_type": "internal_account",
"id": "9b2d6f0e-3c1a-4e8b-a7d5-2f4c6e8a0b13",
},
"destination": {
"destination_type": "external_account",
"id": "0f8fad5b-d9cb-469f-a165-70867728950e",
"rail": "sepa",
},
"amount": "1000.00",
"description": "Design work October",
"client_reference": "sepa-lf-1013",
},
)
print(response.status_code, response.json())
{
"data": {
"amount": "1000",
"amount_currency": "USD",
"reference": "4b7e9d2a-1c3f-4a5b-9e8d-7c6b5a4f3e21",
"date_created": "2026-10-13T11:00:00.000Z",
"transfer_status": "pending",
"transaction_id": "2f9a8b7c-6d5e-4f3a-8b2c-1d0e9f8a7b6c",
"local_amount": "885",
"local_currency": "EUR",
"fee": "2",
"fee_currency": "USD",
"requires_approval": false
},
"error": null
}
local_amount and local_currency are the amount priced for Lena when Gravv accepted the transfer. If a rate looks wrong (for example exactly 1), stop and contact support before you send more.
4. Follow it to the end
The transaction shows transaction_type global_remittance and channel sepa. The webhooks carry workflow_type sepa_transfer.
What can go wrong
| Response | Why | Fix |
|---|---|---|
400 IBAN checksum invalid (mod 97 = N, expected 1): invalid input or invalid IBAN format |
The IBAN has a typo | Check the IBAN again |
400 iban required but missing for sepa payee: invalid input |
The request has no iban |
Add iban |
400 USD not supported for sepa external accounts: invalid input |
SEPA payees are EUR only | Send "currency": "EUR" |
400 required but missing for swift payee: … |
The IBAN is outside SEPA and the request had no payee_type, so Gravv treated the payee as SWIFT |
To pay USD abroad, use SWIFT. Gravv does not support EUR outside SEPA |
400 this account can only send USD: the recipient must receive USD |
The source is a bank-held USD account | Pay from a wallet-backed account |
400 the selected source account cannot be used for an international transfer |
The source account cannot pay abroad | Contact support |
400 transfers to DE/EUR are not supported at this time |
Gravv has not enabled that country for your business | Contact support |
400 transfer rail "sepa" is not configured |
SEPA is not set up in this environment | Contact support |
400 FX rate unavailable for currency conversion |
No rate is available right now | Retry later with a new key. Gravv replays the 400 for 24 hours under the old key |
external_account.setup_failed with error_message these account details could not be accepted |
The details were refused | Fix the payee |
transfer.status.failed with event_data.transfer_status manual_processing_required |
A person is checking the payout | Do not send it again. Contact support with the reference |
Webhooks you will receive
You receive external_account.created, then external_account.active, then several transfer.status.pending events, then transfer.status.completed. Transfer webhooks carry no currency field, so read the amount and currency from the transaction.
Testing in sandbox
Sandbox simulates SEPA payee setup, and the payee becomes active within seconds. verify returns verified: true for DE89370400440532013000 and verified: false for DE89370400440532013001. Sandbox may not offer a wallet-backed source account for every currency. Where it does, fund one with POST /v2/accounts/simulate/deposit.