MintRender
All docs pages

Async jobs and webhooks

A job answers at once and runs in the background. You learn that it ended by polling GET /v1/renders/{id}, or by a webhook: a signed POST to your URL.

Jobs and polling

Send "mode": "async" to POST /v1/render, or send a PDF of more than 10 pages or more than 5 sizes. The answer is 202 with the render id. A batch is always a job. See Sync or job.

JavaScript

// Poll a job until it ends.async function waitForRender(id) {  for (;;) {    const res = await fetch(`https://mintrender.com/v1/renders/${id}`, {      headers: { Authorization: `Bearer ${process.env.MINTRENDER_KEY}` },    });    const render = await res.json();    if (!res.ok) throw new Error(render.error.message);    if (["succeeded", "failed", "canceled"].includes(render.status)) return render;    await new Promise((r) => setTimeout(r, 2000));  }}

Python

import osimport time import requests  def wait_for_render(render_id):    """Poll a job until it ends."""    while True:        res = requests.get(            f"https://mintrender.com/v1/renders/{render_id}",            headers={"Authorization": f"Bearer {os.environ['MINTRENDER_KEY']}"},            timeout=30,        )        render = res.json()        if not res.ok:            raise RuntimeError(render["error"]["message"])        if render["status"] in ("succeeded", "failed", "canceled"):            return render        time.sleep(2)

Each poll is one API call and counts toward the key's rate limit. A webhook needs no polling.

Two ways to get events

WayGetsSignature secret
A registered webhook (POST /v1/webhooks, or Account, Webhooks)The events you pick, for every render and batch of the workspaceShown once, when you add the webhook
webhook_url in one render or batch requestThe events of that one render or batchThe workspace's request secret. Owners see it, and can make a new one, under Account, Secret for webhook_url

When a request's webhook_url is also a registered URL, that event is sent once, signed with the registered webhook's secret.

The secret for webhook_url is made the first time it is needed. After an owner presses "Make a new secret", every try from then on is signed with the new secret, also a retry of an older event. Change it on your server at the same time.

Events

EventSent when
render.succeededA render made with the API or MCP succeeds, sync or job.
render.failedA render made with the API or MCP fails, sync or job, including a job stopped for running too long.
batch.completedEvery row of a batch has ended and its ZIP, if asked for, is ready or failed. It is also sent when every row failed.

Signed link views send no events. Batch rows send no render events. The batch sends one batch.completed. A canceled job sends no event.

What we send

Each delivery is a POST with a JSON body {"event": …, "data": …}. For render events, data is the render as GET /v1/renders/{id} gives it.

