Receive webhooks
Create a signed webhook endpoint, verify deliveries in your language of choice, and fetch the full record from the read API.
Webhooks tell your integration when something happens in an organization, without polling. Givebear POSTs a small signed payload to your URL. You verify the signature, read data.id, then fetch the full record from the read API.
Payloads are deliberately thin: they carry the event type, an event id, and the affected resource id only. This keeps donor data out of the delivery pipe.
Before you start
Create an endpoint
Create one endpoint, choose the events you want, and store the signing secret. Givebear returns the secret exactly once.
Open your dashboard and go to Developers -> Webhooks. Add an endpoint, paste your URL, and select the events to receive. Copy the signing secret when it appears. It starts with whsec_.
Create an endpoint with the webhooks:write scope. The secret field is returned only on the create call.
curl -X POST https://givebear.io/api/v1/webhooks \
-H "Authorization: Bearer gb_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/givebear",
"events": ["donation.created", "payout.paid"]
}'import { GivebearConnect } from "@givebear/connect";
const gb = new GivebearConnect({ token: "gb_live_..." });
const endpoint = await gb.webhooks.create({
url: "https://example.com/webhooks/givebear",
events: ["donation.created", "payout.paid"],
});
// Store endpoint.secret now: it is returned only here.import requests
endpoint = requests.post(
"https://givebear.io/api/v1/webhooks",
headers={"Authorization": "Bearer gb_live_..."},
json={
"url": "https://example.com/webhooks/givebear",
"events": ["donation.created", "payout.paid"],
},
).json()
# Store endpoint["secret"] now: it is returned only here.<?php
$ch = curl_init("https://givebear.io/api/v1/webhooks");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer gb_live_...",
"Content-Type: application/json",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"url" => "https://example.com/webhooks/givebear",
"events" => ["donation.created", "payout.paid"],
]));
$endpoint = json_decode(curl_exec($ch), true);
curl_close($ch);
// Store $endpoint["secret"] now: it is returned only here.require "net/http"
require "json"
uri = URI("https://givebear.io/api/v1/webhooks")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer gb_live_..."
request["Content-Type"] = "application/json"
request.body = {
url: "https://example.com/webhooks/givebear",
events: ["donation.created", "payout.paid"],
}.to_json
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
endpoint = JSON.parse(response.body)
# Store endpoint["secret"] now: it is returned only here.body := strings.NewReader(`{
"url": "https://example.com/webhooks/givebear",
"events": ["donation.created", "payout.paid"]
}`)
req, _ := http.NewRequest("POST", "https://givebear.io/api/v1/webhooks", body)
req.Header.Set("Authorization", "Bearer gb_live_...")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
// Store the returned secret now: it is returned only here.{
"id": "...",
"object": "webhook_endpoint",
"url": "https://example.com/webhooks/givebear",
"description": null,
"events": ["donation.created", "payout.paid"],
"created_at": "2026-06-01T18:30:00.000Z",
"secret": "whsec_..."
}The signing secret (whsec_...) is shown once. Store it in an environment variable or a secrets manager. If you lose it, delete the endpoint and create a new one.
The event payload
Every delivery has the same thin shape. Use data.id to fetch the full record.
Prop
Type
{
"id": "8f3c2b1a-6d4e-4a90-9b2c-1f0e7d5c4b3a",
"type": "donation.created",
"created": "2026-06-01T18:30:00.000Z",
"data": { "id": "..." }
}Webhook headers
Each POST carries these headers.
| Header | Value |
|---|---|
x-givebear-event | The event type, for example donation.created |
x-givebear-id | The event id (matches the body id) |
x-givebear-signature | t=<unix_seconds>,v1=<hex_hmac> |
content-type | application/json |
user-agent | Givebear-Connect/1.0 |
For 24 hours after a secret rotation the signature header carries two v1 entries, one per secret. Verification passes when any v1 matches, which verifyWebhook handles for you.
Verify the signature
Always verify before trusting a delivery. The v1 value is HMAC-SHA256 over <t>.<raw_body>, using your endpoint's signing secret, encoded as hex.
In JavaScript, the @givebear/connect client does the work: it parses the header, recomputes the HMAC, compares in constant time, and rejects stale timestamps. In any other language it is about ten lines with the standard library, shown below. Reject deliveries whose timestamp is more than 300 seconds old.
Pass the raw request body exactly as received. Do not parse and re-serialize the JSON first, or the signature will not match.
# curl cannot receive webhooks, but it can send a correctly signed test
# delivery to YOUR endpoint while you build the handler:
body='{"id":"evt_test","type":"donation.created","created":"2026-01-01T00:00:00Z","data":{"id":"don_123"}}'
t=$(date +%s)
v1=$(printf '%s.%s' "$t" "$body" \
| openssl dgst -sha256 -hmac "$GIVEBEAR_WEBHOOK_SECRET" -hex \
| sed 's/^.* //')
curl -X POST https://example.com/webhooks/givebear \
-H "Content-Type: application/json" \
-H "x-givebear-signature: t=$t,v1=$v1" \
-d "$body"import { verifyWebhook, WebhookVerificationError } from "@givebear/connect";
export async function POST(req: Request) {
const body = await req.text(); // raw body, unparsed
const signature = req.headers.get("x-givebear-signature") ?? "";
try {
const event = await verifyWebhook({
secret: process.env.GIVEBEAR_WEBHOOK_SECRET!,
signature,
body,
});
// event.type, event.data.id
return new Response(null, { status: 200 });
} catch (err) {
if (err instanceof WebhookVerificationError) {
return new Response("invalid signature", { status: 400 });
}
throw err;
}
}import hashlib
import hmac
import json
import os
import time
def verify_webhook(signature_header: str, body: bytes) -> dict:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t = int(parts["t"])
if abs(time.time() - t) > 300:
raise ValueError("timestamp outside tolerance window")
secret = os.environ["GIVEBEAR_WEBHOOK_SECRET"].encode()
expected = hmac.new(
secret, f"{t}.".encode() + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, parts["v1"]):
raise ValueError("signature mismatch")
return json.loads(body)<?php
function verify_webhook(string $signatureHeader, string $body): array
{
$parts = [];
foreach (explode(",", $signatureHeader) as $pair) {
[$k, $v] = explode("=", $pair, 2);
$parts[$k] = $v;
}
$t = (int) $parts["t"];
if (abs(time() - $t) > 300) {
throw new Exception("timestamp outside tolerance window");
}
$secret = getenv("GIVEBEAR_WEBHOOK_SECRET");
$expected = hash_hmac("sha256", "$t.$body", $secret);
if (!hash_equals($expected, $parts["v1"])) {
throw new Exception("signature mismatch");
}
return json_decode($body, true);
}require "json"
require "openssl"
def verify_webhook(signature_header, body)
parts = signature_header.split(",").to_h { |pair| pair.split("=", 2) }
t = Integer(parts["t"])
raise "timestamp outside tolerance window" if (Time.now.to_i - t).abs > 300
secret = ENV.fetch("GIVEBEAR_WEBHOOK_SECRET")
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{body}")
raise "signature mismatch" unless OpenSSL.secure_compare(expected, parts["v1"])
JSON.parse(body)
endfunc verifyWebhook(signatureHeader string, body []byte) (map[string]any, error) {
var t int64
var v1 string
for _, part := range strings.Split(signatureHeader, ",") {
k, v, _ := strings.Cut(part, "=")
switch k {
case "t":
t, _ = strconv.ParseInt(v, 10, 64)
case "v1":
v1 = v
}
}
if d := time.Now().Unix() - t; d > 300 || d < -300 {
return nil, errors.New("timestamp outside tolerance window")
}
mac := hmac.New(sha256.New, []byte(os.Getenv("GIVEBEAR_WEBHOOK_SECRET")))
fmt.Fprintf(mac, "%d.", t)
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(v1)) {
return nil, errors.New("signature mismatch")
}
var event map[string]any
err := json.Unmarshal(body, &event)
return event, err
}In JavaScript, verifyWebhook applies the 300-second window for you. Override it with toleranceSeconds, or set it to 0 to disable the check:
const event = await verifyWebhook({
secret: process.env.GIVEBEAR_WEBHOOK_SECRET!,
signature,
body,
toleranceSeconds: 600,
});Fetch the full record
The payload never includes the record itself. Read data.id and fetch it from the read API for the matching resource.
curl https://givebear.io/api/v1/donations/DONATION_ID \
-H "Authorization: Bearer $GIVEBEAR_TOKEN"import { GivebearConnect } from "@givebear/connect";
const gb = new GivebearConnect({ token: process.env.GIVEBEAR_TOKEN! });
const event = await verifyWebhook({
secret: process.env.GIVEBEAR_WEBHOOK_SECRET!,
signature,
body,
});
if (event.type === "donation.created") {
const donation = await gb.donations.get(event.data.id);
console.log(donation.amount_cents, donation.donor_email);
}event = verify_webhook(signature_header, body)
if event["type"] == "donation.created":
donation = requests.get(
f"https://givebear.io/api/v1/donations/{event['data']['id']}",
headers={"Authorization": f"Bearer {os.environ['GIVEBEAR_TOKEN']}"},
).json()
print(donation["amount_cents"], donation["donor_email"])$event = verify_webhook($signatureHeader, $body);
if ($event["type"] === "donation.created") {
$id = urlencode($event["data"]["id"]);
$ch = curl_init("https://givebear.io/api/v1/donations/$id");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . getenv("GIVEBEAR_TOKEN"),
]);
$donation = json_decode(curl_exec($ch), true);
curl_close($ch);
}event = verify_webhook(signature_header, body)
if event["type"] == "donation.created"
uri = URI("https://givebear.io/api/v1/donations/#{event["data"]["id"]}")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("GIVEBEAR_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
donation = JSON.parse(response.body)
endevent, err := verifyWebhook(signatureHeader, body)
if err == nil && event["type"] == "donation.created" {
id := event["data"].(map[string]any)["id"].(string)
req, _ := http.NewRequest("GET", "https://givebear.io/api/v1/donations/"+id, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("GIVEBEAR_TOKEN"))
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
}The read calls need the matching read scope (for example donations:read). See Authentication.
Delivery and retries
Givebear attempts each delivery as the event happens, then retries failures.
- A delivery succeeds when your endpoint returns a
2xxstatus within 5 seconds. - Anything else (non-
2xx, timeout, or connection error) counts as a failure and is retried. - Failures retry on a widening schedule: about 1 minute, 5 minutes, 30 minutes, 2 hours, then 6 hours later.
- After 6 failed attempts the delivery is dead-lettered and dropped.
Respond 2xx quickly and do heavy work asynchronously. A slow handler can blow the 5-second timeout and be marked failed. Retries can also redeliver an event, so deduplicate on the event id.
Endpoint URLs must use https and resolve to a publicly reachable host; registration rejects anything else. Givebear does not follow redirects and blocks delivery to internal or link-local addresses.
Event catalog
These are the events you can subscribe to. The data.id column shows which read endpoint resolves the resource.
| Event | Meaning | data.id resolves with |
|---|---|---|
donation.created | Donation received (includes recurring charges) | GET /api/v1/donations/{id} |
donation.refunded | Donation refunded | GET /api/v1/donations/{id} |
donation.disputed | Donation disputed | GET /api/v1/donations/{id} |
payout.paid | Payout paid out | GET /api/v1/payouts/{id} |
subscription.created | Recurring gift started | GET /api/v1/donations/{id} |
subscription.updated | Recurring gift updated | GET /api/v1/donations/{id} |
subscription.canceled | Recurring gift canceled | GET /api/v1/donations/{id} |
donor.created | Donor added | GET /api/v1/donors/{id} |
registration.created | Event registration confirmed | GET /api/v1/events/{event_id}/registrations |
registration.refunded | Event registration fully refunded | GET /api/v1/events/{event_id}/registrations |
A recurring charge surfaces as donation.created, since money was received. The subscription.* events cover lifecycle changes to the recurring gift itself.
registration.created fires when a registration becomes confirmed: instantly
for free registrations, at charge settlement for paid tickets (web and kiosk),
and on approval or waitlist promotion for gated events. A pending paid
registration is an open cart and never emits. registration.refunded fires
once when the payment becomes fully refunded. Reading registrations requires
the registrations:read scope.
Manage endpoints via the API
With the webhooks:write scope you can manage endpoints programmatically, which is handy for confirming your sync is wired up on first connect. A credential only sees its own endpoints: an OAuth token sees its app's endpoints, an API key sees the org's direct endpoints.
# List what exists, then create only if your URL is missing.
curl https://givebear.io/api/v1/webhooks \
-H "Authorization: Bearer $GIVEBEAR_TOKEN"import { GivebearConnect } from "@givebear/connect";
const gb = new GivebearConnect({ token: process.env.GIVEBEAR_TOKEN! });
const existing = await gb.webhooks.list();
if (!existing.some((e) => e.url === MY_URL)) {
const endpoint = await gb.webhooks.create({
url: MY_URL,
events: ["donation.created", "payout.paid"],
});
// Store endpoint.secret now: it is returned only here.
}existing = requests.get(
"https://givebear.io/api/v1/webhooks", headers=headers
).json()["data"]
if not any(e["url"] == MY_URL for e in existing):
endpoint = requests.post(
"https://givebear.io/api/v1/webhooks",
headers=headers,
json={"url": MY_URL, "events": ["donation.created", "payout.paid"]},
).json()
# Store endpoint["secret"] now: it is returned only here.$ch = curl_init("https://givebear.io/api/v1/webhooks");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $token"]);
$existing = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
$urls = array_column($existing, "url");
if (!in_array($myUrl, $urls, true)) {
// POST /api/v1/webhooks as in "Create an endpoint" above.
}uri = URI("https://givebear.io/api/v1/webhooks")
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch("GIVEBEAR_TOKEN")}"
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
existing = JSON.parse(response.body)["data"]
unless existing.any? { |e| e["url"] == MY_URL }
# POST /api/v1/webhooks as in "Create an endpoint" above.
endreq, _ := http.NewRequest("GET", "https://givebear.io/api/v1/webhooks", nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("GIVEBEAR_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var existing struct {
Data []struct {
URL string `json:"url"`
} `json:"data"`
}
json.NewDecoder(resp.Body).Decode(&existing)
// POST /api/v1/webhooks as in "Create an endpoint" above if missing.Available methods: list, get, create, update, delete, rotateSecret, and listDeliveries. These map to GET and POST /api/v1/webhooks; GET, PATCH, and DELETE /api/v1/webhooks/{id}; POST /api/v1/webhooks/{id}/rotate-secret; and GET /api/v1/webhooks/{id}/deliveries. You can hold up to 25 active endpoints per organization. See the API reference for full request and response shapes.
Rotate a signing secret
Rotate when a secret may have leaked, or on your regular credential schedule. The response carries the new secret exactly once. For the next 24 hours deliveries are signed with both the new and the old secret (two v1 entries in the header), so you can swap the secret on your server without dropping verification.
curl -X POST "https://givebear.io/api/v1/webhooks/whe_123/rotate-secret" \
-H "Authorization: Bearer $GIVEBEAR_TOKEN"const endpoint = await gb.webhooks.rotateSecret("whe_123");
// endpoint.secret is the NEW secret, returned only here.Organization admins can also rotate from the dashboard under Developers, then Webhooks.
Inspect deliveries
Each endpoint keeps a delivery log: status (pending, sending, succeeded, failed, or dead), attempt count, the last HTTP response code, the last error, and the next retry time. Use it to debug a handler without guessing.
curl "https://givebear.io/api/v1/webhooks/whe_123/deliveries?limit=10" \
-H "Authorization: Bearer $GIVEBEAR_TOKEN"const { data } = await gb.webhooks.listDeliveries("whe_123", { limit: 10 });
for (const d of data) {
console.log(d.status, d.event_type, d.last_response_status, d.last_error);
}The same log appears in the dashboard: expand an endpoint under Developers, then Webhooks.
Next steps
API reference
Every endpoint, field, and status code, including the webhook routes.
Authentication and scopes
API keys, OAuth, and the scopes the read and write calls need.
Quickstart
Make your first authenticated Connect API call.
Versioning
How the v1 surface and event catalog evolve.