Skip to content

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.

  1. Create a webhook destination with your endpoint URL. It must be a public https:// URL.
  2. Copy the signing secret — it starts with whsec_ and is shown once, when the destination is created. Store it in your secret manager.
  3. Verify the signature on every request (see below) and answer 2xx.
  4. Use Test connection to send an export.test event, 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.

Every request is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
ISF-Signaturet=<unix seconds>,v1=<hex signature> — see Verify the signature
Idempotency-KeyThe event’s id — the same on every retry of the same request

A run sends, in order:

  1. export.batch events — the rows, dataset by dataset.
  2. One export.completed event once every dataset has been delivered.
{
"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,
"…": "…"
}
]
}
FieldNotes
typeexport.batch. Branch on type; ignore types you don’t know.
idUnique per batch: <run_id>:<dataset>:<chunk_index>. Also sent as Idempotency-Key.
run_idThe export run the batch belongs to.
destination_idThe destination that sent it.
datasetWhich dataset the rows are from.
schema_versionThe dataset’s schema version.
chunk_index0, 1, 2 … within the dataset, in the order sent.
rowsThe 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.

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.

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.

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:

  1. Split the header on , and read t and v1.
  2. Reject the request if t is more than 5 minutes from your clock — this stops replays.
  3. 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.
  4. Compare with v1 in constant time.
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);
// });
import hashlib
import hmac
import 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 "", 204
  • Answer 2xx once 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, 429 or any 5xx. The run is retried automatically; batches already delivered for finished datasets are not sent again, and a re-sent batch has the same id and Idempotency-Key.
  • Not retried: any other 4xx, or a redirect — redirects are never followed. The run fails and the destination is marked needs attention (401/403 report access_denied, 404/410 report not_found, anything else destination_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:

  1. Per request — skip an id (or Idempotency-Key) you have already processed. This absorbs retries.
  2. Per row — upsert on (dataset, row_id). A run that failed half-way is covered again by the next one under a new run_id, and a backfill re-sends older rows; both repeat rows with the same row_id.
  • Always verify ISF-Signature and 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.