Guides

API webhooks

Get a signed request on your own server when an Iminify image or page scan finishes: the events, the body, checking the signature, and retries.

A webhook sends a request to your own server the moment one of your images or page scans finishes, so you don't have to keep asking the API whether it has. Webhooks come with Pro (see the plans); on the free plan you poll instead.

Setting one up

Open Settings, Webhooks and save the address that should receive the events. It has to start with https:// and lead to a server on the public internet: an address that points at a private network, or at the machine itself, is refused when you save it and again on every send.

When you save an address for the first time, Iminify shows its signing secret, starting whsec_. Copy it into your server then, because it isn't shown again. New secret in the same place makes another one and the old one stops working at once.

Send a test event posts a ping to your address straight away and shows what your server answered.

One account has one address, and every image and scan of the account sends to it, whether it started on the website, through the API or from an AI agent.

Events

Event Sent when
image.completed An image finished: its status is completed, or already-optimized when nothing came out smaller than the upload.
image.failed An image could not be compressed. Its status is failed.
scan.completed The scanner finished a page and queued its images, or found none it could take (no-image-found). The images keep compressing afterwards, and each sends its own image event.
scan.failed The page could not be scanned.
ping You pressed Send a test event.

Cancelling an image or a scan sends nothing. Running one again (a retry or a recompress) sends a new event once it finishes.

The request

Each event is a POST with a JSON body:

JSON
{
    "id": "4f0c2a9e-61d3-4b8a-9b0e-2d7c5a1e8f34",
    "type": "image.completed",
    "created_at": "2026-10-25T10:42:07+00:00",
    "data": {
        "id": "9b2e7c1a-3f4d-4e8b-a6c2-1d5f8e9a0b3c",
        "status": "completed",
        "...": "..."
    }
}
  • id names this delivery. It stays the same when the same event is sent again after a failure, so you can skip one you've already handled.
  • type is the event.
  • created_at is when the image or scan finished.
  • data is the image exactly as GET /images/{id} answers it, or the scan as the scan list answers it: its status, counts and totals. A ping carries an empty data.

A scan's body doesn't carry its images or its page report. Fetch GET /scans/{id} for those, or list its images when their own events have arrived. Each image of a scan names the scan in its scan_id.

The download addresses in an image (download_url) need your API token, the same as anywhere else in the API.

An image's AI alt text is usually still being written when image.completed is sent, so alt_text.status reads pending. It takes a few seconds; fetch the image again a little later for the text.

The request carries these headers:

Header Value
Content-Type application/json
Iminify-Event The event, as in type.
Iminify-Delivery The delivery's id, as in id.
Iminify-Signature The signature, below.

Answering

Answer with any 2xx status within 10 seconds. Iminify reads only the status, never the body. If your work takes longer, store the event, answer, and do the work afterwards.

Anything else counts as a failed send: another status, no answer in time, a server that can't be reached, and a redirect, which is never followed.

Checking the signature

Anyone who learns your address could send it a request, so check that each one came from Iminify. The Iminify-Signature header looks like this:

Iminify-Signature: t=1761388927,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

t is the time the request was sent, in seconds since 1970, and v1 is the HMAC-SHA256 of the time, a full stop and the raw request body, keyed with your whole signing secret, written as lowercase hex. To check it:

  1. Read the raw body, before any JSON parsing changes it.
  2. Compute the HMAC-SHA256 of t, . and the body with your secret.
  3. Compare it with v1 in constant time.
  4. Turn away a request whose t is more than a few minutes from your own clock, so an old request can't be sent to you again.

In PHP:

PHP
<?php

$secret = getenv('IMINIFY_WEBHOOK_SECRET');
$payload = file_get_contents('php://input');

parse_str(str_replace(',', '&', $_SERVER['HTTP_IMINIFY_SIGNATURE'] ?? ''), $signature);

$timestamp = (int) ($signature['t'] ?? 0);
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);

if (! hash_equals($expected, (string) ($signature['v1'] ?? '')) || abs(time() - $timestamp) > 300) {
    http_response_code(400);
    exit;
}

$event = json_decode($payload, true);

// Store the event and answer; do the slow work afterwards.
http_response_code(204);

In Python:

Python
import hashlib
import hmac
import time


def is_from_iminify(payload: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    signature = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
    timestamp = int(signature.get("t", "0"))
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + payload, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, signature.get("v1", "")) and abs(time.time() - timestamp) <= tolerance

Pass it the body as bytes, as your framework received it (request.get_data() in Flask, await request.body() in FastAPI).

In JavaScript (Node.js):

JavaScript
import { createHmac, timingSafeEqual } from 'node:crypto';

export function isFromIminify(payload, header, secret, tolerance = 300) {
    const signature = Object.fromEntries(header.split(',').map((part) => part.split('=', 2)));
    const timestamp = Number(signature.t);
    const expected = createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest();
    const given = Buffer.from(signature.v1 ?? '', 'hex');

    return given.length === expected.length
        && timingSafeEqual(given, expected)
        && Math.abs(Date.now() / 1000 - timestamp) <= tolerance;
}

Pass it the raw body as a string or a buffer, for example from express.raw({ type: 'application/json' }).

When a send fails

A failed send is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, 6 tries in all. Each try is signed again with its own time, so your clock check still passes, and carries the same Iminify-Delivery. Events can arrive out of order and, rarely, more than once, so key your handling on the delivery's id and the image's or scan's status.

Settings shows when the last send went out and what your server answered. After 100 failed sends in a row, Iminify stops sending until you press Resume sending there. Events that finished while it was stopped aren't sent later; list your images to catch up.

If your account leaves Pro, nothing is sent and your address and secret are kept. Sending starts again with Pro. Remove takes the address and the secret away for good, on any plan.