Guide · Webhooks

Hear about uploads, moderation and quota from img2site.

When a project has a callback URL, img2site sends a signed POST for each event. Verify the signature over the raw body with verifyWebhook, answer 2xx within ten seconds, and treat the delivery id as your idempotency key.

Set up a callback URL

Set the project's callback_url in the dashboard project settings (or with PATCH /v1/projects/{slug} from a dashboard session). The URL must be https and resolve to a public address. The response that first sets a callback URL, or that sends rotate_callback_secret: true, carries the signing secret callback_secret (whsec_…) once. Afterwards the project only shows callback_secret_set. Store the secret as a server-side environment variable.

Events are only generated for projects that have a callback URL when the event happens. Removing the URL stops new events; there is no backfill.

A delivery

POST https://example.com/webhooks/img2site
Content-Type: application/json
User-Agent: img2site-webhooks/2
X-Img2site-Event: upload.ready
X-Img2site-Delivery: upload.ready:upl_01J8Z…
X-Img2site-Signature: t=1790000000,v1=5f2b…(64 hex)

{"id":"upload.ready:upl_01J8Z…","event":"upload.ready","created_at":"2026-09-22T10:00:00.000Z",
 "data":{"upload_id":"upl_01J8Z…","project_id":"proj_…","file_id":"file_…","file_slug":"hero","sha256":"9c1f…","revision":1}}

Events

Webhook events and their data fields
EventWhendata fields
upload.readyAn upload was processed and the file is published (also for a same-slug re-upload, with a higher revision).upload_id, project_id, file_id, file_slug, sha256, revision
file.blockedModeration (the automatic scanner or staff) blocked the file; delivery answers 403 file_blocked.project_id, file_id, file_slug, revision, reason
file.unblockedStaff unblocked the file; it is served again.project_id, file_id, file_slug, revision
quota.exceededThe organization's credit balance reached zero; delivery answers 403 quota_exceeded. One event per project with a callback URL.organization_id, project_id, day, credit_balance, quota_exceeded_at
quota.restoredCredits were added and delivery resumes.organization_id, project_id, day, credit_balance, quota_exceeded_at (null)

The body is always { id, event, created_at, data }. id is stable across retries of the same event (it equals the X-Img2site-Delivery header), created_at is when the delivery was first recorded, and data holds the fields above. Bodies are at most 2 KB. Order is not guaranteed: compare revision before acting on an older file event.

Headers

Headers sent with every webhook delivery
HeaderValue
Content-Typeapplication/json
User-Agentimg2site-webhooks/2
X-Img2site-EventThe event name, for example upload.ready.
X-Img2site-DeliveryThe event id (same as the body's id), stable across retries.
X-Img2site-Signaturet=<unix seconds>,v1=<hex hmac-sha256(callback_secret, t + "." + body)>. The same scheme as Stripe, so existing verifiers work.

Verify with verifyWebhook

verifyWebhook(rawBody, headers, callbackSecret, { toleranceSec }) from @img2site/sdk-server takes the raw body (string or Uint8Array), the headers (a Headers object or a Node header record) and the secret. It returns the parsed WebhookEvent or throws WebhookSignatureError. toleranceSec defaults to 300.

Pass the body exactly as received. A body parsed and serialized again (for example by express.json()) no longer matches the signature.

Next.js route handler

// app/api/img2site-webhook/route.ts (Next.js App Router, Node.js runtime)
import { verifyWebhook, WebhookSignatureError, type WebhookEvent } from "@img2site/sdk-server";

export async function POST(request: Request) {
  const rawBody = await request.text(); // the exact bytes; never JSON.stringify(await request.json())
  let event: WebhookEvent;
  try {
    event = verifyWebhook(rawBody, request.headers, process.env.IMG2SITE_CALLBACK_SECRET!, { toleranceSec: 300 });
  } catch (error) {
    if (error instanceof WebhookSignatureError) return new Response(error.reason, { status: 400 });
    throw error;
  }

  // Deliveries are at-least-once: the same id can arrive again.
  if (await alreadyHandled(event.id)) return new Response(null, { status: 204 });

  switch (event.event) {
    case "upload.ready":
      await onFileReady(String(event.data.file_slug), Number(event.data.revision));
      break;
    case "file.blocked":
      await hideFile(String(event.data.file_slug), String(event.data.reason));
      break;
  }
  await markHandled(event.id);
  return new Response(null, { status: 204 });
}

Express

import express from "express";
import { verifyWebhook, WebhookSignatureError } from "@img2site/sdk-server";

const app = express();

// express.raw keeps req.body as the raw Buffer; express.json() would break the signature.
app.post("/webhooks/img2site", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = verifyWebhook(req.body, req.headers, process.env.IMG2SITE_CALLBACK_SECRET!);
    queue.push(event); // do the work after answering: a delivery must get a 2xx within 10 s
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookSignatureError) res.status(400).send(error.reason);
    else throw error;
  }
});

