---
title: "Follow a payout to completion"
description: "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](/platform/transfers/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

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url https://api.gravv.xyz/v1/transactions/8d4e2b6c-1f3a-4e7b-b0c9-5a6d7e8f9a01 \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

```json title="Response"
{
  "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.

<CodeGroup>
```bash title="cURL"
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>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

[`GET /v1/transactions/volume`](/api-reference/transactions/get-v1-transactions-volume) sums amounts. Filter it by `transaction_type` and `status`, or it mixes currencies and directions. [`GET /v1/transactions/export`](/api-reference/transactions/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:

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url 'https://api.gravv.xyz/v1/webhooks/history?event_type=transfer&status=failed' \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

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:

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/webhooks/event/send/0d6c3a1e-5b7f-4e2a-9c8d-1f3e5a7b9c24 \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

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](/platform/webhooks/event-catalogue#transfers).

## 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

- [Get a transaction](/api-reference/transactions/get-v1-transactions-transaction-id)
- [List transactions](/api-reference/transactions/get-v1-transactions)
- [Webhooks overview](/platform/webhooks/webhooks-overview)
