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