Guide to CharityStack Webhooks: Features and Best Practices
CharityStack webhooks tell your systems when money and supporters move. A donation comes in or changes status, a recurring gift starts, changes or is cancelled, a donor's record is added or updated, or a donation form changes. CharityStack is an online fundraising platform for nonprofits, and its webhooks are how those events reach a CRM, a thank-you email flow or a finance database without polling the API.
This guide covers how CharityStack webhooks are registered and signed, the events you can subscribe to, what the documentation leaves out, and the practices that keep a CharityStack receiver reliable.
What are CharityStack webhooks?
A CharityStack webhook is a URL plus a list of event types, registered through the API. When a subscribed event happens, CharityStack sends an HTTP POST with a JSON body to the URL.
Each delivery carries three security headers. X-Webhook-Signature holds sha256= and a hex HMAC-SHA256 of the X-Webhook-Timestamp value, a full stop and the raw request body, keyed with the webhook's secret. X-Webhook-ID identifies the delivery. The secret comes back once, in the response to the call that creates the webhook, and CharityStack never shows it again.
CharityStack webhook features
| Feature | Details |
|---|---|
| Configuration | API only: POST /v1/webhooks with the webhooks:write permission |
| Subscription | A non-empty list of event types per webhook |
| Endpoint URL | HTTPS. HTTP is accepted in development environments only |
| Signature header | X-Webhook-Signature: sha256=<hex> |
| Timestamp header | X-Webhook-Timestamp, Unix seconds |
| Delivery ID | X-Webhook-ID |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | {X-Webhook-Timestamp}.{raw body} |
| Secret | Returned once in the creation response, used as a UTF-8 string |
| Monitoring | GET /v1/webhooks returns status, lastDeliveryAt, successCount and failureCount |
| Replay guidance | Reject timestamps more than 5 minutes old (CharityStack's guide) |
CharityStack doesn't document its retry policy, response timeout or source IPs, and it publishes no example webhook body. Its verification samples read the event name from a type field.
Common events
| Event | Triggered when |
|---|---|
donation.created | A new donation is received |
donation.updated | A donation's status changes |
subscription.created | A new recurring subscription is started |
subscription.updated | A subscription is modified, for example its amount |
subscription.cancelled | A subscription is cancelled |
subscription.payment_ | A donor updates a subscription's payment method through a hosted update link |
contact.created | A new contact is added |
contact.updated | A contact's information is updated |
form.created | A new form is created |
form.updated | A form's configuration changes |
When a hosted payment method update completes, CharityStack sends subscription.payment_ and also subscription.updated, for backward compatibility. If you subscribe to both, expect two events for one change. CharityStack says the dedicated event "includes a safe summary and excludes provider identifiers".
Because there is no published payload, write your handler defensively: read type, log the first real bodies you receive, and build your parsing from those. Hookdeck's request log is a convenient place to capture them.
Setting up CharityStack webhooks
There is no dashboard step. Register the webhook with your API key:
curl -X POST "$CHARITYSTACK_API_BASE/v1/webhooks" \
-H "Authorization: Bearer cs_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/charitystack",
"events": ["donation.created", "donation.updated", "subscription.cancelled"],
"description": "Production webhook"
}'
$CHARITYSTACK_API_BASE stands for the API base URL given in CharityStack's API reference. The 201 response contains the webhookId, a status of ACTIVE and a secret. Copy the secret into your secrets manager before you close the response, for example as CHARITYSTACK_. If you lose it, you must delete the webhook and create a new one.
If you use Hookdeck, create a source with the CharityStack source type first, so you have its URL, and register that URL as the webhook's url. CharityStack shows the webhook secret only once, when you create the webhook, so paste the secret from the creation response into the source straight away. The source accepts POST only, which is what CharityStack sends.
Securing CharityStack webhooks
Read X-Webhook-Timestamp and X-Webhook-Signature. Remove the sha256= prefix, compute an HMAC-SHA256 of the timestamp, a . and the raw body with the secret string as the key, encode it as hex and compare in constant time.
CharityStack's guide also says to reject timestamps more than five minutes from your server's time, while its concepts page calls the check optional. The code below includes it as an option that is off by default.
const crypto = require('crypto');
const express = require('express');
function verifyCharityStackSignature(rawBody, timestamp, signatureHeader, secret, toleranceSeconds = 0) {
if (!timestamp || !signatureHeader || !secret) return false;
const signature = signatureHeader.replace(/^sha256=/i, '');
if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false;
// Optional replay check, off by default.
if (toleranceSeconds > 0) {
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
}
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp, 'latin1')
.update('.')
.update(rawBody)
.digest('hex');
const received = Buffer.from(signature.toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/charitystack', express.raw({ type: '*/*' }), (req, res) => {
const ok = verifyCharityStackSignature(
req.body,
req.get('x-webhook-timestamp'),
req.get('x-webhook-signature'),
process.env.CHARITYSTACK_WEBHOOK_SECRET
);
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.type. Persist the event, then process it asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import re
import time
HEX_64 = re.compile(r"[0-9a-fA-F]{64}")
def verify_charitystack_signature(raw_body: bytes, timestamp, signature_header, secret: str,
tolerance_seconds: int = 0) -> bool:
if not timestamp or not signature_header or not secret:
return False
signature = re.sub(r"^sha256=", "", signature_header, count=1, flags=re.IGNORECASE)
if not HEX_64.fullmatch(signature):
return False
# Optional replay check, off by default.
if tolerance_seconds > 0:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
signed = timestamp.encode("latin-1") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.lower().encode("utf-8"), expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_charitystack_signature(raw_body, request.headers.get("x-webhook-timestamp"),
# request.headers.get("x-webhook-signature"),
# os.environ["CHARITYSTACK_WEBHOOK_SECRET"])
CharityStack's Node sample registers express.raw({ type: "application/json" }) for the whole app and compares with timingSafeEqual without a length check, which throws on a malformed header. Using type: '*/*' on the webhook route keeps the body raw whatever the content type, and the length check turns a bad header into a plain false.
Hookdeck's CharityStack source type verifies the same HMAC over {X-Webhook-Timestamp}.{body}, with or without the sha256= prefix, and applies no timestamp tolerance. A request that fails the check is marked as failed verification and is not delivered to your destinations.
Make CharityStack webhooks production-ready. Hookdeck Event Gateway verifies the
X-Webhook-Signatureheader on every CharityStack delivery, stores each donation and subscription event, and delivers it to your services with retries you control.
CharityStack webhook limitations and pain points
The secret is shown once
The Problem: The signing secret is lost, and there is no way to read it back.
Why It Happens: CharityStack returns the secret only in the POST /v1/webhooks response. The list and get endpoints don't include it.
Workarounds:
- Store the secret in a secrets manager in the same script that creates the webhook.
- If it is lost, create a new webhook, deploy its secret, then delete the old one.
How Hookdeck Can Help: With Hookdeck in front, only the Hookdeck source needs the secret, so a replacement means updating one place.
Undocumented retries and timeouts
The Problem: You can't tell how long CharityStack keeps trying after your endpoint fails, or whether it tries again at all.
Why It Happens: The documentation describes the webhook object's failureCount and a DISABLED status, but not a retry schedule, a timeout or the conditions that disable a webhook.
Workarounds:
- Return a
2xxquickly after verification and process the event afterwards. - Poll
GET /v1/webhooks/{id}and alert whenfailureCountrises orstatusis no longerACTIVE. - Reconcile donations against the CharityStack API after any outage.
How Hookdeck Can Help: Hookdeck accepts each delivery as it arrives and stores it, then delivers to your service with a retry policy you configure, so you don't depend on CharityStack's.
No payload reference
The Problem: You have to write a parser for bodies you have never seen.
Why It Happens: CharityStack documents event names and headers, but no example body or field list.
Workarounds:
- Point a test webhook at a request inspector and trigger each event type once.
- Treat every field as optional, and log unknown shapes rather than failing.
How Hookdeck Can Help: Hookdeck stores every request with its headers and body, so the first real deliveries are there to inspect before you finish your handler, and you can replay them once it is ready.
The five-minute window and replays
The Problem: A delivery replayed later fails your own timestamp check.
Why It Happens: CharityStack's guide rejects timestamps older than five minutes. Hookdeck retries and replays resend the original X-Webhook-Timestamp, so a later redelivery falls outside that window.
Workarounds:
- Leave the window off and deduplicate on stable IDs instead.
- If you enable it, choose a window longer than any retry or replay delay you allow.
How Hookdeck Can Help: Hookdeck applies no timestamp tolerance, so redeliveries still verify at the source.
Best practices
Verify against the raw body
CharityStack's guide is explicit that parsing and re-serialising JSON can change the bytes. Use express.raw({ type: '*/*' }) in Express, Buffer.from(await request.arrayBuffer()) in Next.js and await request.body() in FastAPI.
Deduplicate deliveries
X-Webhook-ID is described as a unique identifier for each delivery attempt, so a retry may arrive with a new value. Deduplicate on the donation, subscription or contact ID in the body together with the event type. See our guide to webhook idempotency.
Pick one subscription event
If you only need to know that a subscription changed, subscribe to subscription.updated. If you need payment method changes specifically, subscribe to subscription.payment_ and expect the matching subscription.updated as well.
Acknowledge fast and process asynchronously
Verify, persist, return 200, and send thank-you emails or CRM updates afterwards. See why to process webhooks asynchronously.
Conclusion
CharityStack signs every delivery with sha256= and a hex HMAC-SHA256 of the timestamp and raw body, keyed with a secret you see only once. Store that secret when you create the webhook, verify against the raw bytes, deduplicate on IDs from the body, and watch the webhook's failureCount, because CharityStack doesn't publish its retry policy.
Hookdeck Event Gateway verifies every CharityStack signature and holds each donation and subscription event until your service has processed it.
Get started with Hookdeck for free and handle CharityStack webhooks reliably in minutes.