Givebear LogoGivebear
Connect API

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

  • You can create an API key or an OAuth app.
  • You have an HTTPS URL that can receive a POST request.

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.

HeaderValue
x-givebear-eventThe event type, for example donation.created
x-givebear-idThe event id (matches the body id)
x-givebear-signaturet=<unix_seconds>,v1=<hex_hmac>
content-typeapplication/json
user-agentGivebear-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)
end
func 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)
end
event, 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 2xx status 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.

EventMeaningdata.id resolves with
donation.createdDonation received (includes recurring charges)GET /api/v1/donations/{id}
donation.refundedDonation refundedGET /api/v1/donations/{id}
donation.disputedDonation disputedGET /api/v1/donations/{id}
payout.paidPayout paid outGET /api/v1/payouts/{id}
subscription.createdRecurring gift startedGET /api/v1/donations/{id}
subscription.updatedRecurring gift updatedGET /api/v1/donations/{id}
subscription.canceledRecurring gift canceledGET /api/v1/donations/{id}
donor.createdDonor addedGET /api/v1/donors/{id}
registration.createdEvent registration confirmedGET /api/v1/events/{event_id}/registrations
registration.refundedEvent registration fully refundedGET /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.
end
req, _ := 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

Was this page helpful?

On this page