Follow a payout to completion
Track every payout with webhooks and polling, reconcile with the transaction list, and handle failures, returns and manual review.
Track every bank payout with webhooks and polling, reconcile payouts with the transaction list, and handle failures, returns and manual review.
Who it is for. Every business that reconciles bank payouts.
Status. Available.
Money example
- You send 2,500.00 USD by ACH with a 1.50 USD fee.
- Usually the transfer ends
completed, and your ledger shows -2,501.50 USD. - Sometimes the payout comes back. Three days later, the receiving bank returns it because the account is closed.
transfer.status.returnedarrives, a refund row appears, and the money is back in the account. - Sometimes the bank refuses the payout before it is sent. The transfer ends
failed, and Gravv refunds the amount and the fee.
Before you start
- You have registered a webhook URL in the Dashboard, under
Manage, thenConfigure, thenWebhooks. The API has no route for this. The URL uses HTTPS, and your endpoint verifiesX-Signature, an HMAC-SHA256 of the raw body with your full secret. - You have the transfer
reference(the webhookevent_group_id) and thetransaction_idfrom the transfer response.
Find out what is required
You need nothing new. The status comes from the webhooks and the transaction.
Steps
1. Listen
For each delivery, do these steps:
- Verify the signature on the raw bytes.
- Deduplicate on the body's
event_id. Never deduplicate onX-Event-ID, because every event of one transfer shares it. - Return a 2xx within 30 seconds.
Order events by the number after : in idempotency_key (<reference>:<sequence>). Ignore an event whose number is lower than one you already applied.
2. Map the status
Read event_data.status:
| Status | What it means |
|---|---|
pending |
The payout is still moving. transfer_status names the internal step. Do not build logic on those names |
completed |
The payee was paid |
failed with transfer_status failed or rejected |
Final. Nothing was paid, and Gravv returns the amount and fee to the source |
failed with transfer_status manual_processing_required |
Not final. A person is checking the payout, and the transaction stays pending. Do not send it again, because a later event may still say completed |
paused |
The transfer is waiting for you. See Paused transfers |
returned |
The payout came back after completed. The original transaction stays completed, and Gravv adds a new transfer_refund row |
3. Poll as a backup
curl --request GET \
--url https://api.gravv.xyz/v1/transactions/8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01 \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/transactions/8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01", {
method: "GET",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os
import requests
response = requests.get(
"https://api.gravv.xyz/v1/transactions/8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
{
"data": {
"id": "8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01",
"customer_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"amount": "2500",
"date_created": "2026-10-13T09:15:22.9Z",
"direction": "out",
"transaction_type": "bank_account",
"description": "Contractor fee October",
"sending_account_id": "c56a4180-65aa-42ec-a945-5fd21dec0538",
"sending_account_name": "Acme Payroll Operating",
"sending_account_number": "4000123456",
"receiving_account_id": null,
"reference": "3f0c6a8e-6b1d-4c1e-9a55-0d2f7b9e4a11",
"tenant_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"channel": "ach",
"status": "completed",
"remark": null,
"client_reference": "payout-2026-10-0001",
"fee": "1.5",
"tx_hash": null,
"metadata": {
"recipient_name": "MAYA CHEN"
},
"external_id": null,
"balance_before": "10000",
"balance_after": "7498.5",
"currency": "USD",
"client_metadata": null
},
"error": null
}
A transaction's status is pending, paused, waiting_approval, completed or failed. An unknown id returns HTTP 400 transaction not found.
4. Reconcile
List completed bank payouts. Filters take comma-separated lists. The list has no date filter. The API sorts it newest first, so page until you pass your date, and stop on an empty page.
curl --request GET \
--url 'https://api.gravv.xyz/v1/transactions?customer_id=7c9e6679-7425-40de-944b-e07fc1f90ae7&channel=ach,wire,rtp&status=completed&page=1&items_per_page=50' \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/transactions?customer_id=7c9e6679-7425-40de-944b-e07fc1f90ae7&channel=ach,wire,rtp&status=completed&page=1&items_per_page=50", {
method: "GET",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os
import requests
response = requests.get(
"https://api.gravv.xyz/v1/transactions?customer_id=7c9e6679-7425-40de-944b-e07fc1f90ae7&channel=ach,wire,rtp&status=completed&page=1&items_per_page=50",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
GET /v1/transactions/volume sums amounts. Filter it by transaction_type and status, or it mixes currencies and directions. GET /v1/transactions/export (export_format=csv, lower case) returns only the 10 newest matching rows as a base64 file. Use the list for more rows.
5. Catch missed webhooks
List deliveries that failed:
curl --request GET \
--url 'https://api.gravv.xyz/v1/webhooks/history?event_type=transfer&status=failed' \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/webhooks/history?event_type=transfer&status=failed", {
method: "GET",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os
import requests
response = requests.get(
"https://api.gravv.xyz/v1/webhooks/history?event_type=transfer&status=failed",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
Each row has its own id. Use it with GET /v1/webhooks/event/{id} (attempts, payload and your endpoint's response) and GET /v1/webhooks/calls/{id} (each HTTP attempt). The row's event_id field holds the resource id, not the payload's event_id. To resend one delivery, send this request:
curl --request POST \
--url https://api.gravv.xyz/v1/webhooks/event/send/0d6c3a1e-5b7f-4e2a-9c8d-1f3e5a7b9c24 \
--header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/webhooks/event/send/0d6c3a1e-5b7f-4e2a-9c8d-1f3e5a7b9c24", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
},
});
console.log(response.status, await response.json());
import os
import requests
response = requests.post(
"https://api.gravv.xyz/v1/webhooks/event/send/0d6c3a1e-5b7f-4e2a-9c8d-1f3e5a7b9c24",
headers={
"Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
Gravv does not sign a resent delivery, so confirm it by reading the transaction.
What can go wrong
| What you see | Why | Fix |
|---|---|---|
transfer.status.failed with transfer_status manual_processing_required, transaction pending |
A person must review it, because the bank's answer was unclear or the route is held | Do not refund your customer or send again. Wait, or contact support with the reference |
A transfer pending for days |
The bank has not given a final answer. Gravv never fails a payout on time alone, and keeps checking it | Contact support with the reference if your customer is waiting |
transfer.status.returned days after completed |
The receiving bank returned it, for example because the account is closed or the details are wrong | Contact the payee. If their bank reported changed details, Gravv blocks the payee until you save a new one |
transfer.status.failed, transfer_status failed |
The bank refused the payout. Nothing was paid, and Gravv refunds the amount and fee | Fix the cause (often the payee's details) and send a new transfer |
| You receive the same event twice | Gravv delivers each event at least once | Deduplicate on event_id |
You dropped a completed event |
You deduplicated on X-Event-ID |
Deduplicate on the body's event_id |
| Events arrive out of order | Deliveries run in parallel | Use the number in idempotency_key |
| You cannot cancel a transfer | The API has no cancel route for transfers | Only a paused transfer ends on its own. Contact support for anything else |
| The transfer request itself returns a 5xx | The outcome is unknown | Before you send again, look the payout up. List the customer's newest transactions and match your client_reference |
400 create export writer: unknown export format: CSV |
You sent the format in upper case | Send csv |
Webhooks you will receive
Gravv sends these events:
transfer.status.pending, several times.- One of
transfer.status.completed,transfer.status.failedortransfer.requires_information. - Sometimes, later,
transfer.status.returned.
event_data.amount is a string with trailing zeros removed ("2500"). The event has no currency field. See Transfer events.
Testing in sandbox
In sandbox, Gravv checks unfinished payouts every minute. You cannot force a return or manual review. Use POST /v1/webhooks/event/send/{id} to send an event to your endpoint again.