Webhooks
Instead of polling GET /v1/jobs/{id}, let CutOptim tell you when an async max job is done.
Register an endpoint
On your dashboard, under Webhooks, add an https:// URL and choose which jobs it hears: those submitted with live keys, test keys or both. You get a signing secret (whsec_…) exactly once — store it like a password. An account can register up to 5 endpoints; a test key is enough to start.
The URL must be public: an address that resolves to a private, loopback or link-local network is refused, at registration and again before every delivery, and redirects are never followed.
The events
When an async engine: "max" job (2D or nest) finishes, every matching active endpoint gets a POST with a small JSON body — job.succeeded or job.failed. The plan is not in it: read it with GET /v1/jobs/{id}, which costs no quota (see async max jobs).
POST /your/endpoint
CutOptim-Signature: t=1760534400,v1=5f2c…
CutOptim-Event-Id: evt_3f2504e04f8941d39a0c0305e82c3301_succeeded
CutOptim-Event-Type: job.succeeded
CutOptim-Delivery-Attempt: 1
{"id":"evt_3f2504e04f8941d39a0c0305e82c3301_succeeded","type":"job.succeeded","created":1760534400,
"env":"live","data":{"jobId":"3f2504e0-4f89-41d3-9a0c-0305e82c3301","mode":"nest","engine":"max","status":"succeeded"}}Verify the signature
CutOptim-Signature is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 with your secret over "<t>.<raw body>". Verify it on the raw bytes you received — parsing the JSON and serialising it again changes the bytes — reject a timestamp more than 5 minutes away from your clock, and compare in constant time:
// Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, header, secret, toleranceS = 300) {
const t = Number(/(?:^|,)t=(\d+)/.exec(header)?.[1]);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceS) return false;
const want = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return [...header.matchAll(/(?:^|,)v1=([0-9a-f]{64})/g)]
.some(([, v]) => timingSafeEqual(want, Buffer.from(v, 'hex')));
}# Python
import hmac, hashlib, re, time
def verify(raw_body: bytes, header: str, secret: str, tolerance_s: int = 300) -> bool:
m = re.search(r'(?:^|,)t=(\d+)', header)
if not m or abs(time.time() - int(m.group(1))) > tolerance_s:
return False
want = hmac.new(secret.encode(), m.group(1).encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(want, v) for v in re.findall(r'(?:^|,)v1=([0-9a-f]{64})', header))// PHP — $rawBody = file_get_contents('php://input'); $header = $_SERVER['HTTP_CUTOPTIM_SIGNATURE'] ?? null;
function verify(string $rawBody, ?string $header, string $secret, int $toleranceS = 300): bool
{
if ($header === null || !preg_match('/(?:^|,)t=(\d+)/', $header, $m)) return false;
if (abs(time() - (int) $m[1]) > $toleranceS) return false;
$want = hash_hmac('sha256', $m[1] . '.' . $rawBody, $secret);
preg_match_all('/(?:^|,)v1=([0-9a-f]{64})/', $header, $all);
foreach ($all[1] as $v) if (hash_equals($want, $v)) return true;
return false;
}The official SDKs do the same in one call — verifyWebhook, verify_webhook, Webhook::verify — and take both secrets during a rotation.
Answer fast, expect retries
- Answer any
2xxwithin 10 seconds. Do the work after answering — a slow handler is a failed delivery. - Anything else — another status, a redirect, a timeout, a connection error — is retried at 1 min, 5 min, 30 min, 2 h, 5 h, 10 h and 24 h after the first attempt (8 attempts), then given up.
CutOptim-Delivery-Attemptsays which attempt this is. - Delivery is at least once: the same event can arrive twice (a retry after a lost answer). Deduplicate on
CutOptim-Event-Id, which is the same on every retry. - An endpoint that has failed for 3 days without one success is disabled, and the account is e-mailed. Fix the receiver and enable it again on the dashboard; the job results are never lost —
GET /v1/jobs/{id}still returns them.
Rotating the secret, testing, the log
Rotate secret on the dashboard gives you a new secret; for the next 24 hours every delivery is signed with both, so the header carries two v1 values and either may match — switch your receiver at leisure.
Send test event posts a webhook.test event through the same signing and delivery path; it never counts toward disabling the endpoint. The dashboard shows the last 30 days of deliveries — status, attempts, the last error — and any delivered or failed event can be sent again.