Technical documentation

Webhooks

Webhooks automatically send a signed message to your server for every event of your organisation on Donoor: donation received, refund, membership, ticket scanned, order, tax receipt, payout… Your system is notified within seconds, without anyone having to export anything.

api_version 2026-10-17HTTPS · JSON · HMAC-SHA25628 events

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. 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. 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. 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. 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. 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.

Prefer a video?The Webhooks training shows every step on video, from the initial setup to monitoring deliveries.

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

TypeSent when…Content
payment.succeededA 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.failedA card or direct debit is declined, for example the renewal of a monthly donation.paymentattribution
payment.refundedA payment is refunded, fully or partially. One message per refund.paymentrefundattribution
dispute.openedA donor disputes a payment with their bank.paymentdisputeattribution
dispute.closedThe dispute is closed, won or lost.paymentdisputeattribution

Monthly donations

TypeSent when…Content
subscription.createdA donor starts a monthly donation.subscriptionattribution
subscription.past_dueThis month's charge did not go through: the monthly donation is awaiting payment.subscriptionattribution
subscription.reactivatedAn unpaid monthly donation has been paid again.subscriptionattribution
subscription.canceledA monthly donation is stopped.subscriptionattribution

Orders (memberships, ticketing, shop)

TypeSent when…Content
order.confirmedA membership, tickets or a shop purchase are confirmed, including free ones.order
order.pending_approvalA membership request is awaiting your approval.order
order.rejectedYou rejected a membership request.order
order.canceledAn order is canceled.order
order.expiredAn order was not paid in time and has expired.order
order.fulfillment_updatedA shop order moves to a new step: being prepared, shipped (with the tracking number), delivered or picked up.order

Members and tickets

TypeSent when…Content
membership.createdA membership card becomes active.membership
membership.canceledA membership is canceled.membership
ticket.checked_inA ticket is scanned at the entrance of your event.ticket
ticket.canceledA ticket is canceled.ticket

Refund requests

TypeSent when…Content
refund_request.createdA donor or buyer asks you for a refund.refund_request
refund_request.updatedA refund request changes status: answered, refunded or refused.refund_request

Tax receipts

TypeSent when…Content
fiscal_receipt.issuedA tax receipt is issued for a donor.fiscal_receipt
fiscal_receipt.voidedA tax receipt is voided, for example after a refund.fiscal_receipt

Bank payouts

TypeSent when…Content
payout.paidStripe or PayPal has transferred money to the organisation's bank account.payout
payout.failedA transfer to the organisation's bank account has failed.payout

Forms and fundraisers

TypeSent when…Content
form.createdA fundraiser, event, membership plan, shop, payment link or QR code is created.form
campaign.goal_reachedA fundraiser reaches its goal.campaign

Organisation account

TypeSent when…Content
organization.payment_account_updatedThe organisation's Stripe or PayPal account is enabled or suspended.payment_account

Test

TypeSent when…Content
test.pingTest 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.

FieldTypeDescription
idstringUnique identifier of the event (evt_…). It stays the same on every retry and every resend: this is what lets you ignore a duplicate.
objectstringAlways event.
typestringEvent type, for example payment.succeeded.
api_versionstringVersion of the message format. It only changes for a breaking change.
createdstring (ISO 8601)Date the event was created, in UTC.
livemodebooleantrue for a real event; false for a simulation or in the test environment. Never count a livemode false message as a real payment.
organizationobjectThe organisation concerned: id (Donoor number) and name.
dataobjectThe 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.

idintegerDonoor identifier of the payment.
conversion_idstringConversion 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 | nullIdentifier of the related order (membership, ticketing, shop), otherwise null.
statusstringsucceeded, failed, refunded, partially_refunded or disputed.
amountnumberAmount paid for the organisation, including fees covered by the donor. This is the value to send as the conversion value.
amount_centsintegerThe same amount, in cents.
net_amountnumber | nullAmount actually paid out to the organisation by Stripe or PayPal, after their fees. null until the provider has reported it.
fees_covered_amountnumberShare of the fees the donor chose to cover (0 otherwise).
provider_fee_amountnumber | nullFees withheld by Stripe or PayPal, as they report them.
currencystringCurrency, ISO 4217 code in uppercase (EUR).
refunded_amountnumberTotal already refunded on this payment.
kindstringType of payment: donation, membership, ticket or shop.
channelstringChannel: online (donation page, widget), campaign (fundraiser), qr_code, tap_to_pay or offline (payment entered manually).
formobject | nullThe originating form: type (campaign, event, membership_plan, shop, qr_code), id and title. null for a donation on the donation page.
recurringobject | nullFor a monthly donation: interval, sequence (first for the first payment, renewal afterwards) and subscription_id. null otherwise.
payment_providerstringstripe, paypal or manual.
payment_methodstring | nullPayment method reported by the provider: card, sepa_debit, card_present (Tap to Pay), paypal…
walletstring | nullapple_pay or google_pay when the donor paid with it, otherwise null.
provider_referencestring | nullPayment reference at the provider (pi_… at Stripe, PayPal order number).
donorobjectThe 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.

HeaderDescription
Content-Typeapplication/jsonThe 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_9mK2xQpL4vR8sT1wIdentical to the id field of the body: handy for discarding a duplicate before even reading the JSON.
Donoor-Event-Typepayment.succeededIdentical to the type field of the body.
Donoor-Delivery-Attempt1Attempt 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. 1

    Read the RAW request body, byte for byte, before any JSON parsing.

  2. 2

    Extract t and all the v1 values from the Donoor-Signature header.

  3. 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. 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.

Secretwhsec_5wQpK9mZ2vX7rT4yN8bH3jL6fD1sA0cE
Timestamp (t)1792300000
Raw body{"id":"evt_doc0exemple0signature","object":"event","type":"test.ping"}
Expected v1 signaturef0f590ea41b7f691921bb57c47f593d785d4e4d7d6c14f947848cb9d2d471bdf

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 responseWhat Donoor does
2xxSuccess: the message is marked as succeeded in the log.
3xxFailure (redirect not followed): new attempt later.
4xx, 5xxFailure: new attempt later.
no responseMore 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.

  1. 1st delivery
  2. +1 min
  3. +5 min
  4. +30 min
  5. +2 h
  6. +5 h
  7. +10 h
  8. +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 organisation5
Maximum response time10 seconds
Attempts per message8, over about 28 h
Automatic pausingafter 3 days without success
Test messages20 per hour
Simulations60 per hour
Manual resends100 per hour
Messages per day20,000 per organisation
Delivery log retention30 days
Attribution retention400 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.

ProductionTest
Websitedonoor.orgsandbox.donoor.org
Dashboarddashboard.donoor.orgdashboard-sandbox.donoor.org
Paymentsrealfictitious, card 4242 4242 4242 4242
livemodetruefalse
Emailsnormalsubject prefixed with “[TEST]”
  1. Create an organisation account from sandbox.donoor.org (Register my organisation button): it contains no real data.
  2. Connect Stripe in Settings › Fundraising process. In test mode, Stripe accepts dummy information (SMS code: 000000): no real bank account is needed.
  3. Your test payment page goes live straight away, for example sandbox.donoor.org/pay/your-organisation, as do your test fundraisers, ticketing and shops.
  4. Add your webhook endpoint in Settings › Webhooks, just as in production.
  5. 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.

VersionChanges
2026-10-17First 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