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

  1. You send 2,500.00 USD by ACH with a 1.50 USD fee.
  2. Usually the transfer ends completed, and your ledger shows -2,501.50 USD.
  3. Sometimes the payout comes back. Three days later, the receiving bank returns it because the account is closed. transfer.status.returned arrives, a refund row appears, and the money is back in the account.
  4. 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, then Configure, then Webhooks. The API has no route for this. The URL uses HTTPS, and your endpoint verifies X-Signature, an HMAC-SHA256 of the raw body with your full secret.
  • You have the transfer reference (the webhook event_group_id) and the transaction_id from 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:

  1. Verify the signature on the raw bytes.
  2. Deduplicate on the body's event_id. Never deduplicate on X-Event-ID, because every event of one transfer shares it.
  3. 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:

  1. transfer.status.pending, several times.
  2. One of transfer.status.completed, transfer.status.failed or transfer.requires_information.
  3. 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.

Reference

Was this page helpful?