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-Signature header on a CharityStack webhook?
  • Why does my CharityStack webhook signature verification fail?
  • How do I handle donation.created or subscription.cancelled events?
  • 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_WEBHOOK_TOLERANCE_SECONDS (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.

EventTriggered when
donation.createdA new donation is received
donation.updatedA donation's status changes
subscription.createdA new recurring subscription is started
subscription.updatedA subscription is modified (e.g. amount changed)
subscription.cancelledA subscription is cancelled
subscription.payment_method_updatedA payment method is updated through a hosted update link (subscription.updated is also emitted)
contact.created / contact.updatedA contact is added or updated
form.created / form.updatedA 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: POST with a JSON body.
  • No retry or timeout policy is documented, so return a 2xx quickly and process asynchronously. Return 2xx for event types you don't handle.
  • X-Webhook-ID identifies 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →