Skip to main content
Sign in

Webhooks

Subscribe to organisation events via signed HTTP callbacks. Every delivery carries a Standard Webhooks signature; verify it, handle it, move on.

Need client-side conversion tracking (Meta Pixel, GTM) on an embedded donation form? The embed also emits a browser-side 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"],
    },
)
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.

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 familyEvents
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)
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 / 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

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