Webhooks
Subscribe to organisation events via signed HTTP callbacks. Every delivery carries a Standard Webhooks signature; verify it, handle it, move on.
together:event when a donation completes - no server required. See Track donations in Meta Pixel or Google Analytics. These webhooks remain the source of truth for settled money (including bank debits that clear days later).Shape
Each event is delivered as a POST with Content-Type: application/json and three signature headers. The body is the JSON payload for the event type, nothing wrapping it.
POST https://your-service.example.com/hook
webhook-id: dlv_01H9Z...
webhook-timestamp: 1776990152
webhook-signature: v1,kr4Q3...
content-type: application/json
user-agent: Together-Webhooks/1.0
{
"type": "donor.created",
"occurred_at": "2026-04-24T00:13:54.456Z",
"organisation_id": "cm3org0001...",
"data": {
"id": "cm3don0001...",
"donor_type": "INDIVIDUAL",
"email": "alice@example.com",
"first_name": "Alice",
"last_name": "Ng",
"organisation_name": null
}
}Verify the signature
Use the Standard Webhooks library for your language. Pass the signing secret shown once at endpoint creation, the raw request body, and the three headers. Reject any request whose signature does not verify - or whosewebhook-timestamp is more than five minutes old.
// Node.js
import { Webhook } from "standardwebhooks";
const wh = new Webhook(
Buffer.from(process.env.TOGETHER_WEBHOOK_SECRET, "utf8").toString("base64"),
);
export async function POST(req: Request) {
const raw = await req.text();
try {
const event = wh.verify(raw, {
"webhook-id": req.headers.get("webhook-id")!,
"webhook-timestamp": req.headers.get("webhook-timestamp")!,
"webhook-signature": req.headers.get("webhook-signature")!,
});
// event is the parsed payload
await handle(event);
return new Response("", { status: 200 });
} catch {
return new Response("invalid signature", { status: 400 });
}
}# Python
from standardwebhooks import Webhook
wh = Webhook(base64.b64encode(os.environ["TOGETHER_WEBHOOK_SECRET"].encode()))
event = wh.verify(
request.body,
{
"webhook-id": request.headers["webhook-id"],
"webhook-timestamp": request.headers["webhook-timestamp"],
"webhook-signature": request.headers["webhook-signature"],
},
)Buffer.from(secret, "utf8").toString("base64") (Node) or the equivalent before passing it in.Respond quickly
- Return a 2xx within 10 seconds. Body content is ignored.
- Queue heavy work for background processing. A slow response ties up delivery workers for every customer sharing the window.
- Any non-2xx, or a timeout, schedules a retry. Return 2xx even if the event is a duplicate (see Idempotency below).
Retries and backoff
Failed deliveries retry automatically with exponential backoff. Six attempts total (initial + 5 retries); after the last, the delivery is marked permanently failed.
| Attempt | Delay since previous | Total elapsed |
|---|---|---|
| 1 | Immediate | 0 |
| 2 | 1 minute | 1 min |
| 3 | 5 minutes | 6 min |
| 4 | 30 minutes | 36 min |
| 5 | 3 hours | 3h 36min |
| 6 | 24 hours | ~28h |
Past 28 hours without a 2xx, the delivery stays failed. A manual replay from the admin UI creates a brand-new delivery with a newwebhook-id.
Idempotency
webhook-id uniquely identifies a delivery, not an event. On retry you receive the same webhook-id for the same delivery, so deduping on it is safe and strongly recommended.
- Store processed
webhook-ids for at least 24 hours. - On a second arrival with the same id, return 2xx without re-processing.
- Cross-delivery dedup (e.g. two manual replays of the same underlying event) is the application's responsibility - look at the event body if that matters for your integration.
Security
- HTTPS required in production. Plaintext delivery would expose the signed payload and headers to any on-path observer.
- Private IPs are blocked at source. We enforce SSRF protection on every outbound request; endpoint URLs that resolve to RFC1918 / loopback / link-local / CGNAT / cloud metadata ranges are rejected at both save and delivery time.
- Verify every signature. An attacker can send POSTs to your endpoint URL - the signature is what proves it came from us.
- Reject stale timestamps. A replayed-from-capture request will have a
webhook-timestampolder than five minutes. Treat it as invalid. - PII is included.
donor.createdships email and names so receivers can act without a second API call. Only add endpoints you trust and terminate TLS on infrastructure you control.
Event catalogue
Every event type - name, description, and full payload schema - lives in the interactive API reference. It's generated from the same source of truth that the emission code uses, so the list is always exhaustive and never drifts:
Click an event in the side panel to expand the payload schema with every field, whether it's nullable, enum values, and response semantics. Pair that with the conceptual sections on this page (signing, retries, idempotency, security) for the full picture.
At a glance, the eight event families (only the donation lifecycle gets a prose walkthrough on this page - every family is fully specified in the reference):
| Event family | Events |
|---|---|
donor.* | created, updated, archived, merged |
donation.* | created, processing, succeeded, refunded, refund_reversed, failed (lifecycle walkthrough below) |
donation_form.* | created, updated, archived |
checkout_link.* | created, updated, cancelled, archived |
disclosure.* | created, updated (compliance module) |
recommendation_set.* | created, updated, deactivated, archived, payment_succeeded, payment_failed, distribution_succeeded, distribution_failed |
recipient_org_account_link.* | created, updated, deactivated |
subscription.* | created, cancelled, payment_failed — donor recurring gifts only. The cancelled event carries data.cancel_reason from the Stripe field cancellation_details.reason when it is one of cancellation_requested, payment_disputed, or payment_failed. Any other value, or a missing reason, is delivered as null: treat it as unknown and never guess a win-back message. It also carries data.donor_cancel_reason_code and data.donor_cancel_reason, which are non-null ONLY when the donor cancelled the gift themselves in the donor portal: the code is one of cost, giving_differently, moving_on, process or prefer_not_to_say, and the reason is the donor’s own optional sentence (up to 500 characters). Both fields are also present on subscription.created and subscription.payment_failed, where they are always null — those events describe a gift that has not ended. Both are null for a staff cancel, a dunning-exhausted ending and a cancel made in the Stripe Dashboard — so the presence of the code, not cancel_reason (which reads cancellation_requested for a staff cancel too), is what identifies a donor-initiated ending. Answering is optional, so a donor-initiated ending can still carry a null code. subscription.payment_failed fires once per failed invoice on a live gift — a renewal, or the very first charge on it — with data.status PAST_DUE and data.donation_id pointing at the same Donation row the matching donation.failed carries as data.id. It is not emitted for a gift that had already ended when the failure was applied — subscription.cancelled reports that. Deliveries are not ordered across event types, so reconcile on occurred_at and data.status rather than arrival order. Stripe retries on its own schedule, so do not prompt the donor to give again off this event: a second gift can land days before the retry succeeds. |
introduction.* | created, viewed, accepted, linked, expired, revoked (state machine: /developer/introductions) |
Donation lifecycle
Every payment-creating path emits the same outbound sequence so you can write one dispatch routine and have it work for cards, BECS Direct Debit, PayTo, recurring renewals, distributed allocations, and CRM-sync ingest:
donation.created → row exists in Together; Stripe call may still be in flight donation.processing → BECS / PayTo entered the bank network (skipped for cards) donation.succeeded → funds settled donation.failed → terminal failure (Stripe error, card decline, BECS / PayTo dishonour)
donation.created fires synchronously with the donor pressing "Donate" - BEFORE we call Stripe - so even if Stripe is unreachable you see the attempt. If the Stripe call then errors, the very next event for that id is donation.failed with failure_code: "stripe_unreachable" (or the Stripe error code if the API rejected). The donor was not charged.
For paths with no in-flight phase (subscription renewals, NB / Raisely sync, manual entry) donation.created and donation.succeeded fire back-to-back at row-write time. Card forms emit created then succeeded in the same second. BECS and PayTo emit created then processing immediately, then succeeded or failed hours-to-days later when the bank settles.
A bank transfer (payment_method: "bank_transfer") emits created when the donor submits (the gift is pending), then succeeded when their funds arrive - up to two weeks later. Its donation_date is provisional until then: on donation.succeeded it is the date the funds arrived, which can fall in a different financial year or cap window from the date donation.created carried. Store the date from donation.succeeded. A transfer whose funds never arrive lapses 14 days after the donor submitted it and emits donation.failed instead, with failure_code: "bank_transfer_lapsed".
Each event type is dedup-keyed on the donation id, so webhook re-deliveries of the same Stripe event collapse to one outbound delivery per type per donation.
The donation's revenue code is on data.revenue_code (and its canonical FK data.revenue_code_id). The older data.tracking_code / data.tracking_code_id fields carry the identical value and are kept as deprecated aliases so existing receivers keep working after the tracking-code → revenue-code rename; they will be removed in a future major version. New integrations should read revenue_code / revenue_code_id.
Recurring donations carry data.subscription_id (the local Together subscription id, not a Stripe id) and data.is_recurring: true. One-off donations have subscription_id: null and is_recurring: false. data.donation_form_id and data.campaign_id are the originating form and campaign attribution when they exist.
data.consent_given is tri-state: true or false when the donor was shown a consent prompt and made a choice; null when the surface they gave through showed no prompt at all (for example, a sync-ingested donation). Do not collapse null to false — that would report a donor as having declined when they were never asked. A recurring donor answers once, when the regular gift is set up, and every renewal reports that same answer.
An organisation can ask more than one question at donation time. data.consent_responses is an array carrying every one the donor answered, in the order they were shown; each entry has prompt_id, text, accepted and recorded_at. text is the wording exactly as the donor saw it, recorded at the time — it does not move when the organisation later rewords the prompt. accepted: false is a recorded decline (a box shown and left unticked), which is a different fact from the question never being asked, which is an absent entry.
data.consent_given keeps its exact meaning: the first prompt's answer. It is not removed and not redefined — read consent_responses when you need the rest. prompt_id is null where the wording has no prompt record in your organisation: a recipient organisation's copy of a distribution donor's answer, and anything recorded before prompt ids were carried.
Consent here is evidence. Together records what was asked and what the donor answered; it grants no marketing permission and flips no flags on your behalf.
data.differs_from_record tells you whether the donor details typed for this gift differ from your canonical donor record as it stood when the event fired. It is tri-state: true or false when the gift carried submitted details, and null either when it carried none at all (a recurring renewal, a CRM-synced gift, a checkout-link gift) or when we could not make the comparison (we log that our side). Do not collapse null to false — null is "we are not telling you", which is a different fact from "matches". No donor values ride on the event: fetch them from GET /api/v1/donations/{id} under your READ key, where they arrive as submitted_donor, so the PII is an explicit request rather than something riding every delivery.
Two things to know before you act on a true. The values are unverified — they are whatever the person at the keyboard typed, and Together never writes them onto a contact by itself; email in particular is the identity anchor the donation was matched on, so compare it and never apply it. And on a recommendation-set donation (source: RECOMMENDATION_SET, recommendation_set_id is the tell) the values were typed on the recommending organisation's giving form at donation_date, not on one of yours — so true there does not mean your donor sent you new details.
data.amount_cents is the donor's gross intent - what the donor chose to give - and is the same number across the created and succeeded events for a given donation. For recommendation-set donations (source: RECOMMENDATION_SET), one Donation row fires per recipient, and each row's amount_cents is the donor's gross intent toward that recipient - NOT the post-fee amount transferred. Platform fees are not surfaced on these events.
Recommendation-set donations also carry their linkage as named fields: data.recommendation_set_id (the set the donation was distributed from) and data.allocation_id (the per-recipient allocation), null for every other source. The matching recommendation_set.distribution_* events carry data.donation_id in the other direction, so a receiver consuming both streams can join them directly - no need to decode the external_id encoding. Note the trust boundary: the set belongs to the recommending organisation, so a recipient org seeing recommendation_set_id on its donation events should treat it as an opaque correlation id.
Breaking change (recommendation-set events). The recommendation_set.payment_* / recommendation_set.distribution_* payloads renamed their identifier fields to data.recommendation_set_payment_id and data.allocation_id (previously data.split_payment_id / data.split_allocation_id), and the donation source value SPLIT_GATEWAY became RECOMMENDATION_SET (on every donation.* event and GET /api/v1/donations). Unlike the tracking-code rename above, these are hard renames with no deprecated aliases- update any receiver that reads the old field names or filters on SPLIT_GATEWAY.
Sandbox
Outbound webhooks fire from sandbox organisations the same way they do from live - that's the whole point of the sandbox. Configure endpoints against your sandbox org (slug ending in -sandbox) while wiring up; switch to the live org when ready.
Endpoints and signing secrets are scoped to the organisation that created them: a sandbox endpoint's secret is distinct from any live endpoint's, and neither verifies the other's deliveries. Hold both per environment (like API keys) and select by deployment.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Signature always fails | Secret not base64-encoded when passed to the Standard Webhooks library. Or you're verifying against a parsed body instead of the raw bytes. |
| Events stop after a few attempts | All six attempts failed. Check your recent responses in the delivery log and confirm your endpoint is up + returns 2xx. |
| No events arriving | Endpoint paused, revoked, or subscribed to the wrong event types. Check the row at /settings/integrations/webhooks. |
| "URL must be HTTPS in production" on save | Your URL uses http://. Switch to https://. We do allow http:// in the sandbox environment for local tunnelling, but not in the live org. |
| "Hostname resolves to a private IP" on save | Your URL resolves to an RFC1918 / loopback / link-local / CGNAT / metadata IP. Host on a publicly routable service. |
Related
- Error envelope and codes - what the public API returns; different shape than the webhook payload.
- Sandbox - recommended environment for wiring up a new integration.
- /settings/integrations/webhooks - manage your endpoints (admin only, Grow+).