Skip to main content
← API reference

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 2xx within 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-Attempt says 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.