Agent skill
CharityStack Webhooks Skill
Receive and verify CharityStack webhooks. Use when setting up CharityStack webhook handlers, debugging X-Webhook-Signature verification (HMAC-SHA256 hex over "<X-Webhook-Timestamp>.<raw body>"), or handling events like donation.created, subscription.cancelled, contact.created and form.updated.
Install this skill
npx skills add hookdeck/webhook-skills --skill charitystack-webhooks
When to Use This Skill
- How do I receive CharityStack webhooks?
- How do I verify the
X-Webhook-Signatureheader on a CharityStack webhook? - Why does my CharityStack webhook signature verification fail?
- How do I handle
donation.createdorsubscription.cancelledevents? - Where do I find the CharityStack webhook secret?
Setup note
CharityStack shows the webhook secret only once, when you create the webhook. Paste it here.
The secret is the secret field of the POST /v1/webhooks response. If you lose it, delete the webhook and create a new one. See references/setup.md.
Verification (core)
Every delivery carries three headers:
X-Webhook-Signature: sha256=<hex digest>
X-Webhook-Timestamp: <unix seconds>
X-Webhook-ID: <delivery identifier>
The signature is the lowercase hex HMAC-SHA256 of <X-Webhook-Timestamp>.<raw body>, keyed with the webhook secret's UTF-8 bytes (not hex- or base64-decoded). Build the message as bytes, never decode and re-encode the body, and don't parse the JSON before verifying. Accept the digest with or without the sha256= prefix, check it is exactly 64 hex characters, compare in constant time, and respond 401 on any failure.
const signed = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from('.'), rawBody]);
const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(signed).digest();
const digest = signature.startsWith('sha256=') ? signature.slice(7) : signature;
if (!/^[0-9a-fA-F]{64}$/.test(digest)) return false; // validate before decoding
return crypto.timingSafeEqual(expected, Buffer.from(digest.toLowerCase(), 'hex'));
signed = b"".join([timestamp.encode("latin-1"), b".", raw_body])
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
digest = signature.removeprefix("sha256=")
if not re.fullmatch(r"[0-9a-fA-F]{64}", digest): # validate before decoding
return False
return hmac.compare_digest(expected, bytes.fromhex(digest))
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
CharityStack has no official webhook SDK, so verification is manual in every framework.
Timestamp tolerance is opt-in
CharityStack's guide suggests rejecting timestamps more than 5 minutes old. The examples do not do this by default: Hookdeck retries and replays resend the original headers and timestamp, and Hookdeck itself applies no tolerance, so a fixed window would reject legitimate retries. Set CHARITYSTACK_ (for example 300) to turn it on; unset or 0 means off. X-Webhook-Timestamp is Unix seconds.
Read the raw body
Express: express.raw({ type: '*/*' }). Next.js: await request.arrayBuffer(). FastAPI: await request.body(). Parse the JSON only after the signature check.
Common Event Types
Dispatch on the body's type field.
| 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 (e.g. amount changed) |
subscription.cancelled | A subscription is cancelled |
subscription.payment_ | A payment method is updated through a hosted update link (subscription.updated is also emitted) |
contact.created / contact.updated | A contact is added or updated |
form.created / form.updated | A form is created or its configuration changes |
For payload notes and delivery details, see references/overview.md and the CharityStack webhooks docs.
Delivery Notes
- Method:
POSTwith a JSON body. - No retry or timeout policy is documented, so return a
2xxquickly and process asynchronously. Return2xxfor event types you don't handle. X-Webhook-IDidentifies a delivery attempt, so don't rely on it alone to deduplicate retries; deduplicate on an identifier from the event payload.
Environment Variables
CHARITYSTACK_WEBHOOK_SECRET=your_charitystack_webhook_secret
# Optional: reject timestamps older than this many seconds (unset or 0 = off)
CHARITYSTACK_WEBHOOK_TOLERANCE_SECONDS=300
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 charitystack --path /webhooks/charitystack
Register the printed URL as the webhook url when you call POST /v1/webhooks.
Reference Materials
- references/overview.md - What CharityStack webhooks are, events, delivery
- references/setup.md - Register a webhook and capture the secret
- references/verification.md - Signature details, gotchas, debugging