---
title: "Webhooks"
description: "Get a call on your server when a donation, a vote, a campaign or a receipt changes, check its signature, and handle repeats and retries."
canonical: https://www.charitips.com/docs/webhooks/configuration
---

# Webhooks

> Get a call on your server when a donation, a vote, a campaign or a receipt changes, check its signature, and handle repeats and retries.

A webhook sends a POST request to your server each time an object of your account changes: a donation is created or claimed, a campaign is activated, a fiscal receipt is ready. Listen to them instead of polling the API.

> **In short**
>
> - Create a webhook in the dashboard, under Settings, API, or with [Create a webhook](https://www.charitips.com/docs/api-reference/create-webhook.md): a URL, the events to receive, and how Charitips authenticates to your server.
> - Each call carries `event`, `payload` (the object) and `diff` (what changed).
> - Check the `X-Charitips-Signature` header with the webhook's `signingKey`, shown once, on creation.
> - A call can arrive twice: handle each change once. Answer `2xx` within 10 seconds, then do the work.
> - A parent account's webhooks also receive the events of its child accounts.

## Create a webhook

Give it a `name`, the `target` URL, the `events` to receive and an authentication: `BASIC` with a `username` and a `password`, or `BEARER` with an `accessToken`. Charitips sends them in the `Authorization` header of each call, so your server can refuse anyone else.

```bash
curl -X POST https://api.sandbox.charitips.com/v1/webhooks \
  -H "Authorization: Bearer $CHARITIPS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Donations",
    "target": "https://example.com/webhooks/charitips",
    "events": [
      "donation_create",
      "donation_update"
    ],
    "authenticationStrategy": "BEARER",
    "accessToken": "<a secret of your own>"
  }'
```

The response holds the `signingKey`. It is shown this once: store it in your server's secrets. A lost key cannot be read again; delete the webhook and create a new one for the same URL.

Webhooks need API access on your plan (`402 Plan upgrade required` otherwise). A webhook belongs to one environment: a sandbox webhook only receives sandbox events. With account hierarchy, pass a child's `accountId` to create the webhook on that child account.

## Events

| Event                                                    | Sent when                                                               |
| -------------------------------------------------------- | ----------------------------------------------------------------------- |
| `donation_create`, `donation_update`                     | a donation is created, then on every change: claim, validation, payment |
| `vote_create`, `vote_update`                             | a vote is created, then on every change, such as the cast               |
| `recurringdonation_create`, `recurringdonation_update`   | a recurring donation is created, then on every change                   |
| `campaign_create`, `campaign_update`                     | a campaign is created, then on every change: activation, closing        |
| `charitiesselection_create`, `charitiesselection_update` | a selection is created, then when charities are added or removed        |
| `paymentmethod_create`                                   | a payment method is added to the account                                |
| `fiscalreceipt_generated`                                | a fiscal receipt is ready                                               |
| `file_generated`                                         | a file you asked for, such as the PDF of printed cards, is ready        |
| `charity_publish`                                        | a new charity joins the catalog                                         |
| `charityprofile_update`                                  | a charity updates one of its published profiles                         |

Update events are also sent for changes Charitips makes itself, such as a donation assigned to a charity when its campaign closes. Missing an event? [Tell us](https://www.charitips.com/contact).

## What a call holds

```json
{
  "event": "donation_update",
  "payload": {
    "donationId": "6f1c2a4e-3b5d-4e8f-9a0b-1c2d3e4f5a6b",
    "campaignId": "c0ffee00-1234-4abc-8def-0123456789ab",
    "status": "DONE",
    "charityId": "85e36146-206f-4c89-86c4-90b11e744c22",
    "amount": 2000,
    "uniqueExternalId": "order_1042"
  },
  "diff": { "status": "DONE", "charityId": "85e36146-206f-4c89-86c4-90b11e744c22" }
}
```

- `payload` is the object after the change, as Charitips stores it, with its ids and its `status` (shortened here).
- `diff` holds the fields that changed; on a create event, the whole object. Charity events have no `diff`.
- `fiscalreceipt_generated` and `file_generated` carry no file link: get it with [Generate a fiscal receipt download url](https://www.charitips.com/docs/api-reference/generate-fiscal-receipt-download-url.md) or [Generate a file download url](https://www.charitips.com/docs/api-reference/generate-file-generation-request-download-url.md).
- Route the event with the object's own `accountId` and your ids (`uniqueExternalId`, `externalId`).

## Check the signature

Each call has two headers:

- `X-Charitips-Timestamp`: when it was sent, in milliseconds since 1970;
- `X-Charitips-Signature`: `sha256=` followed by the HMAC-SHA256, in hex, of `<timestamp>:<raw body>`, keyed with the webhook's `signingKey`.

Compute it on the raw body, before any JSON parsing: a parsed and serialized body no longer matches.

```javascript
import crypto from 'node:crypto';
import express from 'express';

const FIVE_MINUTES = 5 * 60 * 1000;

export function isValidCharitipsSignature(rawBody, timestamp, signature, secret = process.env.CHARITIPS_WEBHOOK_SIGNING_KEY) {
  if (!rawBody || !timestamp || !signature) return false;
  const sentAt = Number(timestamp);
  if (!Number.isFinite(sentAt) || Math.abs(Date.now() - sentAt) > FIVE_MINUTES) return false;
  const hash = crypto.createHmac('sha256', secret).update(`${timestamp}:${rawBody}`).digest('hex');
  const expected = Buffer.from(`sha256=${hash}`);
  const received = Buffer.from(signature);
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

// The raw body: the signature is computed on the bytes as sent, before any JSON parsing.
app.post('/webhooks/charitips', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString();
  if (!isValidCharitipsSignature(rawBody, req.header('X-Charitips-Timestamp'), req.header('X-Charitips-Signature'))) {
    return res.status(401).send('Invalid signature');
  }
  const { event, payload } = JSON.parse(rawBody);
  // Act on event and payload once per resource and status: a delivery can come twice.
  res.sendStatus(200);
});
```

The check refuses a timestamp more than five minutes away from now, in the past or in the future, which stops a replayed call. It compares the lengths before `timingSafeEqual`, which throws on buffers of different sizes.

## Delivery and retries

- **At least once.** The same change can arrive twice. Store what you processed, such as the `donationId` and its `status`, and skip a repeat.
- **Answer within 10 seconds.** Reply with a `2xx` first, then do the work, in a queue for example. Charitips waits 10 seconds at most, and a call that times out is not retried.
- **Retries.** After `429`, `500`, `502`, `503` or `504`, the call is retried, up to twice, within a few minutes. Any other status, such as `401` or `404`, means the webhook is misconfigured and is not retried.
- **Order.** Calls can arrive out of order: rely on the `status` in `payload`, not on the order of arrival.

[List webhooks](https://www.charitips.com/docs/api-reference/list-webhooks.md) and [Get a webhook](https://www.charitips.com/docs/api-reference/get-webhook.md) show your webhooks; [Delete a webhook](https://www.charitips.com/docs/api-reference/delete-webhook.md) removes one.

## Account hierarchy

An event on a child account's object goes to the webhooks of that child account and to those of its parent. One webhook on your account receives the events of all your customers: route each one with the `accountId` of its `payload`. See [Account hierarchy](https://www.charitips.com/docs/concepts/account-hierarchy.md#webhooks).

## Example: act when a donation is claimed

When a person picks a charity, the donation becomes `DONE`. To decrease a points balance or mark an order on your side when that happens, listen to both events: a donation created with its charity is `DONE` from the start.

```javascript
const { event, payload, diff } = JSON.parse(rawBody);
const claimed = (event === 'donation_update' && diff.status === 'DONE') || (event === 'donation_create' && payload.status === 'DONE');
if (claimed) {
  // Find your own record with payload.uniqueExternalId or payload.externalId,
  // and process it unless it is already done.
}
```

## Related

[Donation lifecycle](https://www.charitips.com/docs/concepts/donation-lifecycle.md), [Troubleshooting](https://www.charitips.com/docs/api-reference/troubleshooting.md#webhooks), [Create a webhook](https://www.charitips.com/docs/api-reference/create-webhook.md).
