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 — donor recurring gifts only. The cancelled event carries data.cancel_reason from Stripe's 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. |
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.createdfires 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.
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 aliasesso 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 the consent checkbox and made a choice; null when the surface they gave through showed no checkbox at all (for example, a sync-ingested donation or a renewal where the checkbox was not re-presented). Do not collapse null to false — that would report a donor as having declined when they were never asked.
data.amount_centsis 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+).