Trace a transfer and resend a webhook

Find a transfer's current state, every event Gravv sent about it in order, whether your server accepted each one, and resend the one it missed.

Use this recipe to find a transfer's current state and every event Gravv sent about it, in order. You then see whether your server accepted each event, and you resend the one it missed. It is for a support engineer at a remittance app who must answer "I sent 250 USD to Ada on Monday. Where is it?"

Status. Available.

Money example

On Monday at 09:15 you sent 250 USD to Ada.

  1. Gravv recorded it and sent transfer.status.pending.
  2. Gravv moved it through several steps and sent more transfer.status.pending events.
  3. Ada's bank paid her, and Gravv sent transfer.status.completed. Your server returned 500, so your app still shows "sending".
  4. You find that event, fix your server and resend it. Your app now shows "paid".

Before you start

You have the transaction_id or the transfer reference from POST /v1/transfer. If you only have your client_reference, list the customer's rows with GET /v1/transactions?customer_id=… and find it there.

Find out what is required

A transfer has three ids, and each route takes only one of them:

Id Where you got it Used for
transaction_id The POST /v1/transfer response GET /v1/transactions/{transaction_id}; the transaction_id filter on webhook history
Transfer reference The POST /v1/transfer response Matches event_data.reference and event_group_id on webhooks; POST /v1/transfer/{reference}/resume
Webhook record id items[].id from GET /v1/webhooks/history The event detail, its delivery attempts and a resend

Steps

1. Read the current state

curl --request GET \
 --url https://api.gravv.xyz/v1/transactions/1a2a73ed-562b-4f79-8374-59eef50bea63 \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/transactions/1a2a73ed-562b-4f79-8374-59eef50bea63", {
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/1a2a73ed-562b-4f79-8374-59eef50bea63",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())

Read status, remark, fee and metadata.local_amount. completed and failed are final.

2. List every event for it

event_type on webhook history is the category (transfer), not the full event name.

curl --request GET \
 --url 'https://api.gravv.xyz/v1/webhooks/history?transaction_id=1a2a73ed-562b-4f79-8374-59eef50bea63&event_type=transfer&limit=100' \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/webhooks/history?transaction_id=1a2a73ed-562b-4f79-8374-59eef50bea63&event_type=transfer&limit=100", {
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?transaction_id=1a2a73ed-562b-4f79-8374-59eef50bea63&event_type=transfer&limit=100",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
{
  "data": {
    "items": [
      {
        "id": "3139d32c-93cd-49bf-9c94-1cf0dc98d2c1",
        "transaction_id": "1a2a73ed-562b-4f79-8374-59eef50bea63",
        "event_type": "transfer",
        "status": "failed",
        "path": "/hooks/gravv",
        "http_method": "POST",
        "response_status_code": 500,
        "created_at": "2026-10-12T14:02:11.120453Z",
        "last_attempt": "2026-10-12T14:03:12.004417Z"
      },
      {
        "id": "ddd1dfb2-3b98-4ef8-9af6-1a26146d3f31",
        "transaction_id": "1a2a73ed-562b-4f79-8374-59eef50bea63",
        "event_type": "transfer",
        "status": "sent",
        "path": "/hooks/gravv",
        "http_method": "POST",
        "response_status_code": 200,
        "created_at": "2026-10-12T09:15:23.512004Z"
      }
    ],
    "items_per_page": 100,
    "page": 1,
    "total_items": 2,
    "total_pages": 1
  },
  "error": null
}

The history list does not include the event payload. status is pending, processing, sent, failed or retrying.

3. See what an event said

curl --request GET \
 --url https://api.gravv.xyz/v1/webhooks/event/3139d32c-93cd-49bf-9c94-1cf0dc98d2c1 \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/webhooks/event/3139d32c-93cd-49bf-9c94-1cf0dc98d2c1", {
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/event/3139d32c-93cd-49bf-9c94-1cf0dc98d2c1",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())

Read payload.event_type (here transfer.status.completed), payload.event_data.status, payload.event_data.reason, payload.idempotency_key, response_status_code, response_body, attempts, max_retries and next_attempt. On transfer events, payload.idempotency_key is <reference>:<sequence>, and a higher sequence means a later state.

4. See every delivery attempt

List the delivery attempts for the same webhook record id.