POST /hooks/mintrender HTTP/1.1Content-Type: application/jsonMR-Signature: t=1793525400,v1=5f2b6c…MR-Delivery: dlv_0a1b2c3d4e5f6a7b8c9d0e1fUser-Agent: MintRender/1.0 (+https://mintrender.com) {  "event": "render.succeeded",  "data": {    "id": "rnd_9x7Lm2Qp8sT4vB0nC6kR",    "status": "succeeded",    "outputs": [      { "size": "main", "url": "https://files.mintrender.com/r/ws_…/a1b2….pdf", "width": 1123, "height": 794, "bytes": 48211, "format": "pdf", "pages": 1 }    ],    "credits": 1,    "cached": false,    "took_ms": 840,    "metadata": { "order": "1234" },    "created": "2026-11-01T09:30:00.000Z"  }}
{  "event": "render.failed",  "data": {    "id": "rnd_9x7Lm2Qp8sT4vB0nC6kR",    "status": "failed",    "outputs": [],    "credits": 1,    "cached": false,    "took_ms": 95,    "error": { "code": "url_failed", "message": "the image URL answered 404", "field": "data.photo" },    "created": "2026-11-01T09:30:00.000Z"  }}
{  "event": "batch.completed",  "data": {    "id": "bat_2Lk9mQ8xP0aB4nC7vR2s",    "status": "completed",    "rows": 500,    "done": 498,    "failed": 2,    "zip_url": "/v1/batch/bat_2Lk9mQ8xP0aB4nC7vR2s/zip",    "created": "2026-11-01T09:30:00.000Z"  }}
HeaderAbout
Content-Typeapplication/json
MR-Signaturet={unix seconds},v1={hex}: see below.
MR-DeliveryThe delivery id. Retries of one delivery have the same id. Use it to drop repeats.
User-AgentMintRender/1.0 (+https://mintrender.com)

Check the signature

  1. Read the raw body as it arrived, before you parse the JSON.

  2. Split MR-Signature at the commas into t (Unix seconds when we sent this try) and v1.

  3. Compute HMAC-SHA256 with the whole secret text (whsec_…, as UTF-8 bytes) as the key, over t, a dot and the raw body. Write it as lowercase hex.

  4. Compare it with v1 in constant time. Refuse a t far from your clock, for example more than 5 minutes, so an old delivery cannot be sent again by someone else.

Each version returns true for a good signature. The JavaScript, Python, Go and Ruby versions are checked against our signing code.

JavaScript (Node)

import { createHmac, timingSafeEqual } from "node:crypto"; // rawBody: the request body as text, exactly as it arrived (before JSON.parse).export function verifyMintRender(rawBody, header, secret, toleranceSeconds = 300) {  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));  const t = Number(parts.t);  if (!Number.isInteger(t) || !parts.v1) return false;  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();  const given = Buffer.from(parts.v1, "hex");  return given.length === expected.length && timingSafeEqual(given, expected);} // In a handler with the Fetch API (Cloudflare Workers, Deno, Bun, Node 18+):export async function handle(request) {  const raw = await request.text();  const ok = verifyMintRender(raw, request.headers.get("MR-Signature") ?? "", process.env.MINTRENDER_WEBHOOK_SECRET);  if (!ok) return new Response("bad signature", { status: 400 });  const { event, data } = JSON.parse(raw);  console.log(event, data.id);  return new Response("ok");}

Python

import hashlibimport hmacimport time  def verify_mintrender(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:    """raw_body: the request body as bytes, exactly as it arrived."""    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)    try:        t = int(parts["t"])    except (KeyError, ValueError):        return False    if abs(time.time() - t) > tolerance:        return False    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()    return hmac.compare_digest(expected, parts.get("v1", ""))  # Flask: verify_mintrender(request.get_data(), request.headers.get("MR-Signature", ""), secret)

PHP

<?phpfunction verify_mintrender(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {    $parts = [];    foreach (explode(',', $header) as $p) {        [$k, $v] = array_pad(explode('=', $p, 2), 2, '');        $parts[$k] = $v;    }    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) return false;    if (abs(time() - (int) $parts['t']) > $tolerance) return false;    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);    return hash_equals($expected, $parts['v1']);} $raw = file_get_contents('php://input');if (!verify_mintrender($raw, $_SERVER['HTTP_MR_SIGNATURE'] ?? '', getenv('MINTRENDER_WEBHOOK_SECRET'))) {    http_response_code(400);    exit;}$event = json_decode($raw, true);

Go

package webhooks import (	"crypto/hmac"	"crypto/sha256"	"encoding/hex"	"strconv"	"strings"	"time") // VerifyMintRender checks MR-Signature. rawBody is the body exactly as it arrived.func VerifyMintRender(rawBody []byte, header, secret string, tolerance time.Duration) bool {	var t, v1 string	for _, p := range strings.Split(header, ",") {		k, v, _ := strings.Cut(p, "=")		switch k {		case "t":			t = v		case "v1":			v1 = v		}	}	ts, err := strconv.ParseInt(t, 10, 64)	if err != nil || v1 == "" {		return false	}	if d := time.Since(time.Unix(ts, 0)); d > tolerance || d < -tolerance {		return false	}	mac := hmac.New(sha256.New, []byte(secret))	mac.Write([]byte(t + "."))	mac.Write(rawBody)	given, err := hex.DecodeString(v1)	return err == nil && hmac.Equal(given, mac.Sum(nil))}

Ruby

require "openssl" # raw_body: the request body as a string, exactly as it arrived.def verify_mintrender(raw_body, header, secret, tolerance = 300)  parts = header.split(",").filter_map { |p| p.split("=", 2) if p.include?("=") }.to_h  t = Integer(parts["t"].to_s, exception: false)  return false unless t && parts["v1"]  return false if (Time.now.to_i - t).abs > tolerance   expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{t}.#{raw_body}")  expected.bytesize == parts["v1"].bytesize && OpenSSL.fixed_length_secure_compare(expected, parts["v1"])end # Rack or Rails: verify_mintrender(request.raw_post, request.headers["MR-Signature"].to_s, secret)

Answer and retries

  • Answer with any 2xx status within 10 seconds. We read at most 64 KB of your answer.
  • Any other status, a timeout or a network error is a failed try. We try again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours: 7 tries in about 21 hours. Then we stop.
  • We do not follow redirects. A 3xx answer is a failed try, and the signed body goes only to the URL you set.
  • A delivery can arrive more than once, for example when your server saved it but answered too late. Events of different renders can arrive in any order. Use MR-Delivery and the render or batch id to handle each one once.
  • Do slow work after you answer: put the event on your own queue and answer 200.

Manage webhooks

Add a webhook. events is optional and has all three events by default.

curl

curl -X POST "https://mintrender.com/v1/webhooks" \  -H "Authorization: Bearer $MINTRENDER_KEY" \  -H "Content-Type: application/json" \  -d '{"url":"https://example.com/hooks/mintrender","events":["render.succeeded","render.failed","batch.completed"]}'

JavaScript

const res = await fetch("https://mintrender.com/v1/webhooks", {  method: "POST",  headers: {    Authorization: `Bearer ${process.env.MINTRENDER_KEY}`,    "Content-Type": "application/json",  },  body: JSON.stringify({    url: "https://example.com/hooks/mintrender",    events: ["render.succeeded", "render.failed", "batch.completed"],  }),});const body = await res.json();if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);console.log(body);

