developers
Documents, from one API call.
curl -X POST "https://api.mezdoc.com/api/v1/templates/coi_v3/render?return=url" \
-H "Authorization: Bearer $MEZDOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"data": {
"insured_name": "Harbor Point LLC",
"each_occurrence": 1000000,
"effective_date": "2026-06-01"
},
"idempotency_key": "coi-2026-00481"
}'# 200 OK
{
"submission_id": "sub_8Kq2mR4tVx",
"pdf_url": "https://api.mezdoc.com/api/v1/submissions/sub_8Kq2mR4tVx/pdf",
"page_count": 1,
"render_ms": 941
}Quickstart
Four steps from sign-up to a PDF. Every example on this page uses https://api.mezdoc.com.
- 1Start freeCreate an account. No card needed. On the Free plan, PDFs from the API carry a watermark.
- 2Publish a templateBuild it in the editor and publish it. A key renders the version published to its environment.
- 3Create an API keyIn the dashboard, under API. The key is shown once and stored only as a hash. Keep it on your server.
- 4Send the dataPOST JSON to
/api/v1/templates/:alias/renderand get the PDF back.
curl -X POST "https://api.mezdoc.com/api/v1/templates/coi_v3/render?return=url" \
-H "Authorization: Bearer $MEZDOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"data": {
"insured_name": "Harbor Point LLC",
"each_occurrence": 1000000,
"effective_date": "2026-06-01"
},
"idempotency_key": "coi-2026-00481"
}'const res = await fetch("https://api.mezdoc.com/api/v1/templates/coi_v3/render?return=url", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MEZDOC_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
data: { insured_name: "Harbor Point LLC", each_occurrence: 1000000, effective_date: "2026-06-01" },
idempotency_key: "coi-2026-00481",
}),
});
if (!res.ok) throw new Error((await res.json()).message);
const { submission_id, pdf_url } = await res.json();import os, requests
res = requests.post(
"https://api.mezdoc.com/api/v1/templates/coi_v3/render",
params={"return": "url"},
headers={"Authorization": f"Bearer {os.environ['MEZDOC_API_KEY']}"},
json={
"data": {"insured_name": "Harbor Point LLC", "each_occurrence": 1000000, "effective_date": "2026-06-01"},
"idempotency_key": "coi-2026-00481",
},
timeout=60,
)
res.raise_for_status()
print(res.json()["pdf_url"])Authentication
Every request sends a secret key as a bearer token: Authorization: Bearer sk_live_.... Call the API from your backend, never from a browser or a mobile app.
The prefix names the environment
| Environment | Key prefix | Renders |
|---|---|---|
| Production | sk_live_ | The version published to production. |
| Staging | sk_test_ | The version published to staging, with a STAGING watermark. |
| Development | sk_dev_ | The version published to development, with a DEVELOPMENT watermark. |
Every plan has production, staging and development environments. A key can be set to expire after 30 days, 90 days or a year, and you can revoke it at any time.
Scopes limit what a key can do
| Scope | Allows |
|---|---|
template:read | List templates and read a template's input schema. |
template:render | Render PDFs, synchronously and asynchronously. |
submission:read | List and read submissions, and download their PDFs. |
workflow:run | Start workflow runs, poll them and download their PDFs. |
New keys get all four scopes. Clear the ones a key does not need: a request outside its scopes returns 403 forbidden.
Render a PDF
One endpoint renders every template, a fillable PDF or a smart document, from the same data object.
| Field | Type | Notes |
|---|---|---|
data | object, required | Values keyed by the field names in the template's input schema, from GET /api/v1/templates/:alias. |
idempotency_key | string | A repeat with the same key and data returns the original result instead of rendering again. |
callback_url | string | A one-off webhook for this render. It fires on completion, sync or async, and is not signed. |
Send Content-Type: application/json. Bodies over 1 MB return 413.
Three ways to get the PDF back
| Request | Response | Use it when |
|---|---|---|
POST .../render | 200 with the PDF bytes, plus X-Mezdoc-Submission-Id and X-Mezdoc-Render-Ms headers. | You want the file in the response. |
?return=url | 200 JSON: submission_id, pdf_url, page_count, render_ms. | You store a link. pdf_url needs the same key to download. |
?async=true | 202 JSON: submission_id, status, poll_url. | Batches and long documents. Poll, or wait for the webhook. |
curl -X POST "https://api.mezdoc.com/api/v1/templates/coi_v3/render?async=true" \
-H "Authorization: Bearer $MEZDOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "data": { "insured_name": "Harbor Point LLC" } }'# 202 Accepted
{
"submission_id": "sub_8Kq2mR4tVx",
"status": "processing",
"poll_url": "https://api.mezdoc.com/api/v1/submissions/sub_8Kq2mR4tVx"
}The template version is fixed when the request arrives, so a publish between the request and the render never changes the output. Poll poll_url until status is completed, when the body carries pdf_url, or wait for the submission.completed webhook.
Shaping the data object
| Field type in the schema | Send |
|---|---|
| text, email, phone, url, name, address, ssn, ein, vin, iban, swift | A string. |
| number, numeric, currency, percentage, calculated | A number. |
| boolean, checkbox | true or false. |
| date, date-dropdowns | A string in YYYY-MM-DD, such as 2026-06-01. |
| dropdown, radio-group | The chosen option's value. |
| multi-select | An array of option values. |
| image, signature | A public image URL or a data: URL. |
| object, json-object | An object keyed by the field's sub-field keys. |
A field with is_array: true takes an array of those values, which is how repeating table rows arrive. A missing or wrong-typed required field returns 400 data_invalid with an errors array naming the field.
Endpoints
Nine endpoints, all under /api/v1. Keys and webhooks are managed in the dashboard.
| Endpoint | Scope | Returns |
|---|---|---|
GET /api/v1/templates | template:read | Your templates: alias, name, kind, status and live version. |
GET /api/v1/templates/:alias | template:read | One template and its input schema: the data keys to send. |
POST /api/v1/templates/:alias/render | template:render | The PDF, JSON with ?return=url, or 202 with ?async=true. |
GET /api/v1/submissions | submission:read | Submissions, newest first. Filter by status, environment or template; page with created_before. |
GET /api/v1/submissions/:id | submission:read | One submission's status and metadata. This is the async poll URL. |
GET /api/v1/submissions/:id/pdf | submission:read | The PDF bytes. |
POST /api/v1/workflows/:alias/runs | workflow:run | Runs a workflow: the merged packet and each document, or 202 with ?async=true. |
GET /api/v1/workflows/runs/:runId | workflow:run | One run's status. This is the async poll URL for runs. |
GET /api/v1/workflows/runs/:runId/pdf | workflow:run | The merged packet, one document with ?doc=0, or everything as a zip with ?doc=zip. |
pdf_url and poll_url values point at these endpoints, never at raw storage, and need the same key. Opening one in a browser returns 401, which is expected.
Workflow runs
A workflow puts several templates behind one set of answers. One call fills every document that applies and returns the packet.
curl -X POST "https://api.mezdoc.com/api/v1/workflows/renewal_packet/runs" \
-H "Authorization: Bearer $MEZDOC_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "data": { "insured_name": "Harbor Point LLC", "state": "TX" } }'# 200 OK
{
"run_id": "run_4Tz9Lq2Wm",
"status": "completed",
"page_count": 9,
"merged_pdf_url": "https://api.mezdoc.com/api/v1/workflows/runs/run_4Tz9Lq2Wm/pdf?doc=merged",
"documents": [
{ "name": "Binder", "alias": "binder", "url": ".../pdf?doc=0" },
{ "name": "Certificate", "alias": "certificate", "url": ".../pdf?doc=1" }
],
"document_pdf_urls": [ ".../pdf?doc=0", ".../pdf?doc=1" ]
}The body is { data }, keyed by the workflow's field aliases. Add ?async=true to get 202 with a poll_url and render in the background, then poll the run or wait for workflow.run.completed.
For a worked insurance example, see the quote-to-binder document workflow for US MGAs.
Safe retries
Put an idempotency_key in the render body and a retry can never render twice.
- A repeat with the same key and the same data returns the original submission: the same PDF or JSON in sync mode, the same submission_id and its current status in async mode.
- Keys are unique per template within your organization, so reuse your own order or policy id.
- The same key with different data returns 409 idempotency_conflict. So does a sync repeat while the first attempt is still processing or has failed.
- A replay that returns an existing submission does not count against your plan.
Webhooks
Mezdoc POSTs a signed JSON event to your endpoint when a render or a workflow run finishes, so you never have to poll.
Set up an endpoint
In the dashboard, under API, add an endpoint: an https URL we can reach, the environment it listens to, the events it wants and the sources that fire it. Private, loopback and cloud metadata addresses are refused and checked again at every delivery. The signing secret, whsec_..., is shown once.
| Event | Fires when |
|---|---|
submission.completed | A render finished. The payload links the PDF. |
submission.failed | A render could not be produced. The payload carries the error. |
workflow.run.completed | Every document in a run is done and the packet is ready. |
workflow.run.failed | A run could not be completed. |
| Source | Fires by default |
|---|---|
| Async API renders (?async=true) | Yes |
| Hosted forms and embeds | Yes |
| Sync API renders | No. The PDF is already in the response; turn it on per endpoint. |
What a delivery looks like
| Header | Example |
|---|---|
| Content-Type | application/json |
| User-Agent | Mezdoc-Webhooks/1 |
| Mezdoc-Event-Id | evt_3f9a1c2b... |
| Mezdoc-Event-Type | submission.completed |
| Mezdoc-Signature | t=1790519400,v1=5f8b... |
{
"id": "evt_3f9a1c2b7d8e4f60a1b2c3d4e5f60718",
"type": "submission.completed",
"created": 1790519400,
"data": {
"submission_id": "sub_8Kq2mR4tVx",
"template_id": "utpl_2Hc7Qw",
"kind": "dynamic",
"environment": "production",
"status": "completed",
"page_count": 3,
"sha256": "ee1922af94c5...",
"error": null,
"pdf_url": "https://app.mezdoc.com/api/v1/submissions/sub_8Kq2mR4tVx/pdf",
"created_at": "2026-09-27T14:30:00.000Z",
"completed_at": "2026-09-27T14:30:02.140Z"
}
}pdf_url is an authenticated API link: download it with your key. A webhook never carries the PDF itself. Workflow events carry run_id and workflow_id in place of submission_id and template_id.
Verify the signature
Mezdoc-Signature is t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 of <t>.<raw body> with your secret, compare it with v1 in constant time, and reject a t more than 300 seconds from now.
// Express. Hash the raw body, before any JSON parsing.
const crypto = require("crypto");
function verifyMezdoc(secret, rawBody, header, toleranceSec = 300) {
const parts = {};
for (const seg of header.split(",")) {
const i = seg.indexOf("=");
if (i > 0) parts[seg.slice(0, i).trim()] = seg.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!t || !parts.v1) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/mezdoc", express.raw({ type: "application/json" }), (req, res) => {
const ok = verifyMezdoc(process.env.MEZDOC_WEBHOOK_SECRET, req.body.toString(), req.get("Mezdoc-Signature") || "");
if (!ok) return res.status(400).send("bad signature");
res.status(200).send("ok"); // respond fast, then process the event
});import hmac, hashlib, time
def verify_mezdoc(secret, raw_body, header, tolerance=300):
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t = int(parts.get("t", "0") or "0")
if not t or "v1" not in parts:
return False
if abs(int(time.time()) - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])// app/api/webhooks/mezdoc/route.ts. Read the body with req.text(), never req.json().
import crypto from "node:crypto";
export async function POST(req: Request) {
const rawBody = await req.text();
const sig = req.headers.get("Mezdoc-Signature") ?? "";
if (!verifyMezdoc(process.env.MEZDOC_WEBHOOK_SECRET!, rawBody, sig)) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(rawBody);
// event.data.submission_id, event.data.pdf_url ... respond fast, then process
return new Response("ok", { status: 200 });
}
// verifyMezdoc: the same function as the Node tab.Delivery rules
- Answer with a 2xx within 10 seconds, then do the work. Redirects are not followed.
- Anything else is retried up to 5 times with growing backoff. Every attempt shows in the dashboard delivery log.
- Delivery is at least once: dedupe on Mezdoc-Event-Id. Order is not guaranteed, so use created and your own state.
- A callback_url on a single render always fires for that render but is not signed: confirm it by fetching the submission with your key.
Errors
Every error uses one envelope: a code you can switch on and a message you can show.
# 400 Bad Request
{
"code": "data_invalid",
"message": "Field \"effective_date\" is required.",
"errors": [{ "alias": "effective_date", "message": "Field \"effective_date\" is required." }]
}| Status and code | When |
|---|---|
400 bad_request | The body is not valid JSON, or a query parameter is out of range. |
400 data_invalid | data does not match the template's schema. errors names each field. |
401 unauthorized | The key is missing, malformed, revoked or expired. |
403 forbidden | The key lacks the scope this endpoint needs. |
404 not_found | No template, submission or run with that alias or id. |
409 not_published | Nothing is published to the key's environment. |
409 template_invalid | The published version cannot be rendered. |
409 idempotency_conflict | The idempotency_key was used with different data, or its first attempt is still processing or failed. |
413 payload_too_large | The body is over 1 MB. |
429 rate_limited | Your workspace has reached its available monthly usage, so additional production is paused until the allowance resets or the plan changes. The body includes limit, used, resets_at and upgrade_url, and Retry-After gives the seconds until the reset. |
500 render_failed | The renderer failed. A failed submission is recorded for the audit log. |
500 internal | Something unexpected went wrong on our side. |
Limits
| Limit | Value |
|---|---|
| Request body | 1 MB |
| Submissions per list page | 50 by default, up to 100 with ?limit= |
| Webhook response time | 10 seconds |
| Webhook retries | Up to 5, with backoff |
| Signature tolerance | 300 seconds |
Try every endpoint in Postman.
The collection has all nine requests with sample bodies. Set token to your API key and press Send.
Questions from developers.
- How do I authenticate with the Mezdoc API?
Send your secret key as a bearer token: Authorization: Bearer sk_live_... Keys are created in the dashboard, shown once and stored only as a hash. The prefix names the environment the key renders from, and each key carries scopes that limit what it can do.
- Should I render synchronously or asynchronously?
A plain render call waits and returns the PDF, or JSON with a link to it when you add ?return=url. Add ?async=true to get a 202 with a poll_url straight away, then poll it or wait for the submission.completed webhook. The template version is fixed when the request arrives either way.
- How do I make retries safe?
Put an idempotency_key in the render body. A repeat with the same key and the same data returns the original submission instead of rendering again, and does not count against your plan. The same key with different data returns 409 idempotency_conflict.
- How do I check that a webhook came from Mezdoc?
Each delivery to an endpoint you configured carries a Mezdoc-Signature header with a timestamp and an HMAC-SHA256 of the timestamp and the raw body, keyed with your whsec_ secret. Recompute it, compare in constant time, reject timestamps older than five minutes, and dedupe on Mezdoc-Event-Id.
Send your first render.
Free plan, no card