curl --request GET \
 --url https://api.gravv.xyz/v1/webhooks/calls/3139d32c-93cd-49bf-9c94-1cf0dc98d2c1 \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/webhooks/calls/3139d32c-93cd-49bf-9c94-1cf0dc98d2c1", {
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/calls/3139d32c-93cd-49bf-9c94-1cf0dc98d2c1",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
{
  "data": {
    "calls": [
      {
        "id": "fc377a4c-4a15-444d-85e7-ce8a3a578a8e",
        "attempt_number": 1,
        "request_url": "https://payroll.example.com/hooks/gravv",
        "response_status": 500,
        "status": "failed",
        "response_time_ms": 88,
        "created_at": "2026-10-12T14:02:41.330127Z",
        "error_message": null
      }
    ]
  },
  "error": null
}

5. Resend it once your server is fixed

A resend takes no Idempotency-Key and no body. A scoped key needs webhook:create.

curl --request POST \
 --url https://api.gravv.xyz/v1/webhooks/event/send/3139d32c-93cd-49bf-9c94-1cf0dc98d2c1 \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/webhooks/event/send/3139d32c-93cd-49bf-9c94-1cf0dc98d2c1", {
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/3139d32c-93cd-49bf-9c94-1cf0dc98d2c1",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
{
  "data": {
    "id": "3139d32c-93cd-49bf-9c94-1cf0dc98d2c1",
    "tenant_id": "bdd640fb-0667-4ad1-9c80-317fa3b1799d",
    "event_category": "transfer",
    "target_url": "https://payroll.example.com/hooks/gravv",
    "status_code": 200,
    "duration_ms": 142,
    "response_body": { "received": true }
  },
  "error": null
}

The API returns 200 even when your server returned 500. Read status_code, which is your server's response. A manual resend carries no X-Signature. If your endpoint rejects unsigned calls, confirm the state another way, for example by reading the transaction again with GET /v1/transactions/{transaction_id}. Or wait for the automatic retries, which are signed. If your server rejects a resend, the event goes back into automatic retry.

To find a refund, send GET /v1/transactions?customer_id=5be6128e-18c2-4797-a142-ea7d17be3111&transaction_type=transfer_refund and match reference. To find a fee, see Add up a month of fees.

What can go wrong

Response Why Fix
400 transaction not found The id is wrong, belongs to another business, or belongs to the other environment Check which key you used. Sandbox and live rows are separate
400 invalid transaction_id The id is not a UUID Use the transaction_id, not the reference
History with event_type=transfer.status.completed is empty The filter takes the category, not the event name Send event_type=transfer
No way to filter history by reference There is no such filter Filter by transaction_id
You deduplicated on X-Event-ID and lost the completed event X-Event-ID is the same for every event of one transfer Deduplicate on the body's event_id
Events arrive out of order Gravv delivers events concurrently Apply an event only when its idempotency_key sequence is higher than the last one you applied
400 event is already being processed on a resend An automatic attempt is running Wait, then try again
400 unsafe webhook url: webhook url must use https scheme Your webhook URL is http Register an https URL in the Dashboard
400 event not found The id is not a history record id. For example, it is the event detail's event_id Use items[].id from the history
400 invalid event ID format The id sent to the event detail route is not a UUID Use items[].id from the history
An empty history for a status filter An unknown status value returns nothing Use pending, processing, sent, failed or retrying
400 circuit breaker open for webhooks: circuit breaker is open Gravv's webhook service is failing Try again after about 30 seconds

Webhooks you will receive

For one transfer, Gravv sends events in this sequence order:

  1. Several transfer.status.pending events, one per step.
  2. Possibly transfer.requires_information, then transfer.resumed.
  3. transfer.status.completed or transfer.status.failed.
  4. Later, possibly transfer.status.returned.

Read event_data.reference, client_reference, status, reason, associated_transactions and idempotency_key. See Transfer events.

Testing in sandbox

Send a small sandbox transfer, point your sandbox webhook URL at an endpoint that returns 500, then run steps 2 to 5.

For example, Gravv sent 8 events for a 5 USD sandbox payout from a USDC wallet, with idempotency_key values <reference>:1 through <reference>:8. The first seven were transfer.status.pending, and transfer.status.completed followed about 2 minutes later. Each event has its own payload.event_id. The event detail's top-level event_id is the transfer reference, so it is the same for every event. A resend returned 200 with the receiver's own status_code, added one to attempts and moved the event back to retrying.

Reference

Was this page helpful?