One HTTP API turns the label code you already print into clean templates you render by name — deterministic, versioned, cached at the edge. Pick your language once; every example on this page follows it.
Maddox Render is a REST API. Every request is HTTPS to https://api.maddoxapi.dev/v1, authenticated with a Bearer key, and speaks JSON. There are three calls in the hot path — synthesize a template from legacy ZPL, render it by name, and subscribe a webhook — plus a handful of helpers.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /v1/templates/synthesize | Turn legacy ZPL into a reusable template draft |
| POST | /v1/labels/from-data | Render a committed template to a PNG by name |
| POST | /v1/labels | Render raw ZPL straight to a PNG (no template) |
| GET | /v1/whoami | Return the tenant + plan behind the current key |
| GET | /v1/usage | Live rate-limit, concurrency, and quota counters |
| POST | /v1/webhooks/:id | Subscribe a URL to the label.rendered event |
The whole path, start to scan:
mdx_live_3c8f1a90….export MADDOX_API_KEY=mdx_live_…Render your first label — this exact request works against your Sandbox key:
curl -X POST https://api.maddoxapi.dev/v1/labels/from-data \
-H "Authorization: Bearer $MADDOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": "tpl_asset_tag_2x1",
"fields": { "asset_id": "MER-04821", "location": "Aisle 7 - Bin 12" }
}'{
"status": "ok",
"content_hash": "9f2c…a1b7",
"url": "https://api.maddoxapi.dev/v1/labels/9f2c…?sig=…",
"cached": false
}Your free Sandbox includes 5 syntheses and 50 labels a month, full-quality output, no watermark.
All requests use a Bearer API key in the Authorization header. Your tenant is derived from the key, so you never pass an account id. Keys look like mdx_live_3c8f1a90… — a mdx_live_ prefix followed by 64 hex characters. The full secret is shown once, at creation; afterwards only the 8-character prefix is visible.
Verify a key and see which plan it maps to:
curl https://api.maddoxapi.dev/v1/whoami \
-H "Authorization: Bearer $MADDOX_API_KEY"Two independent ceilings apply: a per-minute request rate and a concurrency cap on in-flight renders. Monthly syntheses and labels are plan quotas — Sandbox enforces hard caps, paid plans meter overages. Everything below is per tenant.
| Plan | Requests / min | Concurrent renders | Syntheses / mo | Labels / mo | Max DPI | Retention |
|---|---|---|---|---|---|---|
| Sandbox | 10 | 1 | 5 | 50 | 203 | 14 days |
| Pro | 100 | 4 | 50 | 10,000 | 300 | 90 days |
| Warehouse | 500 | 15 | 200 | 50,000 | 600 | 365 days |
| Scale | 1000 | 30 | 500 | 150,000 | 600 | 365 days |
When you exceed the request rate you get a 429. Back off using the Retry-After header (seconds); the X-RateLimit-* headers on every render response tell you where you stand:
HTTP/1.1 429 Too Many Requests
Retry-After: 0.6
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1717700060Maddox uses conventional HTTP status codes. 2xx means success, 4xx means the request was rejected (and tells you why), 5xx means something failed on our side. Every error body carries the same error envelope:
{
"error": {
"type": "invalid_request_error",
"message": "Unknown template_id 'tpl_nope'.",
"param": "template_id"
}
}| Status | type | Meaning |
|---|---|---|
| 400 | invalid_request_error | Malformed JSON, an invalid DPI or font profile, or an unrecognized field. |
| 401 | authentication_error | Missing, malformed, or revoked API key. |
| 402 | invalid_request_error | Monthly quota exhausted with overages off (Sandbox hard cap), or the subscription has lapsed. |
| 403 | permission_error | Key is valid but the template is out of scope, or the plan lacks the feature (e.g. a DPI above your plan's max). |
| 404 | not_found_error | Unknown template_id, webhook id, or a label that has aged out of retention. |
| 409 | invalid_request_error | Idempotency-Key replayed with a different body, or your plan's API-key cap is reached. |
| 413 | invalid_request_error | Request body exceeds the 5 MB limit. |
| 422 | validation_error | A required field is missing or a value is malformed (including ZPL that can't be parsed). |
| 429 | rate_limit_error | Request-rate cap exceeded. Honor Retry-After. |
| 5xx | api_error | Transient server error (502/503/504). Safe to retry with backoff — renders are idempotent. |
200 with a low confidence_score so you can decide whether to commit it. Only unparseable ZPL is rejected.Rendering is content-addressed. Maddox hashes the committed template version plus the exact field values you send and returns that as content_hash. Identical inputs always produce the same hash, the same pixels, and the same signed URL — so a render is naturally idempotent.
The first time a given content_hash is requested it is rendered and stored; every repeat returns the cached object with "cached": true. That means you can safely retry after a network blip, a timeout, or a 5xx without producing a duplicate label or double-charging an overage.
| Response field | Description |
|---|---|
status string | "ok" on success. |
content_hash string | Stable hash of template version + field values. Your idempotency key. |
url string | Signed PNG link, valid for your plan's retention window. |
cached boolean | true if served from cache (no new render billed). |
Need cross-request safety for non-identical payloads too? Send an Idempotency-Key header — it's checked before the rate limiter, replays return the first result, and reusing a key with a different body returns 409.
The API is versioned in the path — every endpoint lives under /v1. Within v1 we only make backward-compatible changes: new endpoints, new optional request fields, and new fields on responses. Treat unknown response fields as forwards-compatible and ignore the ones you do not use.
Breaking changes ship under a new path prefix (/v2) and /v1 keeps running. Templates carry their own versions too — committing a template mints v1, v2, … and renders pin to the latest committed version unless you reference one explicitly.
Send legacy ZPL and get back a draft: a cleaned body with named ^FN placeholders, a field map, and a confidence score. Nothing is stored until you commit the draft in the studio or via the API.
| Parameter | Description |
|---|---|
input_zplrequired string | The raw ZPL you already print, from ^XA to ^XZ (≤ 64 KB). Multi-label streams use the first label. |
target_scopeoptional string | "tenant" (default for tenant keys) or "global". Tenant keys are always scoped to their own tenant. |
curl -X POST https://api.maddoxapi.dev/v1/templates/synthesize \
-H "Authorization: Bearer $MADDOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "input_zpl": "^XA^FO50,50^A0N,30,30^FDMER-04821^FS^XZ" }'{
"status": "ok",
"scope": "tenant",
"draft": {
"name": "asset_tag_2x1",
"width_dots": 406,
"height_dots": 203,
"source_dpi": 203,
"field_map": { "owner": 1, "asset_id": 2, "description": 3, "location": 4 },
"values": { "owner": "MERIDIAN", "asset_id": "MER-04821" },
"body": "^XA … ^XZ"
},
"confidence_score": 0.94,
"low_confidence": false
}| response field | Description |
|---|---|
draft.name string | Suggested name for the template. Rename before committing. |
draft.field_map object | Map of field name → ^FN index, e.g. { "owner": 1 }. |
draft.values object | Sample values lifted from your ZPL, keyed by ^FN index. |
confidence_score number | 0–1. When low_confidence is true (below ~0.7), review the field map before committing. |
Large or slow inputs? POST /v1/templates/synthesize/jobs returns 202 with a job_id; poll GET /v1/templates/synthesize/jobs/:job_id until state is succeeded or failed.
A committed template renders by id forever. Templates come in two scopes:
| Scope | Description |
|---|---|
tenant your account | Templates you synthesize and commit. Private to your tenant. |
global operator-provisioned | Shared, ready-made templates (QR asset tags, bin/location, pallet placards) available to every account. |
List what you can render with GET /v1/templates (add ?scope=tenant or ?scope=global to filter); commit a synthesized draft by id with POST /v1/templates/:id.
A template moves through three states: draft → committed → versioned. Synthesis returns a draft; committing it (in the studio or via the API) writes it to your registry and mints version v1. Re-committing the same id mints v2, v3, … — renders pin to the latest committed version unless you reference one explicitly, so fixing a template never breaks a label already in flight.
Templates never expire — only rendered labels do. Deleting a tenant template is permanent: render calls that reference it start returning 404, but labels you already rendered are unaffected. Global templates are operator-managed and cannot be deleted from your account.
Render any committed template by id. Pass only the fields that change; you get back a signed URL to the PNG, valid for your plan's retention window. DPI is fixed by the template, capped by your plan.
| Parameter | Description |
|---|---|
template_idrequired string | Id of a committed tenant or global template, e.g. tpl_asset_tag_2x1. |
fieldsrequired object | Key/value map of the template's named fields. A required field that is missing returns 422 — there is no sample-value fallback. |
curl -X POST https://api.maddoxapi.dev/v1/labels/from-data \
-H "Authorization: Bearer $MADDOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"template_id": "tpl_asset_tag_2x1",
"fields": { "asset_id": "MER-04821", "location": "Aisle 7 - Bin 12" }
}'{
"status": "ok",
"content_hash": "9f2c…a1b7",
"url": "https://api.maddoxapi.dev/v1/labels/9f2c…?sig=…",
"cached": false
}No template yet? POST /v1/labels with a zpl string (plus optional source_dpi, target_dpi, threshold, font_profile) renders one-off ZPL straight to a PNG — the same signed-URL response, no registry entry.
Mint, list, and revoke the keys for your tenant from the API as well as the API keys page. A key's full secret (mdx_live_…) is returned once, at creation — store it like a password; afterwards only its 8-character prefix is shown.
| Parameter | Description |
|---|---|
labeloptional string | An optional human label (≤ 64 chars) so you can tell keys apart — e.g. "production" or "ci". |
| response field | Description |
|---|---|
apiKey string | The full secret, shown once. Copy it now — it cannot be retrieved again. |
key_prefix string | The 8-char prefix that identifies this key everywhere else. |
label string | null | The label you supplied, or null. |
GET /v1/keys lists every key (prefix, label, status, created_at — never the secret). DELETE /v1/keys/:prefix revokes one; the next request that uses it gets 401.
409 — revoke an unused key or upgrade first.Rendered PNGs live behind signed URLs for a plan-dependent window, then are purged. Re-rendering identical inputs after purge is a cache miss and renders fresh. Pull anything you need to keep into your own storage from the url in the response or the webhook.
GET /v1/labels to get a fresh signed URL any time before it's purged.Subscribe a URL and Maddox calls you the moment a label is rendered — no polling. label.rendered is the only v1 event. You choose the subscription id in the path, and the call is an upsert — POST the same id again to update the endpoint. Endpoints must be public HTTPS (loopback and cloud-metadata addresses are rejected). Failed deliveries are retried with backoff and dead-lettered if they never succeed.
| Parameter | Description |
|---|---|
urlrequired string | Public HTTPS endpoint that receives POSTed events. |
topicsrequired string[] | Array of event topics — currently only ["label.rendered"]. |
Each delivery is a JSON POST signed with HMAC-SHA256. Maddox returns a one-time signing secret (whsec_…) when you create the endpoint — store it.
POST /your-endpoint HTTP/1.1
X-Maddox-Signature: t=1717700000,v1=4f2c…e91a
Content-Type: application/json
{
"event": "label.rendered",
"tenant_id": "tnt_…",
"data": {
"template_id": "tpl_asset_tag_2x1",
"external_ref": "MER-04821",
"content_hash": "9f2c…a1b7",
"url": "https://api.maddoxapi.dev/v1/labels/9f2c…?sig=…"
}
}The X-Maddox-Signature header is t=<unix>,v1=<hex>. Recompute the HMAC over "<t>." + raw_body with your signing secret, compare in constant time, and reject anything older than ~5 minutes to stop replays. Always verify against the raw request bytes, before any JSON parsing:
#!/usr/bin/env bash
# Header: X-Maddox-Signature: t=<unix>,v1=<hex HMAC-SHA256>
# Recompute over "<t>." + raw_body and compare. $MADDOX_WHSEC is your whsec_… secret.
t="1717700000"
v1="4f2c…e91a"
signed="$t.$(cat raw_body.json)"
expected=$(printf '%s' "$signed" | openssl dgst -sha256 -hmac "$MADDOX_WHSEC" | awk '{print $2}')
[ "$expected" = "$v1" ] && echo "ok" || echo "reject"Self-serve tiers, from the free Sandbox up to Scale. Every tier ships full-fidelity output with no watermark — they differ on throughput, DPI, retention, and included volume (see Rate limits & quotas). Upgrade, downgrade, or cancel anytime; changes take effect immediately and paid plans run through Stripe Checkout.
| Plan | Price | Best for |
|---|---|---|
| Sandbox | Free | Kick the tires. Full-fidelity output, hard caps, no surprise charges. |
| Pro | $49/mo | For production label pipelines. Metered label overages, 300 DPI, 90-day retention. |
| Warehouse | $199/mo | Multi-site fulfillment. 600 DPI, higher concurrency, 365-day archival. |
| Scale | $399/mo | High-volume operations. Top concurrency and the lowest per-unit overage. |
Maddox meters two units. A synthesis turns one legacy label into a reusable template — you do it once per design. A label is a single render of a template with real data — you do it every time you print. Each plan includes a monthly allowance of both; counters reset on the 1st.
Read your live counters any time with GET /v1/usage — tokens remaining, in-flight renders, concurrency cap, and both monthly used/included figures.
Usage beyond your allowance is governed by a single overages switch — one per-account setting that covers both syntheses and labels, toggleable on paid plans and on by default:
| Overages | Description |
|---|---|
On (default) metered | Syntheses and labels above their allowance are served and billed at their per-unit overage rate, so production never stalls. |
Off hard cap | Anything above either allowance is declined instead of billed — a hard wall at your included volume for both units. |
Per-unit overage rates (Sandbox is always a hard cap — it cannot bill overages):
| Plan | Per synthesis over allowance | Per label over allowance |
|---|---|---|
| Sandbox | Hard cap | Hard cap |
| Pro | $0.50 | $0.020 |
| Warehouse | $0.40 | $0.015 |
| Scale | $0.30 | $0.010 |
"cached": true and counts as zero labels. Track live usage in your console dashboard.Paid plans are billed through Stripe. Your card is stored and processed entirely by Stripe — full card numbers never touch Maddox servers. At the end of each period you're charged the plan's base price plus any metered overage usage.
| What | Description |
|---|---|
Payment method Stripe | Add, update, or remove your card in the Stripe Customer Portal, linked from the console Billing page. |
Invoices auto | Emailed automatically each period. Download the PDF or view the line items in Stripe anytime. |
Plan changes Checkout | Upgrades and downgrades run through Stripe Checkout; proration is handled by Stripe. |
Sandbox free | No card on file and nothing to pay. Add a payment method only when you upgrade. |
[email protected] — typically within 24 hours. Card and subscription edits always happen in Stripe's PCI-compliant portal, never via the API.Signing in to the console is separate from authenticating API requests. The API only ever uses Bearer keys (see Authentication); the console supports several human sign-in methods:
| Method | Description |
|---|---|
SSO / OAuth Google · GitHub | One-click sign-in with your work or personal identity provider. No password to manage. |
Email + password 8+ characters | Classic credentials. A verified email is required before Stripe Checkout on paid signups. |
Passkey WebAuthn | Enrol a passkey in Settings for fast, phishing-resistant sign-in. |
Security defaults are built into the surface you've already seen on this page:
Four direct lines to the team — a real person reads every message. Typical first-response times:
| Channel | Response | For | |
|---|---|---|---|
| Support | [email protected] | < 24 hours | Renders, templates, integrations |
| Billing | [email protected] | < 24 hours | Invoices, plans, receipts, refunds |
| Security | [email protected] | < 24 hours | Vulnerability disclosure (credited) |
| Legal | [email protected] | 1–2 days | Contracts, DPAs, compliance |
Maddox is a plain HTTPS + JSON API, so it works from any language with an HTTP client — no SDK to install or keep up to date. We ship copy-paste examples for every language below; the picker on each code block sets the language for the whole page.
| Language | How the examples call Maddox |
|---|---|
| cURL | any shell |
| Python | 3.8+ · requests |
| JavaScript | Node 18+ · fetch |
| TypeScript | Node 18+ · fetch |
| Go | 1.21+ · net/http |
| Ruby | 3.0+ · net/http |
| PHP | 8.1+ · cURL ext |
| Java | 11+ · java.net.http |
| C# | .NET 6+ · HttpClient |
| Rust | reqwest + tokio |
| Zapier | Platform CLI · no-code automations |
url.Synthesis replaces the literal values in your ZPL with numbered field placeholders. A ^FN1 in the template body is filled by whatever name maps to index 1 in the field map. At render time you send those names; Maddox substitutes and rasterizes.
^XA
^FO40,40^A0N,46,46^FDPROPERTY OF ^FN1^FS
^FO40,110^A0N,80,80^FN2^FS
^FO40,210^A0N,30,30^FN3^FS
^FO40,250^A0N,30,30^FN4^FS
^FO40,320^BY3^BCN,120,Y,N,N^FN2^FS
^XZasset_id above feeds both the headline text and the barcode from a single value, so they can never drift.Quick answers to the questions and failure modes that come up most often.
The most common cause is a DPI above your plan's ceiling. source_dpi and target_dpi are both checked against your plan max (Sandbox 203 · Pro 300 · Warehouse/Scale 600). Lower the DPI or upgrade. A 403 can also mean the template_id belongs to another tenant's scope.
No — 402 is a quota wall, not an error in your code. On Sandbox it means you hit the hard cap (5 syntheses or 50 labels this month). On a paid plan it means you're over your included volume with overages turned off, or the subscription has lapsed. Turn overages on, upgrade, or wait for the 1st-of-month reset.
Because rendering is content-addressed — identical inputs return the cached object with "cached": true and count as zero labels. See Idempotency & caching.
Signed URLs are time-limited. If the object is still within your plan's retention window, re-list it with GET /v1/labels for a fresh signed URL. If it's past the retention window it's been purged — re-render the same inputs (a cache miss) to get it back.
Check four things: the endpoint is public HTTPS (loopback/metadata IPs are rejected at subscribe time), the topic is exactly ["label.rendered"], your handler returns a 2xx (non-2xx is retried up to 5 times then dead-lettered), and your signature check reads the raw body. POST /v1/webhooks/:id/test sends a sample delivery so you can confirm end-to-end.
A confidence_score below ~0.7 sets low_confidence: true. The draft is still usable — open it in the studio, check the field map and sample values, fix anything off, then commit. Synthesis never hard-fails on messy input; only ZPL that can't be parsed at all is rejected.
No SDK to install — it's a plain HTTPS + JSON API. Copy a snippet from Language support; the call is three lines in every language.