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 https URL. The Dashboard saves an http URL, 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:

  1. Read the raw request body bytes before any JSON parsing. Parsing and serializing again changes the bytes and breaks the signature.
  2. Compute the HMAC-SHA256 of those bytes with your whole secret, as lowercase hexadecimal.
  3. Compare it with X-Signature in constant time.
  4. Process the event only if they match. Return 401 when 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_retries on Get webhook event. After the last attempt the event is failed, 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 records response_status_code 0 and lists no calls.
  • At least once. The same event can reach you more than once. Deduplicate on the body's event_id and treat a repeat as done. Do not deduplicate on X-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_key is <reference>:<sequence>. Apply an event only when its sequence is higher than the last one you applied. For other events, compare the status in event_data with the state you hold. timestamp does not give the order.
  • Repeats of the same state. Gravv does not send an event that carries no idempotency_key and is identical to an earlier one for the same object. For example, when a card goes active, then freeze, then active again, Gravv sends no second active. 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_type takes the category, such as transfer (which includes deposits into accounts), inflow, collection, cards, customer, external_account, applications.accounts, account or feature_access. A full type such as transfer.inflow.completed returns nothing.
  • status is pending, processing, sent, failed or retrying. Any other value returns an empty list, not an error.
  • start_date and end_date 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.
  • transaction_id matches the record's transaction. For a collection, that is the collection's id. For a transfer, it is the first id in associated_transactions, not the transfer reference.
  • page starts at 1. limit is 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-Type and X-Event-ID only;
  • returns HTTP 200 even when your endpoint refused it, so read status_code in 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.
Was this page helpful?