Glossary/Attribution mechanics
Webhook
Definition
A webhook is an HTTP request a service sends to a URL you control when an event occurs, delivering the event's data without your system having to poll for it.
In attribution it is how an install, a re-engagement or a conversion reaches your warehouse, your CRM or your Slack channel seconds after it happens rather than in tomorrow's export. The mechanics are simple enough to underestimate: the three things that decide whether a webhook integration is reliable are signature verification, idempotent handling, and responding before you do any work.
What a delivery looks like
A POST with a JSON body and a small set of headers. The headers carry everything needed to verify and de-duplicate the delivery, which is why a receiver that only reads the body is missing half the contract.
POST /hooks/attribution HTTP/1.1
Content-Type: application/json
X-Event-Id: evt_01HQ8ZK3M4N5P6Q7R8S9T0
X-Event-Type: install.attributed
X-Timestamp: 1755388800
X-Signature: sha256=8f4b2c1d9e6a3f7b5c2d8e1a4f9b6c3d7e2a5f8b1c4d9e6a3f7b5c2d8e1a4f9b
{
"id": "evt_01HQ8ZK3M4N5P6Q7R8S9T0",
"type": "install.attributed",
"created_at": "2026-08-17T09:20:00Z",
"data": {
"install_id": "ins_9f2c1a",
"platform": "android",
"attributed_at": "2026-08-17T09:19:58Z",
"attribution_type": "deterministic",
"click_id": "clk_4b7d2e",
"campaign": { "source": "email", "medium": "newsletter", "name": "spring" },
"deep_link": "https://example.com/products/42",
"referrer": "utm_source=email&utm_campaign=spring"
}
}| Header | Purpose | If you ignore it |
|---|---|---|
X-Event-Id | Stable identifier for the event | Retries create duplicate rows |
X-Event-Type | Routing without parsing the body | Every handler parses everything |
X-Timestamp | Replay protection | A captured request replays forever |
X-Signature | HMAC over timestamp and body | Anyone who learns the URL can post to it |
Content-Type | application/json | Framework-dependent parsing surprises |
An unauthenticated webhook endpoint is a public write API
The URL is not a secret — it travels through logs, proxies and config files. Without signature verification, anyone who learns it can post fabricated installs into your reporting and your downstream automation. Verify before you parse.
Verifying the signature
The standard construction is an HMAC-SHA256 over the timestamp and the raw request body, compared in constant time. Two details do the work: signing the raw bytes rather than re-serialised JSON, and rejecting old timestamps so a captured request cannot be replayed.
import crypto from "node:crypto";
// Read the RAW body. Re-serialising parsed JSON changes key order
// and whitespace, so the signature will never match.
app.post("/hooks/attribution",
express.raw({ type: "application/json" }),
(req, res) => {
const timestamp = req.get("X-Timestamp");
const signature = req.get("X-Signature");
// Reject anything older than five minutes: replay protection.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(400).send("stale");
}
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(timestamp + "." + req.body) // raw Buffer
.digest("hex");
// Constant-time compare. === leaks timing information.
const ok = signature?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.status(401).send("bad signature");
// Acknowledge FIRST, then work. See the next section.
res.status(200).send("ok");
enqueue(JSON.parse(req.body.toString()));
});| Mistake | Consequence |
|---|---|
| Signing re-serialised JSON instead of raw bytes | Signatures never match; teams disable verification to ship |
Comparing with === | Timing side channel on the secret |
| No timestamp check | A captured request is replayable indefinitely |
| Body parser installed globally | The raw body is gone before the handler runs |
Retries, ordering and idempotency
Delivery is at-least-once, not exactly-once, and not ordered. Any sender worth using retries on failure, which means your receiver will see the same event twice — usually on the day something downstream is already slow.
| Property | Reality | What the receiver must do |
|---|---|---|
| Delivery count | At least once | Key on X-Event-Id and ignore repeats |
| Ordering | Not guaranteed | Order by created_at, never by arrival |
| Retry schedule | Exponential backoff over hours | Return 2xx only when you have durably accepted it |
| Timeout | Typically a few seconds | Acknowledge first, process asynchronously |
| Disabling | Endpoints failing for long enough get disabled | Alert on delivery failure, not only on processing errors |
| Payload growth | New fields are added over time | Ignore unknown fields rather than failing validation |
Do the work after you respond
A handler that writes to the warehouse, calls a CRM and posts to Slack before returning 200 will exceed the sender's timeout on its worst day, be retried, and do all of it twice. Validate, enqueue, return 200, then process. This is the single change that turns a flaky webhook integration into a boring one.
Idempotency is cheap to implement and expensive to retrofit: a unique index on the event ID, and an insert that ignores conflicts, is enough for most receivers.
CREATE TABLE webhook_events (
event_id text PRIMARY KEY,
event_type text NOT NULL,
created_at timestamptz NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
-- A retry hits the primary key and does nothing.
INSERT INTO webhook_events (event_id, event_type, created_at, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (event_id) DO NOTHING;Webhooks against the alternatives
| Webhook | Polling an API | Batch export | |
|---|---|---|---|
| Latency | Seconds | Poll interval | Hours |
| Cost at low volume | Very low | Wasteful | Low |
| Needs a public endpoint | Yes | No | No |
| Survives receiver downtime | Via sender retries | Yes — you control the schedule | Yes |
| Backfill and replay | Usually limited | Trivial | Trivial |
| Good for | Real-time reaction to events | Reconciliation | Analytics and warehousing |
The mature setup uses two of them: webhooks for anything that must react quickly — routing a new install to a lifecycle campaign, alerting on a fraud signal — and a periodic reconciliation pass against the API or an export, so a delivery lost during an outage is repaired without anyone noticing. Treating webhooks as the sole source of truth is what makes a missed delivery permanent.
Test with the real payload, not a hand-written one
Capture a genuine delivery, replay it against a local endpoint through a tunnel, and confirm your signature check passes with the raw bytes. Most integrations that fail in production fail on the difference between a real body and the pretty-printed JSON someone pasted into a test.
Free deep linking tools
Webhook delivery is configured per project in the dashboard, and the receiver contract above is the part you build. While you are wiring up attribution, the free tools cover the link side — association files, live domain checks and campaign URL tagging — with no login.
Open the free deep linking tools →Frequently asked questions
- What is a webhook?
- It is an HTTP request — almost always a POST with a JSON body — that a service sends to a URL you control when an event happens, so your system learns about it immediately instead of polling for changes. In attribution, webhooks deliver events such as an attributed install or a conversion to your warehouse, CRM or alerting within seconds.
- How do I verify a webhook signature?
- Compute an HMAC-SHA256 over the timestamp and the raw request body using the shared secret, and compare it to the signature header in constant time. Two details matter: sign the raw bytes rather than re-serialised JSON, because key order and whitespace change the hash, and reject deliveries whose timestamp is more than a few minutes old so captured requests cannot be replayed.
- Why does my webhook receive the same event twice?
- Because delivery is at-least-once. If the sender does not receive a 2xx response within its timeout — including when your handler is merely slow — it retries with exponential backoff. Store the event ID with a unique constraint and ignore conflicts, and acknowledge the delivery before doing any downstream work rather than after.
- Are webhooks delivered in order?
- No. Retries, parallel delivery and network variance all mean events can arrive out of sequence, so an event created later can be received first. Order by the created_at or equivalent field in the payload rather than by arrival time, and design handlers so that applying an older event after a newer one does not overwrite current state.
- What should a webhook endpoint return?
- A 2xx status as soon as the delivery has been validated and durably queued, with no downstream work done first. Anything in the 4xx range other than an authentication failure tells the sender the payload is unacceptable and is usually not retried; a 5xx or a timeout schedules a retry. Endpoints that fail persistently are typically disabled by the sender.
Related terms
- Unattributed installs — An install is unattributed when no signal linking it to a prior ad click or link tap survived the journey through the app store, which can mean the install was organic or that the signal existed and was lost.
- Migrating off Firebase Dynamic Links — Migrating off Firebase Dynamic Links means replacing the link generation, the hosting domain, and the SDK integration of a service that was shut down on 25 August 2025, after which page.link URLs stopped resolving.