Skip to main content
Together
Sign in

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"],
    },
)
Our signing secret is base64-encoded UTF-8 of the raw value. Standard Webhooks libraries expect the key as base64; wrap ours with 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.

AttemptDelay since previousTotal elapsed
1Immediate0
21 minute1 min
35 minutes6 min
430 minutes36 min
53 hours3h 36min
624 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-timestamp older than five minutes. Treat it as invalid.
  • PII is included. donor.created ships 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:

/developer/api - Webhooks

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.

Subscriptions are explicit. Add an endpoint at /settings/integrations/webhooks and tick only the event types you want. There is no wildcard, and events you do not subscribe to are never delivered.

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

SymptomLikely cause
Signature always failsSecret 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 attemptsAll six attempts failed. Check your recent responses in the delivery log and confirm your endpoint is up + returns 2xx.
No events arrivingEndpoint paused, revoked, or subscribed to the wrong event types. Check the row at /settings/integrations/webhooks.
"URL must be HTTPS in production" on saveYour 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 saveYour URL resolves to an RFC1918 / loopback / link-local / CGNAT / metadata IP. Host on a publicly routable service.

Related