Integrate
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: a URL, the events to receive, and how Charitips authenticates to your server.
- Each call carries
event,payload(the object) anddiff(what changed). - Check the
X-Charitips-Signatureheader with the webhook'ssigningKey, shown once, on creation. - A call can arrive twice: handle each change once. Answer
2xxwithin 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.
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>" }'Request bodies are written for this docs and checked against the API's validation; ids in angle brackets are placeholders. Response values are placeholders built from the schema.
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.
What a call holds
{ "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" }}payloadis the object after the change, as Charitips stores it, with its ids and itsstatus(shortened here).diffholds the fields that changed; on a create event, the whole object. Charity events have nodiff.fiscalreceipt_generatedandfile_generatedcarry no file link: get it with Generate a fiscal receipt download url or Generate a file download url.- Route the event with the object's own
accountIdand 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'ssigningKey.
Compute it on the raw body, before any JSON parsing: a parsed and serialized body no longer matches.
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
donationIdand itsstatus, and skip a repeat. - Answer within 10 seconds. Reply with a
2xxfirst, 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,503or504, the call is retried, up to twice, within a few minutes. Any other status, such as401or404, means the webhook is misconfigured and is not retried. - Order. Calls can arrive out of order: rely on the
statusinpayload, not on the order of arrival.
List webhooks and Get a webhook show your webhooks; Delete a webhook 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.
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.
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.}