Webhooks
Gave sends every change to your webhook endpoint as a signed POST: rewards as they move from estimate to payout, your vault's deposits and withdrawals, and programs running out of budget. For now, Gave sets up your endpoint and gives you its signing secret.
Delivery
{
"id": "ev:acme:alice:clearing:txn_0001:everyday:reward.pending",
"type": "reward.pending",
"createdAt": "2026-10-11T09:30:01.000Z",
"data": {
"reward": {
"id": "rw:acme:alice:clearing:txn_0001:everyday",
"userId": "alice",
"programId": "everyday",
"transaction": { "kind": "clearing", "externalTransactionId": "txn_0001" },
"state": "Pending",
"amount": { "asset": "USDC", "amount": "500000" },
"spend": { "currency": "USD", "amount": "5000" },
"claimableAt": "2026-11-10T09:30:00.000Z"
},
"amount": { "asset": "USDC", "amount": "500000" }
}
}| Header | |
|---|---|
content-type | application/json |
x-gave-event-id | The event's id |
x-gave-signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
- Answer with any
2xxwithin 10 seconds. Anything else, or no answer, is retried 30 times over about 24 hours, with a delay that doubles from 30 seconds up to an hour. - At least once: the same event can arrive more than once. Deduplicate on
id: a retried event has the sameidand the same body. - In any order: order a user's events by
createdAt, when the change happened.
Verify the signature
The signature is an HMAC-SHA256 of <t>.<raw body>, keyed with your signing secret. Check it against the raw body, before parsing it, and refuse a timestamp more than 5 minutes from your clock:
/**
* Checks a Gave webhook's `x-gave-signature` header against the raw request body.
* Uses Web Crypto: Node 20+, Workers, Deno and Bun.
*/
export async function verifyWebhook(
rawBody: string,
signatureHeader: string | null,
secret: string,
toleranceSeconds = 300
): Promise<boolean> {
const timestamps: string[] = []
const signatures: string[] = []
for (const part of (signatureHeader ?? "").split(",")) {
const [key, value = ""] = part.split("=")
if (key === "t") timestamps.push(value)
if (key === "v1") signatures.push(value)
}
const timestamp = timestamps[0]
if (timestamps.length !== 1 || timestamp === undefined || !/^\d+$/.test(timestamp)) return false
// Refuse old deliveries, so a captured one can't be replayed.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, [
"sign"
])
const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(`${timestamp}.${rawBody}`))
const expected = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
// While a secret is rotated, the header carries one v1 per secret: any of them may match.
return signatures.some((signature) => constantTimeEqual(signature, expected))
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
let difference = 0
for (let i = 0; i < a.length; i++) difference |= a.charCodeAt(i) ^ b.charCodeAt(i)
return difference === 0
}In your endpoint:
export default {
async fetch(request: Request, env: { GAVE_WEBHOOK_SECRET: string }) {
const body = await request.text()
const signature = request.headers.get("x-gave-signature")
if (!(await verifyWebhook(body, signature, env.GAVE_WEBHOOK_SECRET))) {
return new Response("Invalid signature", { status: 401 })
}
const event = JSON.parse(body)
// Skip an event.id you've already handled, then act on event.type.
return new Response(null, { status: 204 })
}
}Events
| Type | Sent when |
|---|---|
reward.estimated | An authorization gives an estimate |
reward.voided | The authorization was reversed, or its clearing earned nothing |
reward.awaiting_funding | A reward is earned, and waits for your vault to have enough free money |
reward.pending | A reward is earned and reserved in your vault: its holding period starts |
reward.claimable | The holding period is over: your user can claim it |
reward.claimed | A claim paid part or all of it |
reward.reversed | A refund reduced it before it was claimed |
reward.clawed_back | A refund after it was claimed: the amount is owed back |
reward.rejected | Gave refused it, with a rejection reason |
vault.deposit_confirmed | A deposit to your vault was recorded |
vault.withdrawal_confirmed | A withdrawal from your vault was recorded |
program.budget_exhausted | A reward used up what was left of a program's monthly budget |
Reward events carry data.reward, the reward as it stands now, and data.amount, the amount this change concerns, such as the part a refund reversed. Vault events carry the vaultId, the transaction's reference and the amount. program.budget_exhausted carries the programId, the month and the rewardId.
Reward states
data.reward.state is one of:
| State | |
|---|---|
Estimated | From an authorization, not earned yet |
AwaitingFunding | Earned, waiting for your vault to be funded |
Pending | Reserved in your vault, in its holding period |
Claimable | Ready to claim |
Claimed | Paid out |
Voided | The authorization never cleared |
Reversed | Refunded before it was claimed |
ClawedBack | Refunded after it was claimed |
Rejected | Refused by Gave |