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.
- Gravv recorded it and sent
transfer.status.pending. - Gravv moved it through several steps and sent more
transfer.status.pendingevents. - Ada's bank paid her, and Gravv sent
transfer.status.completed. Your server returned 500, so your app still shows "sending". - 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.
6. Find the related rows
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:
- Several
transfer.status.pendingevents, one per step. - Possibly
transfer.requires_information, thentransfer.resumed. transfer.status.completedortransfer.status.failed.- 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.