Webhooks
Events pushed to your own URL the moment they happen — conversions, broken and unsafe links, paused campaigns, plan limits — signed, retried for a day, and logged.
The API answers when you ask. A webhook tells you without being asked: when a conversion arrives, UTMCAP calls a URL of yours with it, within a second or two. Webhooks come with the API, on Growth, Pro and Scale.
#Adding an endpoint
Settings → API → Webhooks → Add endpoint, or POST /api/v1/webhooks. Give an
https URL on the public internet and choose the events. The answer includes a
signing secret, whsec_…, shown once — keep it with the code that receives
the calls. Send test posts a ping and shows what your server answered.
An account can have ten endpoints.
#Events
| Event | When |
|---|---|
conversion.created |
A new conversion — from a postback, a goal, the data corrector or a network sync |
conversion.updated |
A conversion recorded again, such as pending becoming approved, or a chargeback |
link.broken |
An offer or landing page stops answering (two checks in a row) |
link.unsafe |
Google lists a tracking domain, offer or landing page as unsafe |
campaign.paused |
A running campaign is paused — in the dashboard, through the API or by the Copilot |
plan.limit_reached |
This month's clicks pass what the plan allows — once a month |
Each is sent when something changes. A link that stays broken is not sent again every hour.
#What arrives
A POST with a JSON body:
{
"id": "5f0c1c8e-6a1f-4b8e-9b64-2c3e1f0a7d21",
"type": "conversion.created",
"created_at": "2026-09-16T14:02:11.508Z",
"account_id": "7e2d…",
"data": {
"click_id": "9b1c…",
"conversion_id": "ORDER-1042",
"status": "approved",
"payout": 42.5,
"currency": "USD",
"goal": "default",
"campaign_id": "3a7f…",
"offer_id": "c0de…",
"source_id": "1b2c…",
"recorded_at": "2026-09-16 14:02:11.000",
"source": "postback"
}
}
and these headers:
| Header | |
|---|---|
UTMCap-Signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> — see below |
UTMCap-Event |
The event type |
UTMCap-Delivery |
This delivery's id, as in the delivery log |
The event id is the same on a retry and on Send again, so store it and ignore
one you have already handled.
#Checking the signature
Compute HMAC-SHA256 of <t>.<the raw body> with your signing secret, compare it
with v1 in constant time, and refuse a t more than five minutes old. Use the
body exactly as it arrived — parsing and re-serialising the JSON changes it.
Node
const crypto = require('crypto');
function isFromUtmcap(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 || '', 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
Python
import hashlib, hmac, time
def is_from_utmcap(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = int(parts.get("t", "0"))
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
#Answering, and retries
Answer with any 2xx within ten seconds; do the slow work afterwards. Anything else — another status, a timeout, a redirect — is a failed attempt, and it is tried again after 1 minute, 5 minutes, 30 minutes, then 2, 6 and 12 hours. After the seventh attempt it is marked failed.
Redirects are not followed, and a URL that resolves to a private or internal address is refused.
#The delivery log
Settings → API → Webhooks lists the last 50 deliveries with what your server
answered; GET /api/v1/webhooks/deliveries returns all of the last 30 days. A
failed one can be sent again — Send again, or
POST /api/v1/webhooks/deliveries/redeliver — once your server is fixed.