Overview
A webhook is an internet address (a URL) that belongs to your organisation: that of your server, your CRM or a tool such as Make or Zapier. As soon as an event occurs on Donoor, Donoor sends an HTTPS POST request to this address describing the event in JSON format.
Every message is signed with a secret that only you know: your server can therefore verify that it really comes from Donoor and that it has not been altered. If your server does not respond, Donoor automatically retries for about 28 hours.
An event occurs
donation, membership, ticket…
Donoor sends a signed message
POST · Donoor-Signature
Your server
verifies, responds 200, processes
Advertising — pass each donation on as a conversion to Google Ads, GA4 or Meta, along with the originating campaign.
CRM — create or update the record of the donor, member or buyer.
Accounting — record payments, refunds, tax receipts and bank payouts.
Automation — notify your team, fill in a spreadsheet, trigger a welcome email.
Quickstart
Allow about fifteen minutes to receive your first message.
- 1
Prepare a receiving endpoint
An https URL reachable from the Internet, on your server or in a tool such as Make or Zapier. To try it out without installing anything, a free webhook.site address is enough.
- 2
Add it in your dashboard
Settings › Webhooks › Add an address. Give it a description and choose the events to receive (all of them, by default).
- 3
Copy the secret
It starts with whsec_. Store it in your server configuration, never in a web page or a public code repository.
- 4
Verify the signature and respond 200
Your server verifies the Donoor-Signature header, then responds with a 2xx code within 10 seconds. Ready-to-copy code is provided below, in Node.js, PHP and Python.
- 5
Send a test message
Use the Send a test button, or Simulate an event to receive any of the 28 events with dummy data. Then check the result in the delivery log.
Only events that occur after your first endpoint is added are sent: past history is not sent. Messages contain the donors' email and name: only the organisation's owner and administrators can manage webhooks, and the owner is notified by email whenever an endpoint is added or modified.
List of events
Each endpoint receives the events you have selected. The “Content” column lists the objects present in data, described in the Objects section.
Payments
| Type | Sent when… | Content |
|---|---|---|
payment.succeeded | A payment is confirmed: one-off or monthly donation (first payment and every renewal), membership, ticket, shop order, QR code, fundraiser, Tap to Pay. | paymentattribution |
payment.failed | A card or direct debit is declined, for example the renewal of a monthly donation. | paymentattribution |
payment.refunded | A payment is refunded, fully or partially. One message per refund. | paymentrefundattribution |
dispute.opened | A donor disputes a payment with their bank. | paymentdisputeattribution |
dispute.closed | The dispute is closed, won or lost. | paymentdisputeattribution |
Monthly donations
| Type | Sent when… | Content |
|---|---|---|
subscription.created | A donor starts a monthly donation. | subscriptionattribution |
subscription.past_due | This month's charge did not go through: the monthly donation is awaiting payment. | subscriptionattribution |
subscription.reactivated | An unpaid monthly donation has been paid again. | subscriptionattribution |
subscription.canceled | A monthly donation is stopped. | subscriptionattribution |
Orders (memberships, ticketing, shop)
| Type | Sent when… | Content |
|---|---|---|
order.confirmed | A membership, tickets or a shop purchase are confirmed, including free ones. | order |
order.pending_approval | A membership request is awaiting your approval. | order |
order.rejected | You rejected a membership request. | order |
order.canceled | An order is canceled. | order |
order.expired | An order was not paid in time and has expired. | order |
order.fulfillment_updated | A shop order moves to a new step: being prepared, shipped (with the tracking number), delivered or picked up. | order |
Members and tickets
| Type | Sent when… | Content |
|---|---|---|
membership.created | A membership card becomes active. | membership |
membership.canceled | A membership is canceled. | membership |
ticket.checked_in | A ticket is scanned at the entrance of your event. | ticket |
ticket.canceled | A ticket is canceled. | ticket |
Refund requests
| Type | Sent when… | Content |
|---|---|---|
refund_request.created | A donor or buyer asks you for a refund. | refund_request |
refund_request.updated | A refund request changes status: answered, refunded or refused. | refund_request |
Tax receipts
| Type | Sent when… | Content |
|---|---|---|
fiscal_receipt.issued | A tax receipt is issued for a donor. | fiscal_receipt |
fiscal_receipt.voided | A tax receipt is voided, for example after a refund. | fiscal_receipt |
Bank payouts
| Type | Sent when… | Content |
|---|---|---|
payout.paid | Stripe or PayPal has transferred money to the organisation's bank account. | payout |
payout.failed | A transfer to the organisation's bank account has failed. | payout |
Forms and fundraisers
| Type | Sent when… | Content |
|---|---|---|
form.created | A fundraiser, event, membership plan, shop, payment link or QR code is created. | form |
campaign.goal_reached | A fundraiser reaches its goal. | campaign |
Organisation account
| Type | Sent when… | Content |
|---|---|---|
organization.payment_account_updated | The organisation's Stripe or PayPal account is enabled or suspended. | payment_account |
Test
| Type | Sent when… | Content |
|---|---|---|
test.ping | Test message sent from the dashboard. | message |
A paid order triggers two events: order.confirmed (the order) and payment.succeeded (the money). To measure your conversions, use payment.succeeded only; to manage your registrations, use order.confirmed only. Subscribe each endpoint only to the events it needs.
Messages are usually sent less than two minutes after the fact: Donoor waits until all the information is final (amount paid out, fees…) before sending.
Message structure
Each message is a POST request whose JSON body, encoded in UTF-8, always has the same envelope. Only the content of data changes depending on the event type. Field names are in English, as with Stripe or HelloAsso.
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier of the event (evt_…). It stays the same on every retry and every resend: this is what lets you ignore a duplicate. |
object | string | Always event. |
type | string | Event type, for example payment.succeeded. |
api_version | string | Version of the message format. It only changes for a breaking change. |
created | string (ISO 8601) | Date the event was created, in UTC. |
livemode | boolean | true for a real event; false for a simulation or in the test environment. Never count a livemode false message as a real payment. |
organization | object | The organisation concerned: id (Donoor number) and name. |
data | object | The objects specific to the event: payment, order, ticket… (see Objects). |
Sample messages
{
"id": "evt_9mK2xQpL4vR8sT1w",
"object": "event",
"type": "payment.succeeded",
"api_version": "2026-10-17",
"created": "2026-10-17T14:02:11.000Z",
"livemode": true,
"organization": {
"id": 1234,
"name": "Les Amis du Quartier"
},
"data": {
"payment": {
"id": 32631,
"conversion_id": "32631",
"order_id": null,
"status": "succeeded",
"amount": 50.6,
"amount_cents": 5060,
"net_amount": 49.3,
"fees_covered_amount": 0.6,
"provider_fee_amount": 1.3,
"currency": "EUR",
"refunded_amount": 0,
"kind": "donation",
"channel": "online",
"form": null,
"recurring": {
"interval": "month",
"sequence": "first",
"subscription_id": 812
},
"payment_provider": "stripe",
"payment_method": "card",
"wallet": "apple_pay",
"provider_reference": "pi_3UQZvoFXPodLa3BZ2HKqlaEw",
"donor": {
"email": "amina.k@exemple.fr",
"name": "Amina K.",
"anonymous": false
},
"created": "2026-10-17T14:01:02.000Z"
},
"attribution": {
"parameters": {
"utm_source": "google",
"utm_medium": "cpc",
"utm_campaign": "ramadan-2027",
"gclid": "Cj0KCQjw8eTFBhCXARIsAIkiuOz"
},
"landing_page": "https://www.mon-association.org/faire-un-don",
"marketing_consent": true
}
}
}Objects
The objects that can appear in data. A field with no value is null. Dates are in ISO 8601 format, in UTC.
All amounts are in euros, with two decimal places; the …_cents fields give the same amount in cents. Amounts are those of the organisation: the optional tip a donor leaves to Donoor is never included.
idinteger | Donoor identifier of the payment. |
conversion_idstring | Conversion identifier, to pass on to Google and Meta. Equal to id for a donation, and to the order number for a membership, a ticket or a shop purchase. It is also the transaction_id pushed by the widget to the dataLayer. |
order_idstring | null | Identifier of the related order (membership, ticketing, shop), otherwise null. |
statusstring | succeeded, failed, refunded, partially_refunded or disputed. |
amountnumber | Amount paid for the organisation, including fees covered by the donor. This is the value to send as the conversion value. |
amount_centsinteger | The same amount, in cents. |
net_amountnumber | null | Amount actually paid out to the organisation by Stripe or PayPal, after their fees. null until the provider has reported it. |
fees_covered_amountnumber | Share of the fees the donor chose to cover (0 otherwise). |
provider_fee_amountnumber | null | Fees withheld by Stripe or PayPal, as they report them. |
currencystring | Currency, ISO 4217 code in uppercase (EUR). |
refunded_amountnumber | Total already refunded on this payment. |
kindstring | Type of payment: donation, membership, ticket or shop. |
channelstring | Channel: online (donation page, widget), campaign (fundraiser), qr_code, tap_to_pay or offline (payment entered manually). |
formobject | null | The originating form: type (campaign, event, membership_plan, shop, qr_code), id and title. null for a donation on the donation page. |
recurringobject | null | For a monthly donation: interval, sequence (first for the first payment, renewal afterwards) and subscription_id. null otherwise. |
payment_providerstring | stripe, paypal or manual. |
payment_methodstring | null | Payment method reported by the provider: card, sepa_debit, card_present (Tap to Pay), paypal… |
walletstring | null | apple_pay or google_pay when the donor paid with it, otherwise null. |
provider_referencestring | null | Payment reference at the provider (pi_… at Stripe, PayPal order number). |
donorobject | The donor: email, name and anonymous (true if they asked to remain anonymous on the public page; you still receive their contact details). |
createdstring (ISO 8601) | Date of the payment. |
HTTP headers
Every request carries these headers. HTTP header names are case-insensitive: your server may receive them in lowercase.
| Header | Description |
|---|---|
Content-Typeapplication/json | The body is JSON encoded in UTF-8. |
User-AgentDonoor-Webhooks/1.0 (+https://donoor.org/developpeurs/webhooks) | Identifies Donoor. Do not use it for authentication: only the signature is authoritative. |
Donoor-Signaturet=1792300000,v1=f0f590ea41b7… | Timestamp and signature of the message (see Verifying the signature). |
Donoor-Event-Idevt_9mK2xQpL4vR8sT1w | Identical to the id field of the body: handy for discarding a duplicate before even reading the JSON. |
Donoor-Event-Typepayment.succeeded | Identical to the type field of the body. |
Donoor-Delivery-Attempt1 | Attempt number: 1 for the first delivery, up to 8. |
Verifying the signature
Anyone can send a request to your endpoint: always verify the signature before trusting a message. The Donoor-Signature header contains the sending timestamp (t, in seconds) and one or two v1 signatures. Each signature is a hex-encoded HMAC-SHA256 of the text “timestamp + dot + raw body”, computed with your secret.
Donoor-Signature: t=1792300000,v1=f0f590ea41b7f691921bb57c47f593d785d4e4d7d6c14f947848cb9d2d471bdf- 1
Read the RAW request body, byte for byte, before any JSON parsing.
- 2
Extract t and all the v1 values from the Donoor-Signature header.
- 3
Compute HMAC-SHA256 with your secret (including whsec_) over t + “.” + body, then compare the result with each v1 using a constant-time comparison. Only one needs to match.
- 4
Reject the message if t is more than 5 minutes away from your clock: an intercepted message cannot be replayed later.
// Node.js (Express) — the RAW body is required for the signature
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.DONOOR_WEBHOOK_SECRET; // whsec_…
const app = express();
app.post("/webhooks/donoor", express.raw({ type: "application/json" }), (req, res) => {
const entete = req.get("Donoor-Signature") ?? "";
const t = /t=(\d+)/.exec(entete)?.[1];
const signatures = [...entete.matchAll(/v1=([0-9a-f]{64})/g)].map((m) => m[1]);
const attendue = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
const valide = signatures.some((s) =>
crypto.timingSafeEqual(Buffer.from(s, "hex"), Buffer.from(attendue, "hex")));
const recent = t && Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!valide || !recent) return res.status(400).end();
const evenement = JSON.parse(req.body.toString("utf8"));
res.status(200).end(); // respond fast, process afterwards
traiter(evenement); // process the event; ignore it if its id has already been seen
});Verification example
To test your code without waiting for a real message: with these values, you must get exactly this signature.
| Secret | whsec_5wQpK9mZ2vX7rT4yN8bH3jL6fD1sA0cE |
| Timestamp (t) | 1792300000 |
| Raw body | {"id":"evt_doc0exemple0signature","object":"event","type":"test.ping"} |
| Expected v1 signature | f0f590ea41b7f691921bb57c47f593d785d4e4d7d6c14f947848cb9d2d471bdf |
The most common mistake: signing the JSON after parsing and re-serialising it. The slightest space or a different key order changes the signature. Always sign the body exactly as received.
Rotating the secret
If your secret may have been seen by someone else, replace it: Settings › Webhooks › Secret › Change the secret.
For 24 hours, messages are signed with both the old AND the new secret: the header then contains two v1 values. Install the new secret on your server during this period; after that, only the new one works.
Donoor stores the secret encrypted. It is only shown to the organisation's owners and administrators.
Accepted endpoints
- https URLs only, with a valid certificate.
- Port 443 (the default) or 8443.
- The URL must be reachable from the Internet: local or private addresses (localhost, internal network) are rejected.
- Redirects are not followed: a 3xx response counts as a failure. Provide the final URL.
- Donoor does not send from a fixed list of IP addresses: do not filter by IP, verify the signature.
Responding to a message
Respond with a 2xx code (200, 201, 204…) within 10 seconds. The content of your response is not read, but its beginning is shown in your delivery log to help you debug.
Respond first, process later: store the message, respond 200, then run long tasks (calling Google Ads, sending an email…) in the background.
| Your server's response | What Donoor does |
|---|---|
| 2xx | Success: the message is marked as succeeded in the log. |
| 3xx | Failure (redirect not followed): new attempt later. |
| 4xx, 5xx | Failure: new attempt later. |
| no response | More than 10 seconds, connection refused or invalid certificate: failure, new attempt later. |
Retries and pausing
When a delivery fails, Donoor retries automatically: 8 attempts in total, over about 28 hours.
- 1st delivery
- +1 min
- +5 min
- +30 min
- +2 h
- +5 h
- +10 h
- +10 h
- After the 8th attempt, the message is abandoned and the organisation is notified by email (at most one email per endpoint per day).
- An endpoint that accepts no message for 3 days is automatically paused, with an email. It can be reactivated in one click from the dashboard.
- Any message in the log can be resent manually for 30 days: Resend button.
Duplicates and ordering
The same event may arrive more than once: your server processed the message but its response was lost, or someone clicked Resend. The event then keeps the same id: store the ids already processed and ignore those you already know.
Delivery order is not guaranteed: in case of a retry, a refund may arrive before the original payment. Rely on the created field and on the objects' statuses, not on the order of receipt.
For an advertising conversion, conversion_id is enough: Google and Meta themselves ignore a conversion already received with the same identifier.
Limits
| Endpoints per organisation | 5 |
| Maximum response time | 10 seconds |
| Attempts per message | 8, over about 28 h |
| Automatic pausing | after 3 days without success |
| Test messages | 20 per hour |
| Simulations | 60 per hour |
| Manual resends | 100 per hour |
| Messages per day | 20,000 per organisation |
| Delivery log retention | 30 days |
| Attribution retention | 400 days |
Measuring your campaigns
The payment.succeeded message gives you the amount, the currency, the conversion identifier and the parameters of the originating ad. Your server then passes the conversion on to the ad platform: this is reliable even when the browser blocks cookies or trackers.
Google Analytics 4
Send a purchase event through the Measurement Protocol: transaction_id = conversion_id, value = amount, currency = currency.
Google Ads
With gclid (or gbraid / wbraid) in attribution.parameters: upload an offline conversion through the Google Ads API, with order_id = conversion_id to avoid duplicates. Enhanced conversions can use the donor's email, hashed with SHA-256.
Meta (Facebook, Instagram)
Through the Conversions API: event_name = Purchase, event_id = conversion_id, value and currency; fbc is built from fbclid. The donor's email is sent hashed with SHA-256.
Without a developer
Paste the URL of a Make or Zapier scenario (“Custom webhook” module): you then connect it to Google Ads, GA4, Meta, Google Sheets or your CRM, without writing any code.
Widget on your website
When the donation is made in the Donoor widget embedded on your website, the page also receives the donoor_payment event in the Google Tag Manager dataLayer, with no personal data and with the same transaction_id as conversion_id: browser and server count only one conversion.
CRM and internal tools
Use donor.email as the key to find the record, kind to know whether it is a donation, a membership, a ticket or a purchase, and recurring to identify your monthly donors.
Example: a donation sent to GA4
POST https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXX&api_secret=…
{
"client_id": "donoor.32631",
"events": [{
"name": "purchase",
"params": {
"transaction_id": "32631", // data.payment.conversion_id
"value": 50.6, // data.payment.amount
"currency": "EUR", // data.payment.currency
"campaign": "ramadan-2027" // data.attribution.parameters.utm_campaign
}
}]
}Payment attribution
Add your tracking parameters to your ad links, whether they lead to your Donoor page or to the page of your website that shows the Donoor widget:
https://donoor.org/pay/mon-association?utm_source=google&utm_campaign=ramadan-2027&gclid=Cj0KCQjw…- All the link parameters are kept until payment, even if the donor opens the payment page in a new tab.
- They arrive in data.attribution of payment.succeeded, and of the other events for the same payment (refund, dispute, monthly donation).
- For a monthly donation renewal, the attribution is that of the first payment.
- Parameters that look like sensitive data (email, password, token, phone number, IBAN…) and Donoor's technical parameters are discarded. At most 25 parameters, 300 characters per value.
- attribution is null when the donor did not arrive through a link with parameters.
Consent
attribution.marketing_consent indicates whether the donor accepted advertising cookies on Donoor: true, false, or null if unknown.
In Europe, Google and Meta require you to respect this choice (Google Consent Mode, GDPR). Your organisation remains responsible for how it uses this data: do not send the email of a donor who refused, and remember to mention this processing in your privacy policy.
Tests, simulations and log
The Send a test button, on each endpoint, immediately sends a test.ping event signed like all the others. It lets you check that your server is reachable and that your signature is correct.
The Simulate an event button, on each endpoint, sends any of the 28 events in the exact format, with dummy data and livemode set to false, to that endpoint only. A preview shows the message before it is sent. No payment is made and no real data is sent.
To develop safely, tick Test endpoint in the endpoint window: it then only receives simulations and test messages, never real events. Connect your team's development server to it, and keep another endpoint for production.
The delivery log shows every message from the last 30 days: event, status, number of attempts, HTTP code and beginning of your server's response, and the exact content sent. Filter by succeeded, failed or pending, and resend a message in one click.
Test environment
The simulator is enough to develop your webhooks. To also test the full payment journey (donation page, test card, receipt), Donoor provides a separate test environment at sandbox.donoor.org. Its pages are not indexed by search engines.
| Production | Test | |
|---|---|---|
| Website | donoor.org | sandbox.donoor.org |
| Dashboard | dashboard.donoor.org | dashboard-sandbox.donoor.org |
| Payments | real | fictitious, card 4242 4242 4242 4242 |
| livemode | true | false |
| Emails | normal | subject prefixed with “[TEST]” |
- Create an organisation account from sandbox.donoor.org (Register my organisation button): it contains no real data.
- Connect Stripe in Settings › Fundraising process. In test mode, Stripe accepts dummy information (SMS code: 000000): no real bank account is needed.
- Your test payment page goes live straight away, for example sandbox.donoor.org/pay/your-organisation, as do your test fundraisers, ticketing and shops.
- Add your webhook endpoint in Settings › Webhooks, just as in production.
- Make a donation on this page with the test card 4242 4242 4242 4242 (any future date, any code): you receive real signed messages, with livemode set to false. The test secret is different from the production one.
Format versions
The api_version field indicates the version of the message format. It only changes in case of a breaking change (a field renamed or removed, a meaning modified), announced in advance.
Without changing the version, Donoor may add new events and new fields: your code must ignore anything it does not recognise.
| Version | Changes |
|---|---|
| 2026-10-17 | First public version: 28 events, HMAC-SHA256 signature, payment attribution. |
Troubleshooting
I am not receiving any messages
Check in the dashboard that the endpoint is Active and that the expected event is selected, then send a test message: the log will then show your server's response or the error encountered (certificate, timeout, unreachable URL). Your firewall must accept incoming requests from the Internet.
The signature never matches
9 times out of 10, the body was parsed and re-serialised before the computation: use the raw body. Also check that you are using the full secret, including the whsec_ prefix, and the one for the right endpoint (each endpoint has its own secret). The verification example above lets you test your code.
The log shows a 301 or 302 response
Your server redirects the request (often from http to https, or to a URL with or without a trailing slash). Redirects are not followed: provide the exact final URL.
I receive the same payment twice
This is normal in case of a retry or a resend: the event has the same id, ignore it. If the ids are different, they are two distinct events, for example order.confirmed and payment.succeeded for the same order.
I do not receive payments made before my endpoint was added
This is intentional: only events that occur after the first endpoint is added are sent. For past history, use the dashboard exports.
The amount does not match what the donor paid
amount is the amount for the organisation: the optional tip left to Donoor is not included, but fees covered by the donor are. What actually reaches your bank account is net_amount, after Stripe or PayPal fees.
Any questions?
Our team supports your technical team throughout the integration.
Contact us