Set up webhooks and catch up on missed events

Register a webhook URL, check every request's signature, and find and process the events your server missed while it was down.

This recipe registers a webhook URL, checks every request's signature, and finds and processes the events your server missed while it was down. The example is a savings app whose server was down for an hour during a release.

Status. Available.

Money example

  1. Chidi's 50.00 USD deposit lands in his savings account while your server is down. Gravv's delivery of transfer.inflow.completed fails, and Gravv schedules a retry for later.
  2. Your server is back. You do not want Chidi to wait for the retry, so you list the transfer events that are still retrying or have failed, and read the stored event.
  3. You credit Chidi 50.00 USD and keep the event's event_id. When Gravv's retry arrives later, you see that event_id again, respond with 200 and credit nothing.

Before you start

  • A public https URL that responds with 2xx within 30 seconds. It must not redirect, and it must not be on a private network.
  • A place to store the event_id of every event you have processed, such as a table with a unique key.
  • Your API key, for the same environment as the webhook URL.

Find out what is required

  • The Dashboard's webhook form is the only way to register a URL.
  • max_retries on GET /v1/webhooks/event/{id} tells you how many attempts Gravv makes in your environment. It can differ between sandbox and live, so do not hard-code it.

Steps

1. Register your URL

In the Dashboard, go to the webhook settings (see Webhooks), enter the URL and generate the secret. Keep the whole secret, whsec_ included.

  • Register one URL for each environment. You register sandbox and live separately, and every event category goes to that one URL.
  • A change can take up to 5 minutes to apply. When you rotate the secret, accept either secret for at least 5 minutes.

2. Check every request

Compute the HMAC-SHA256 of the raw body bytes with the whole secret as the key, and compare it in constant time with the lower-case hex in X-Signature:

import hashlib
import hmac
import os


def is_from_gravv(raw: bytes, header: str) -> bool:
    secret = os.environ["GRAVV_WEBHOOK_SECRET"].encode()  # the whole value, including "whsec_"
    expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
    header = header or ""
    # compare_digest needs ASCII text, so reject anything else first.
    return header.isascii() and hmac.compare_digest(expected, header)

Full Node, Python and Go handlers are on Verify webhook signatures.

3. Store first, answer fast

For each request that passes the check:

  1. If you already hold its event_id, respond with 200 and stop.
  2. Store the event and its event_id, then respond with 200.
  3. Do the work, such as crediting Chidi, in the background.

Do not deduplicate on the X-Event-ID header. It is the same for every event about one deposit, transfer or customer, so deduplicating on it drops real updates.

4. Find what you missed

List the events of the category that are still retrying, then those that are failed. event_type takes the category, and deposits into accounts are in the transfer category.

curl --request GET \
 --url 'https://api.gravv.xyz/v1/webhooks/history?event_type=transfer&status=retrying&start_date=2026-10-13T10:00:00Z&limit=100' \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/webhooks/history?event_type=transfer&status=retrying&start_date=2026-10-13T10:00:00Z&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?event_type=transfer&status=retrying&start_date=2026-10-13T10:00:00Z&limit=100",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
{
  "data": {
    "items": [
      {
        "id": "f1b3d5e7-9a2c-4e6f-8b1d-3f5a7c9e1b2d",
        "transaction_id": "8d2f4b6a-1c3e-4a5b-9e7f-0a2c4e6b8d1f",
        "event_type": "transfer",
        "status": "retrying",
        "path": "/hooks/gravv",
        "http_method": "POST",
        "response_status_code": 502,
        "created_at": "2026-10-13T11:41:40.120331Z",
        "last_attempt": "2026-10-13T11:42:41.002417Z"
      }
    ],
    "items_per_page": 100,
    "page": 1,
    "total_items": 1,
    "total_pages": 1
  },
  "error": null
}
  • status takes one value per call. The values are pending, processing, sent, failed and retrying. Any other value returns an empty list, not an error.
  • Dates take 2026-10-13T10:00:00Z, 2026-10-13 or 2026-10-13T10:00:00. A date alone means 00:00 that day, so end_date=2026-10-13 leaves out the 13th.
  • page starts at 1, and limit is at most 100. Read total_pages and fetch every page.
  • items[].id is the id of the delivery record. You use it in the next step. It is not the event_id your endpoint receives.

5. Read the stored event and process it

