Webhooks
Subscribe to organisation events via signed HTTP callbacks. Every delivery carries a Standard Webhooks signature; verify it, handle it, move on.
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.
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 dishonour, transfer fail)
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.
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 split-giving donations (source: SPLIT_GATEWAY), one Donationrow fires per recipient, and each row's amount_centsis the donor's gross intent toward that recipient - NOT the post-fee amount transferred. Platform fees are not surfaced on these events.
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.
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+).