Python

import os import requests res = requests.post(    "https://mintrender.com/v1/webhooks",    headers={"Authorization": f"Bearer {os.environ['MINTRENDER_KEY']}"},    json={        "url": "https://example.com/hooks/mintrender",        "events": ["render.succeeded", "render.failed", "batch.completed"],    },    timeout=120,)body = res.json()if not res.ok:    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['error']['request_id']})")print(body)
{  "id": "whk_4Hq8LmQ9xP0aB4nC7vR2",  "url": "https://example.com/hooks/mintrender",  "events": ["render.succeeded", "render.failed", "batch.completed"],  "disabled": false,  "created": "2026-11-01T09:30:00.000Z",  "secret": "whsec_…"}

Store secret at once. It is not shown again. If you lose it, delete the webhook and add it again.

A webhook URL (in POST /v1/webhooks and in webhook_url) must start with https:// or http://, use port 80 or 443, have no user name or password, and point to a public host. We refuse localhost, names like .local, .internal or .lan, one-label hosts, and private or reserved IP addresses, when the URL is added and on every try.

curl

curl "https://mintrender.com/v1/webhooks" \  -H "Authorization: Bearer $MINTRENDER_KEY"

JavaScript

const res = await fetch("https://mintrender.com/v1/webhooks", {  method: "GET",  headers: {    Authorization: `Bearer ${process.env.MINTRENDER_KEY}`,  },});const body = await res.json();if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);console.log(body);

Python

import os import requests res = requests.get(    "https://mintrender.com/v1/webhooks",    headers={"Authorization": f"Bearer {os.environ['MINTRENDER_KEY']}"},    timeout=120,)body = res.json()if not res.ok:    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['error']['request_id']})")print(body)

