---
title: "Set up webhooks and catch up on missed events"
description: "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}`](/api-reference/webhooks/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](/getting-started/authentication#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`:

```python title="verify.py"
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](/platform/webhooks/webhooks-overview#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.

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

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

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url https://api.gravv.xyz/v1/webhooks/event/f1b3d5e7-9a2c-4e6f-8b1d-3f5a7c9e1b2d \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

```json title="Response"
{
  "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](/recipes/transactions/trace-a-transfer-and-resend-a-webhook#4-see-every-delivery-attempt) 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](/platform/webhooks/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](/recipes/sandbox/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

- [Set up webhooks](/platform/webhooks/webhooks-overview)
- [Event catalogue](/platform/webhooks/event-catalogue)
- [Webhook history](/api-reference/webhooks/get-v1-webhooks-history)
- [Webhook event](/api-reference/webhooks/get-v1-webhooks-event-id)
- [Webhook delivery attempts](/api-reference/webhooks/get-v1-webhooks-calls-id)
- [Resend a webhook event](/api-reference/webhooks/post-v1-webhooks-event-send-id)
