Webhook destination
Receive exported data as signed JSON batches at your own HTTPS endpoint — payloads, signature verification, idempotency and retries.
A webhook destination POSTs your exported rows to an HTTPS endpoint you run, as signed JSON
batches. Use it to feed your own service, a queue, or an automation tool that accepts webhooks.
Set it up
Section titled “Set it up”- Create a webhook destination with your endpoint URL. It must be a public
https://URL. - Copy the signing secret — it starts with
whsec_and is shown once, when the destination is created. Store it in your secret manager. - Verify the signature on every request (see below) and answer
2xx. - Use Test connection to send an
export.testevent, then run the destination or wait for its schedule.
Lost the secret, or it leaked? Rotate it: a new whsec_… secret is shown once and every request
from then on is signed with it. The old one stops being used immediately, so deploy the new secret
to your receiver right away.
What your endpoint receives
Section titled “What your endpoint receives”Every request is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
ISF-Signature | t=<unix seconds>,v1=<hex signature> — see Verify the signature |
Idempotency-Key | The event’s id — the same on every retry of the same request |
A run sends, in order:
export.batchevents — the rows, dataset by dataset.- One
export.completedevent once every dataset has been delivered.
export.batch
Section titled “export.batch”{ "type": "export.batch", "id": "0199a7c2-5b1e-7c3a-9f10-3d2b8e6a4c11:position_tracker.results:0", "run_id": "0199a7c2-5b1e-7c3a-9f10-3d2b8e6a4c11", "destination_id": "0199a2f0-1c4d-7e55-8a21-6b7f0c9d2e33", "dataset": "position_tracker.results", "schema_version": 1, "chunk_index": 0, "rows": [ { "row_id": "0199a7c3-0a8e-7f21-b3c4-5d6e7f809a1b", "measured_at": "2026-09-30T06:02:14.512Z", "run_id": "0199a7c2-5b1e-7c3a-9f10-3d2b8e6a4c11", "campaign_id": "0199a2f0-77aa-7d10-9e3f-0a1b2c3d4e5f", "campaign_name": "Brand keywords — US", "keyword": "rank tracker", "domain": "example.com", "position": 3, "…": "…" } ]}| Field | Notes |
|---|---|
type | export.batch. Branch on type; ignore types you don’t know. |
id | Unique per batch: <run_id>:<dataset>:<chunk_index>. Also sent as Idempotency-Key. |
run_id | The export run the batch belongs to. |
destination_id | The destination that sent it. |
dataset | Which dataset the rows are from. |
schema_version | The dataset’s schema version. |
chunk_index | 0, 1, 2 … within the dataset, in the order sent. |
rows | The rows — objects keyed by the dataset’s column names. |
A batch carries at most 500 rows and roughly 1 MiB of rows, whichever comes first. Values
keep their JSON types: timestamps are ISO-8601 strings in UTC, lists are arrays, JSON columns are
objects, and empty values are null.
export.completed
Section titled “export.completed”Sent last, once every dataset has been delivered. Use it to know a run is complete and to check you received every row:
{ "type": "export.completed", "id": "0199a7c2-5b1e-7c3a-9f10-3d2b8e6a4c11:completed", "run_id": "0199a7c2-5b1e-7c3a-9f10-3d2b8e6a4c11", "destination_id": "0199a2f0-1c4d-7e55-8a21-6b7f0c9d2e33", "window_from": "2026-09-30T05:00:00.000Z", "window_to": "2026-09-30T05:55:00.000Z", "datasets": [ { "dataset": "position_tracker.results", "rows": 1240 }, { "dataset": "alerts", "rows": 3 } ]}window_from and window_to are the time range the run covered.
export.test
Section titled “export.test”What Test connection sends:
{ "type": "export.test", "id": "test:1759212000000" }Answer 2xx. A test sent before the destination is saved is signed with a throwaway secret —
you don’t have the real one yet — so it will not pass verification. Accept export.test without
processing it, or save first and test the saved destination, which is signed with its real secret.
Verify the signature
Section titled “Verify the signature”ISF-Signature is t=<timestamp>,v1=<signature>, where the signature is the hex-encoded
HMAC-SHA256 of "<timestamp>.<raw request body>", keyed with your whole signing secret (including
the whsec_ prefix).
To verify:
- Split the header on
,and readtandv1. - Reject the request if
tis more than 5 minutes from your clock — this stops replays. - Compute the HMAC over the raw body bytes exactly as received. Parsing and re-serializing the JSON first changes the bytes and breaks the signature.
- Compare with
v1in constant time.
Node.js
Section titled “Node.js”import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 5 * 60;
export function verifyIsfSignature(rawBody, header, secret) { const parts = Object.fromEntries( (header ?? '').split(',').map((part) => part.trim().split('=', 2)) ); const timestamp = Number(parts.t); if (!Number.isInteger(timestamp) || !parts.v1) return false; if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest(); const received = Buffer.from(parts.v1, 'hex'); return received.length === expected.length && timingSafeEqual(received, expected);}
// Express: keep the body raw on this route.// app.post('/isf-export', express.raw({ type: 'application/json' }), (req, res) => {// if (!verifyIsfSignature(req.body, req.get('ISF-Signature'), process.env.ISF_WEBHOOK_SECRET)) {// return res.sendStatus(401);// }// const event = JSON.parse(req.body);// // … store it, then:// res.sendStatus(204);// });Python
Section titled “Python”import hashlibimport hmacimport time
TOLERANCE_SECONDS = 5 * 60
def verify_isf_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict( part.strip().split("=", 1) for part in (header or "").split(",") if "=" in part ) try: timestamp = int(parts["t"]) received = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False
signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, received)
# Flask:# @app.post("/isf-export")# def isf_export():# if not verify_isf_signature(request.get_data(), request.headers.get("ISF-Signature"), SECRET):# abort(401)# event = request.get_json()# ...# return "", 204Delivery, retries and duplicates
Section titled “Delivery, retries and duplicates”- Answer
2xxonce you have stored the event. Anything else is a failure. If processing is slow, store the body and process it asynchronously — each request times out after 15 seconds. - Retried: a timeout, a connection error,
408,429or any5xx. The run is retried automatically; batches already delivered for finished datasets are not sent again, and a re-sent batch has the sameidandIdempotency-Key. - Not retried: any other
4xx, or a redirect — redirects are never followed. The run fails and the destination is marked needs attention (401/403reportaccess_denied,404/410reportnot_found, anything elsedestination_rejected). Fix your endpoint, then resume the destination. - Batches arrive in order, one at a time, never in parallel.
Two levels of de-duplication cover every case:
- Per request — skip an
id(orIdempotency-Key) you have already processed. This absorbs retries. - Per row — upsert on
(dataset, row_id). A run that failed half-way is covered again by the next one under a newrun_id, and a backfill re-sends older rows; both repeat rows with the samerow_id.
Security
Section titled “Security”- Always verify
ISF-Signatureand the timestamp before trusting a body. - Endpoints on private or internal networks are refused; the URL must resolve to a public address.
- Rotate the signing secret when someone who had it leaves, or if it may have leaked.