curl

curl -X DELETE "https://mintrender.com/v1/webhooks/whk_4Hq8LmQ9xP0aB4nC7vR2" \  -H "Authorization: Bearer $MINTRENDER_KEY"

JavaScript

const res = await fetch("https://mintrender.com/v1/webhooks/whk_4Hq8LmQ9xP0aB4nC7vR2", {  method: "DELETE",  headers: {    Authorization: `Bearer ${process.env.MINTRENDER_KEY}`,  },});const body = await res.json();if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);console.log(body);

Python

import os import requests res = requests.delete(    "https://mintrender.com/v1/webhooks/whk_4Hq8LmQ9xP0aB4nC7vR2",    headers={"Authorization": f"Bearer {os.environ['MINTRENDER_KEY']}"},    timeout=120,)body = res.json()if not res.ok:    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['error']['request_id']})")print(body)

After a delete, deliveries still waiting for that webhook stop, with the error "the webhook was deleted".

The deliveries log

GET /v1/deliveries lists every try, newest first. Filter by render with render. limit is 1 to 200, default 100. Account, Webhooks shows the last 20.

curl

curl "https://mintrender.com/v1/deliveries?render=rnd_9x7Lm2Qp8sT4vB0nC6kR&limit=20" \  -H "Authorization: Bearer $MINTRENDER_KEY"

JavaScript

const res = await fetch("https://mintrender.com/v1/deliveries?render=rnd_9x7Lm2Qp8sT4vB0nC6kR&limit=20", {  method: "GET",  headers: {    Authorization: `Bearer ${process.env.MINTRENDER_KEY}`,  },});const body = await res.json();if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);console.log(body);

Python

import os import requests res = requests.get(    "https://mintrender.com/v1/deliveries?render=rnd_9x7Lm2Qp8sT4vB0nC6kR&limit=20",    headers={"Authorization": f"Bearer {os.environ['MINTRENDER_KEY']}"},    timeout=120,)body = res.json()if not res.ok:    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['error']['request_id']})")print(body)
FieldAbout
idThe delivery id, the same as the MR-Delivery header.
urlWhere it was sent.
eventThe event name.
render_id, batch_idWhat the event is about.
attemptTries made so far.
status_codeYour server's last status, or null.
errorWhy the last try failed, or null.
donetrue when it succeeded or we stopped trying.
next_tryWhen the next try is, or null.
createdWhen the delivery was made.

"Send again" makes a new delivery (a new id) with the same body and tries it at once. It is retried like any other.

curl

curl -X POST "https://mintrender.com/v1/deliveries/dlv_0a1b2c3d4e5f6a7b8c9d0e1f/resend" \  -H "Authorization: Bearer $MINTRENDER_KEY"

JavaScript

const res = await fetch("https://mintrender.com/v1/deliveries/dlv_0a1b2c3d4e5f6a7b8c9d0e1f/resend", {  method: "POST",  headers: {    Authorization: `Bearer ${process.env.MINTRENDER_KEY}`,  },});const body = await res.json();if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`);console.log(body);

Python

import os import requests res = requests.post(    "https://mintrender.com/v1/deliveries/dlv_0a1b2c3d4e5f6a7b8c9d0e1f/resend",    headers={"Authorization": f"Bearer {os.environ['MINTRENDER_KEY']}"},    timeout=120,)body = res.json()if not res.ok:    raise RuntimeError(f"{body['error']['code']}: {body['error']['message']} ({body['error']['request_id']})")print(body)

Errors

StatusCodeWhen
400invalid_paramThe URL is not one we call (see the rules above).
400invalid_requesturl is missing or over 2,048 characters, or events is empty or has an unknown event.
404not_foundNo such webhook or delivery.
503not_configuredWebhooks are not set up on this server.

Seen MintRender used for content that breaks our terms? Report it. Questions about the API: support@mintrender.com, with the request id.