Platform · Integrations

Receiving webhooks you don't control the shape of

Sending a webhook is easy. Receiving one from a system whose payload format you don't control, and turning it into a write against a second system that can be down, is a different job.

The integration

Marketing defines audience cohorts in an analytics product. Push notifications are sent from a separate messaging platform. The two don't know about each other, so someone was exporting lists by hand — which meant campaigns targeted whoever was in the cohort on the day somebody last remembered to export.

The fix is a webhook endpoint: the analytics tool posts cohort membership changes, and the endpoint turns them into user tags in the messaging platform.

Problem one: there isn't one payload shape

The vendor sends different structures depending on how the cohort is configured and which feature triggered the call. A single membership change, an array of them, and a bulk members payload nested under a parameters object — all to the same endpoint.

The temptation is a branching parser. What works better is normalising at the boundary and having exactly one internal shape downstream:

// Every accepted shape collapses to the same list before anything else runs.
function normalise(payload) {
  if (Array.isArray(payload)) return payload.flatMap(normalise);

  if (payload.action === 'members') {
    const { cohort_name, members } = payload.parameters;
    return members.map(m => ({
      userId: m.distinct_id, cohort: cohort_name, action: 'add',
    }));
  }

  return [{ userId: payload.user_id, cohort: payload.cohort,
            action: payload.action }];
}

Everything after this handles one kind of object. When the vendor adds a fourth shape — and they will — it's one more case in one function rather than a change spread across the handler.

Problem two: it will be delivered more than once

Webhook delivery is at-least-once everywhere. Retries after a timeout, duplicate sends during an incident, someone replaying events from the vendor's dashboard. If applying the same membership change twice does something different from applying it once, you have a bug waiting on a bad afternoon.

Tag updates are naturally idempotent — adding a tag someone already has is a no-op. But the calls aren't free, and the sync record shouldn't double-count. Persisting sync state per user and cohort means a repeat delivery is recognised and skipped rather than re-sent.

Idempotency isn't only about correctness. A duplicate that produces the right state but doubles your API calls against a rate-limited vendor is still an incident.

Problem three: the other system goes down

The endpoint's job isn't finished when it has parsed the payload — it has to write to a third party that has its own outages and rate limits. Two rules that keep this from becoming your outage:

  • Acknowledge the sender quickly. Doing the downstream write inline means the vendor's timeout is now your latency budget. Ack, then process.
  • Retry with exponential backoff, bounded. Immediate retries against a system that's rate-limiting you make it worse. Unbounded retries turn a temporary failure into a permanent queue.

And return a summary the caller can act on — how many synced, skipped and failed. A bare 200 tells whoever set the integration up nothing about whether it's working.

Authentication, briefly

The endpoint is public by necessity. It accepts a shared secret in a header, with basic auth as a fallback because not every vendor lets you set custom headers.

Worth being honest that this is weaker than the signature verification you'd use for a payment webhook. A shared secret proves the caller knows a string; a signature proves the payload wasn't altered. Use the strongest thing the vendor supports, and know which one you've got.

← All engineering notes