Set up webhooks
Register your webhook URL, check each request's signature, handle retries, duplicates and order, and find events your server missed.
Gravv sends an HTTPS POST to your webhook URL each time an event happens, such as a transfer completing, a deposit landing, a card being frozen or a customer passing KYC. This page covers what every event has in common: how to register your URL, check signatures, handle retries, duplicates and order, and find the events your server missed. The event catalogue lists each event and its fields.
Example. Chidi's 50.00 USD deposit lands while your server is down for a release. Gravv's delivery of transfer.inflow.completed fails, and Gravv tries again later. Your server comes back after the last attempt, so the event ends failed. You list the failed transfer events, read the stored event, and credit Chidi the 50.00 USD once, because you keep the event_id of every event you have already processed.
Register your webhook URL
You register the URL in the Dashboard. There is no API for it. Follow the steps in Webhooks.
- One URL for each environment. Sandbox and live each have their own URL and secret, and every event type goes to that one URL. Events about sandbox activity go to the sandbox URL.
- A public
httpsURL. The Dashboard saves anhttpURL, but every delivery to it fails. Gravv refuses addresses on a private network. Do not redirect the URL, because a redirect turns the POST into a GET without a body. - The secret. The Dashboard generates a secret that starts with
whsec_. The whole value,whsec_included, is the signing key. Gravv signs requests with HMAC-SHA256, and you cannot choose another method. - Changes take up to 5 minutes. After you change the URL or the secret, some events can still go to the old URL or carry the old signature for up to 5 minutes. When you rotate the secret, accept a signature from either secret for at least 5 minutes, then drop the old one.
- No test button. To see real events, create activity in sandbox. For example, make a test deposit land.
Webhook payload structure
Every event has the same envelope. Only event_data changes with the event type.
{
"tenant_id": "dfa96ede-fc13-43cf-8328-8b910d1cd1d2",
"transaction_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"event_category": "transfer",
"event_type": "transfer.inflow.completed",
"event_group_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"timestamp": "2026-10-13T08:02:11Z",
"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": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
},
"event_id": "c0ffee00-1234-4abc-9def-001122334455",
"idempotency_key": "6c1f0a9e2b7d4c3a8e5f1b0d9c7a6e4f2d3b1a0c9e8f7d6c5b4a3f2e1d0c9b8a"
}
| Field | Type | Description |
|---|---|---|
event_id |
string | This event's own id. It stays the same on every retry and resend of the event, so deduplicate on it |
tenant_id |
string | Your business |
transaction_id |
string | The transaction the event is about, or "" when there is none |
event_category |
string | The category, such as transfer, cards or customer. Webhook history filters by it |
event_type |
string | The full type, such as transfer.inflow.completed |
event_group_id |
string | The same for every event about one thing: one transfer, deposit, card or customer. The catalogue says which id it is for each family |
timestamp |
string | When Gravv sent the event, in ISO 8601. It is not always the time of the change |
event_data |
object | The event's own fields. See the catalogue |
idempotency_key |
string | Only on some events. On transfer events it gives the order. See ordering |
Keys in event_data arrive in alphabetical order, and the characters <, > and & in strings arrive escaped as \u003c, \u003e and \u0026. Your JSON parser handles both. They matter only for the signature, which is why you check it on the bytes you received.
Each request carries these headers. Read header names without regard to case.
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Signature |
The signature. See below |
X-Event-ID |
The event's event_group_id, which every event about the same thing shares. It is not the event_id |
Verify webhook signatures
X-Signature is the lowercase hexadecimal HMAC-SHA256 of the exact request body, keyed with your whole webhook secret, whsec_ included. Check it before you act on an event:
- Read the raw request body bytes before any JSON parsing. Parsing and serializing again changes the bytes and breaks the signature.
- Compute the HMAC-SHA256 of those bytes with your whole secret, as lowercase hexadecimal.
- Compare it with
X-Signaturein constant time. - Process the event only if they match. Return
401when they do not.
const crypto = require("crypto");
const express = require("express");
const app = express();
// express.raw keeps the exact bytes Gravv sent.
app.post("/hooks/gravv", express.raw({ type: "*/*" }), (req, res) => {
const secret = process.env.GRAVV_WEBHOOK_SECRET; // the whole value, including "whsec_"
const received = Buffer.from(req.get("X-Signature") || "", "utf8");
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(req.body).digest("hex"),
"utf8",
);
// timingSafeEqual throws on buffers of different lengths, so compare the lengths first.
const ok = received.length === expected.length && crypto.timingSafeEqual(received, expected);
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// Skip event.event_id if you have processed it; otherwise store the event and queue it.
res.status(200).json({ received: true });
});
app.listen(3000);
import hashlib
import hmac
import json
import os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/hooks/gravv")
def gravv_hook():
secret = os.environ["GRAVV_WEBHOOK_SECRET"].encode() # the whole value, including "whsec_"
raw = request.get_data() # the exact bytes Gravv sent
expected = hmac.new(secret, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-Signature", "")
# compare_digest needs ASCII text, so reject anything else first.
if not received.isascii() or not hmac.compare_digest(expected, received):
abort(401)
event = json.loads(raw)
# Skip event["event_id"] if you have processed it; otherwise store the event and queue it.
return {"received": True}, 200
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func gravvHook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(r.Body) // the exact bytes Gravv sent
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
// The whole secret, including "whsec_".
mac := hmac.New(sha256.New, []byte(os.Getenv("GRAVV_WEBHOOK_SECRET")))
mac.Write(raw)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Signature"))) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// json.Unmarshal(raw, &event); skip event_id if you have processed it; store and queue it.
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/hooks/gravv", gravvHook)
http.ListenAndServe(":3000", nil)
}
The signature has no timestamp, so it does not stop someone from sending a captured request a second time. Deduplicate on event_id, and a replayed request has no effect.
Gravv does not sign a manual resend. An endpoint that checks signatures rejects it, so recover missed events by reading them instead.
Delivery, retries, and idempotency
Gravv treats any 2xx response as delivered. Any other status, a timeout or no response is a failed attempt. Gravv follows a redirect as a GET without the body, so keep the URL free of redirects.
- Respond within 30 seconds. Store the event, return
200, and do the work afterwards. Gravv keeps up to 64 KiB of your response, which you can read on the event route. Respond with a JSON object or an empty body. - Retries. Gravv retries a failed attempt, with growing gaps between attempts. The number of attempts can differ between sandbox and live. Read
max_retrieson Get webhook event. After the last attempt the event isfailed, and nothing retries it automatically. Read it or resend it, as described below. - No URL, or a URL that is not public
https. Every attempt fails before Gravv makes a call. The event recordsresponse_status_code0and lists no calls. - At least once. The same event can reach you more than once. Deduplicate on the body's
event_idand treat a repeat as done. Do not deduplicate onX-Event-ID. Every event about the same transfer, card or customer shares it, so you would drop real updates. - Any order. Gravv delivers events in parallel, and retries mix with new events. For transfer events,
idempotency_keyis<reference>:<sequence>. Apply an event only when its sequence is higher than the last one you applied. For other events, compare the status inevent_datawith the state you hold.timestampdoes not give the order. - Repeats of the same state. Gravv does not send an event that carries no
idempotency_keyand is identical to an earlier one for the same object. For example, when a card goesactive, thenfreeze, thenactiveagain, Gravv sends no secondactive. After a change you make yourself, read the object. - Poll as a backup. Webhooks tell you that something changed, but the API holds the current state. Poll the objects you are waiting on from time to time, so a missed event costs you minutes, not days.
Find and resend missed events
Four routes show what Gravv sent you, in the environment of the key you use. They need no Idempotency-Key.
| Route | Use it to |
|---|---|
GET /v1/webhooks/history |
List delivery records, newest first, and find the failed ones |
GET /v1/webhooks/event/{id} |
Read one record: the event as stored, the attempts, max_retries and your endpoint's last response |
GET /v1/webhooks/calls/{id} |
See each attempt: your status code, the time taken and any connection error |
POST /v1/webhooks/event/send/{id} |
Send one stored event to your current URL now |
Three different ids appear in these routes. Keep them apart:
| Id | Where you find it | What it is |
|---|---|---|
Record id |
items[].id in history, and id on the event route |
The delivery record. It is the {id} in the other three routes |
event_id on the event route |
GET /v1/webhooks/event/{id} |
The event_group_id, the same value as the X-Event-ID header |
payload.event_id |
Inside payload on the event route |
The event_id your endpoint received. Deduplicate on this one |
History filters. All filters are optional, and you can combine them:
event_typetakes the category, such astransfer(which includes deposits into accounts),inflow,collection,cards,customer,external_account,applications.accounts,accountorfeature_access. A full type such astransfer.inflow.completedreturns nothing.statusispending,processing,sent,failedorretrying. Any other value returns an empty list, not an error.start_dateandend_datetake2026-10-13T10:00:00Z,2026-10-13or2026-10-13T10:00:00. A date alone means 00:00 that day, soend_date=2026-10-13leaves out the 13th.transaction_idmatches the record's transaction. For a collection, that is the collection's id. For a transfer, it is the first id inassociated_transactions, not the transferreference.pagestarts at 1.limitis 50 by default and at most 100.
Read the event and process it. payload on the event route is the event as Gravv stored it. You fetched it with your API key, so you can process it directly. Deduplicate on payload.event_id. Its keys are in a different order from the request Gravv sent you, so you cannot check a signature against it.
Or resend it. POST /v1/webhooks/event/send/{id} posts the stored event to your current URL and returns your endpoint's result. It takes no body. A scoped API key needs the webhook:create permission. The resend:
- is not signed, and carries
Content-TypeandX-Event-IDonly; - returns HTTP
200even when your endpoint refused it, so readstatus_codein the response; - puts the event back into automatic, signed retries when your endpoint refuses it;
- sends the event again on every call.
Trace a transfer and resend a webhook and Set up webhooks and catch up on missed events walk through these routes with full requests.
Errors. These routes return errors as HTTP 400 with {"data":null,"error":"<message>"}, including for an id that does not exist. A missing or wrong API key still gets 401, and too many requests get 429.
| Message | Why | Fix |
|---|---|---|
invalid event ID format or invalid event ID |
The {id} is not a UUID |
Use items[].id from history |
event not found, or event not found or not owned by tenant |
No such record for your business, or it is in the other environment | Check the id, and use a key for the same environment |
event is already being processed |
Gravv is delivering it right now | Wait a moment and try again |
no webhook URL configured for tenant <id> category <category> |
No URL is set | Set the URL in the Dashboard |
unsafe webhook url: webhook url must use https scheme |
The URL is http, or private |
Register a public https URL |
webhook delivery failed: <error> |
Your endpoint did not respond within 30 seconds, or refused the connection | Fix the endpoint. Gravv schedules an automatic retry |
failed to process event data |
Your endpoint's last response was JSON but not an object | Respond with a JSON object or an empty body |
circuit breaker open for webhooks: circuit breaker is open |
Gravv's webhook service is failing briefly | Try again after about 30 seconds |
A scoped key without webhook:create gets 403 on the resend, with required_any ["webhook:create"].
Event types
- Event catalogue: every event Gravv sends, with its fields and an example.