curl --request GET \
 --url https://api.gravv.xyz/v1/webhooks/event/f1b3d5e7-9a2c-4e6f-8b1d-3f5a7c9e1b2d \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/webhooks/event/f1b3d5e7-9a2c-4e6f-8b1d-3f5a7c9e1b2d", {
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/f1b3d5e7-9a2c-4e6f-8b1d-3f5a7c9e1b2d",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
{
  "data": {
    "id": "f1b3d5e7-9a2c-4e6f-8b1d-3f5a7c9e1b2d",
    "transaction_id": "8d2f4b6a-1c3e-4a5b-9e7f-0a2c4e6b8d1f",
    "event_id": "8d2f4b6a-1c3e-4a5b-9e7f-0a2c4e6b8d1f",
    "event_type": "transfer",
    "status": "retrying",
    "path": "/hooks/gravv",
    "http_method": "POST",
    "payload": {
      "event_category": "transfer",
      "event_data": {
        "account_id": "4f3e2d1c-0b9a-4876-9543-210fedcba987",
        "amount": "50",
        "currency": "USD",
        "narration": "Savings top-up",
        "sender_account_number": "0123456789",
        "sender_bank": "Example Bank",
        "sender_name": "Chidi Okafor",
        "session_id": "",
        "status": "completed",
        "transaction_id": "8d2f4b6a-1c3e-4a5b-9e7f-0a2c4e6b8d1f"
      },
      "event_group_id": "8d2f4b6a-1c3e-4a5b-9e7f-0a2c4e6b8d1f",
      "event_id": "4e6a8c0b-2d4f-4b6a-8c1e-3f5a7b9d1c2e",
      "event_type": "transfer.inflow.completed",
      "idempotency_key": "3b9e1c7a5d2f8e4b6a0c9d1f3e5b7a9c2d4f6e8a0b1c3d5e7f9a2b4c6d8e0f1a",
      "tenant_id": "dfa96ede-fc13-43cf-8328-8b910d1cd1d2",
      "timestamp": "2026-10-13T11:41:37Z",
      "transaction_id": "8d2f4b6a-1c3e-4a5b-9e7f-0a2c4e6b8d1f"
    },
    "response_status_code": 502,
    "response_body": {
      "error": "Invalid JSON response",
      "raw": "<html>Bad Gateway</html>"
    },
    "attempts": 3,
    "max_retries": 50,
    "last_attempt": "2026-10-13T11:42:41.002417Z",
    "next_attempt": "2026-10-13T11:43:21.002417Z",
    "created_at": "2026-10-13T11:41:40.120331Z",
    "updated_at": "2026-10-13T11:42:41.002417Z"
  },
  "error": null
}
  • payload is the event Gravv sent to your endpoint. Process it as if it had arrived, and deduplicate on payload.event_id (4e6a8c0b-…).
  • The top-level event_id is the event_group_id, the same value as the X-Event-ID header. For a deposit, it is the deposit's transaction_id. Do not deduplicate on it.
  • You fetched payload with your API key, so you do not check a signature on it. You could not check it anyway, because its keys are in a different order from the request Gravv sent.
  • response_body is your endpoint's last response. Gravv wraps a response that was not JSON, such as an HTML error page, as {"error":"Invalid JSON response","raw":"…"}.
  • attempts counts failed attempts. When the event is retrying, Gravv sends it again at next_attempt, signed, and your step 3 check skips it.

6. Optional: see each attempt, or resend

Trace a transfer and resend a webhook shows GET /v1/webhooks/calls/{id} and the resend. Gravv does not sign a resend, so an endpoint that checks signatures, as in step 2, rejects it. Reading the stored event, as in step 5, is the safer way to catch up.

What can go wrong

Response Why Fix
Every delivery fails, response_status_code is 0 and the history lists no attempts The URL is http, on a private network, or not set, so Gravv made no call Register a public https URL
The signature never matches You used the secret without whsec_, or you parsed and serialized the body again before hashing Hash the raw bytes with the whole secret
Some updates seem lost You deduplicate on X-Event-ID Deduplicate on the body's event_id
Events arrive out of order Gravv delivers in parallel, and retries mix with new events For transfers, apply only a higher sequence in idempotency_key. For others, compare the status with what you hold
Two webhooks for one bank deposit into an account that settles on-chain transfer.inflow.deposit_settled and inflow.onchain.completed describe the same money Link them by settlement_event_group_id and credit once
History returns nothing for event_type=transfer.inflow.completed event_type takes the category, not the event name Use event_type=transfer
400 invalid event ID format or event not found The id is not a history items[].id, or it is from the other environment Use items[].id, with a key for the same environment
400 failed to process event data on the event Your endpoint responded with JSON that is not an object Respond with a JSON object or an empty body
400 circuit breaker open for webhooks: circuit breaker is open Gravv's webhook service is failing briefly Try again after about 30 seconds

Webhooks you will receive

Gravv sends every category to your one URL. For deposits, it sends transfer.inflow.* for bank deposits into accounts and inflow.onchain.* for stablecoins. The event catalogue lists every type.

Testing in sandbox

  1. Register a sandbox URL in the Dashboard, with a sandbox key selected, and make it respond with 500 for a while.
  2. Make a test deposit land on a sandbox NGN account, as in Make a test deposit land. USD accounts do not open in sandbox today, but the steps are the same.
  3. Run steps 4 and 5 with your sandbox key, then make your endpoint respond with 200. Watch the retry arrive and your step 3 check skip it.

Sandbox stores and delivers events within seconds, and the first retries follow within a minute or two. Use sandbox keys for the history routes, because a live key does not see sandbox events.

Reference

Was this page helpful?