Failure reasons

WebhookSignatureError reasons
reasonCause
missing_headerNo X-Img2site-Signature header.
malformed_headerThe header is not t=<unix>,v1=<hex>.
timestamp_out_of_tolerance|now − t| is larger than toleranceSec.
signature_mismatchNo v1 value matches: wrong secret, or the body changed.
invalid_bodyThe signature matched but the body is not { id, event, created_at, data } JSON.

Replay protection and tolerance

t is the time of each attempt, and every retry is signed again with a fresh t, so a retry hours later still verifies. A captured request replayed after the tolerance window fails with timestamp_out_of_tolerance. Within the window, reject repeats by remembering the event id. Keep your server clock synchronized (NTP); the check compares your clock with ours in both directions.

Several v1= values in one header are accepted if any matches. Other languages can verify with the formula below and a constant-time comparison.

signed_payload = t + "." + raw_body
expected       = hex(hmac_sha256(callback_secret, signed_payload))
valid          = constant_time_equal(expected, v1) and abs(now - t) <= 300

A tampered body and a stale timestamp

import { createHmac } from "node:crypto";
import { verifyWebhook, WebhookSignatureError } from "@img2site/sdk-server";

const secret = "whsec_test";
const body = JSON.stringify({
  id: "upload.ready:upl_test",
  event: "upload.ready",
  created_at: new Date().toISOString(),
  data: { file_slug: "hero", revision: 1 },
});
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
const headers = { "x-img2site-signature": `t=${t},v1=${v1}` };

verifyWebhook(body, headers, secret); // → { id, event: "upload.ready", created_at, data }

try {
  verifyWebhook(body.replace('"hero"', '"admin"'), headers, secret);
} catch (error) {
  console.log((error as WebhookSignatureError).reason); // "signature_mismatch"
}

try {
  // The same delivery replayed ten minutes later.
  verifyWebhook(body, headers, secret, { now: new Date((t + 600) * 1000) });
} catch (error) {
  console.log((error as WebhookSignatureError).reason); // "timestamp_out_of_tolerance"
}

Retries and abandonment

A delivery succeeds when your endpoint answers 2xx within 10 seconds. Anything else is a failed attempt: other status codes (including 3xx, since redirects are not followed), timeouts, connection errors and callback URLs that resolve to a private or link-local address. Failed deliveries are retried with exponential backoff (30 s, 1 min, 2 min, 4 min and so on, capped at one hour between attempts; retries are picked up by a job that runs every minute). After 10 attempts, about three hours, the delivery is abandoned and not sent again.

Because the same event can arrive more than once, make handlers idempotent and dedupe on id. Do slow work after answering. If you miss events, reconcile from the API (for example GET /v1/projects/{slug}/files); webhooks are a notification, not the source of truth.

Uploads without webhooks

The upload router's onUploadComplete runs when the browser completes an upload. Use webhooks as well when the result must be recorded even if the browser goes away. See the quickstart.

Reference

The project fields callback_url, callback_secret_set and rotate_callback_secret are in the REST reference under projects.