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
| Way | Gets | Signature secret |
|---|---|---|
A registered webhook (POST /v1/webhooks, or Account, Webhooks) | The events you pick, for every render and batch of the workspace | Shown once, when you add the webhook |
webhook_url in one render or batch request | The events of that one render or batch | The 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
| Event | Sent when |
|---|---|
render.succeeded | A render made with the API or MCP succeeds, sync or job. |
render.failed | A render made with the API or MCP fails, sync or job, including a job stopped for running too long. |
batch.completed | Every 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" }}| Header | About |
|---|---|
Content-Type | application/json |
MR-Signature | t={unix seconds},v1={hex}: see below. |
MR-Delivery | The delivery id. Retries of one delivery have the same id. Use it to drop repeats. |
User-Agent | MintRender/1.0 (+https://mintrender.com) |
Check the signature
Read the raw body as it arrived, before you parse the JSON.
Split
MR-Signatureat the commas intot(Unix seconds when we sent this try) andv1.Compute HMAC-SHA256 with the whole secret text (
whsec_…, as UTF-8 bytes) as the key, overt, a dot and the raw body. Write it as lowercase hex.Compare it with
v1in constant time. Refuse atfar 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-Deliveryand 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)| Field | About |
|---|---|
id | The delivery id, the same as the MR-Delivery header. |
url | Where it was sent. |
event | The event name. |
render_id, batch_id | What the event is about. |
attempt | Tries made so far. |
status_code | Your server's last status, or null. |
error | Why the last try failed, or null. |
done | true when it succeeded or we stopped trying. |
next_try | When the next try is, or null. |
created | When 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
| Status | Code | When |
|---|---|---|
| 400 | invalid_param | The URL is not one we call (see the rules above). |
| 400 | invalid_request | url is missing or over 2,048 characters, or events is empty or has an unknown event. |
| 404 | not_found | No such webhook or delivery. |
| 503 | not_configured | Webhooks 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.