docs
Open the app →

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.

Something happensa conversion, abroken linkSavedone delivery perendpointSigned POSTUTMCap-SignatureYour serveranswers 2xxno 2xx in ten secondsTried again after 1 m, 5 m, 30 m, 2 h, 6 h, 12 h — then failedand it can be sent again by hand from the delivery log
An event is saved before it is sent, so nothing is lost if your server is down. It is retried for about a day, and every attempt is in the log.

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.