Skip to content
Gave

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

reward.pending
{
  "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-typeapplication/json
x-gave-event-idThe event's id
x-gave-signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
  • Answer with any 2xx within 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 same id and 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:

verify-webhook.ts
/**
 * 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

TypeSent when
reward.estimatedAn authorization gives an estimate
reward.voidedThe authorization was reversed, or its clearing earned nothing
reward.awaiting_fundingA reward is earned, and waits for your vault to have enough free money
reward.pendingA reward is earned and reserved in your vault: its holding period starts
reward.claimableThe holding period is over: your user can claim it
reward.claimedA claim paid part or all of it
reward.reversedA refund reduced it before it was claimed
reward.clawed_backA refund after it was claimed: the amount is owed back
reward.rejectedGave refused it, with a rejection reason
vault.deposit_confirmedA deposit to your vault was recorded
vault.withdrawal_confirmedA withdrawal from your vault was recorded
program.budget_exhaustedA 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
EstimatedFrom an authorization, not earned yet
AwaitingFundingEarned, waiting for your vault to be funded
PendingReserved in your vault, in its holding period
ClaimableReady to claim
ClaimedPaid out
VoidedThe authorization never cleared
ReversedRefunded before it was claimed
ClawedBackRefunded after it was claimed
RejectedRefused by Gave