# 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_method_updated` | 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_method_updated` 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:

```bash
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_WEBHOOK_SECRET`. 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.

```javascript
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:

```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](/event-gateway) verifies the `X-Webhook-Signature` header 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 `2xx` quickly after verification and process the event afterwards.
* Poll `GET /v1/webhooks/{id}` and alert when `failureCount` rises or `status` is no longer `ACTIVE`.
* 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](/docs/retries) 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](/webhooks/guides/implement-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_method_updated` 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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## 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](https://hookdeck.com) verifies every CharityStack signature and holds each donation and subscription event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle CharityStack webhooks reliably in minutes.