maddoxdocs
Developer documentation

Maddox Render API

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.

Base · https://api.maddoxapi.dev/v1Bearer authJSON in · PNG out
Overview

Introduction#

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.

MethodEndpointPurpose
POST/v1/templates/synthesizeTurn legacy ZPL into a reusable template draft
POST/v1/labels/from-dataRender a committed template to a PNG by name
POST/v1/labelsRender raw ZPL straight to a PNG (no template)
GET/v1/whoamiReturn the tenant + plan behind the current key
GET/v1/usageLive rate-limit, concurrency, and quota counters
POST/v1/webhooks/:idSubscribe a URL to the label.rendered event
New here? Jump to the Quickstart — first label to scan in five minutes, no card required.
Get started in 5 minutes

Quickstart#

The whole path, start to scan:

1
Get your API key
Find it on the API keys page. It is your Bearer token for every request — keys look like mdx_live_3c8f1a90….
2
Export it to your shell
Keep the secret out of source: export MADDOX_API_KEY=mdx_live_…
3
Synthesize a template
Paste a label you already print and get back a named, reusable template with an editable field map.
4
Render by name
Send only the fields that change; get a finished PNG back as a signed URL.
5
Wire it into your app
Copy the snippet below straight into your codebase.

Render your first label — this exact request works against your Sandbox key:

cURLPythonJavaScriptTypeScriptGoRubyPHPJavaC#RustZapier
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" }
  }'
200 OK
{
  "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.

Every request

Authentication#

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.

GEThttps://api.maddoxapi.dev/v1/whoami

Verify a key and see which plan it maps to:

cURLPythonJavaScriptTypeScriptGoRubyPHPJavaC#RustZapier
curl https://api.maddoxapi.dev/v1/whoami \
  -H "Authorization: Bearer $MADDOX_API_KEY"
Keep keys server-side. Rotate with create-then-revoke so a running app never locks itself out — generate the new key, deploy it, then revoke the old one. Manage everything on the API keys page.
Throughput

Rate limits & quotas#

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.

PlanRequests / minConcurrent rendersSyntheses / moLabels / moMax DPIRetention
Sandbox10155020314 days
Pro10045010,00030090 days
Warehouse5001520050,000600365 days
Scale100030500150,000600365 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:

Response headers
HTTP/1.1 429 Too Many Requests
Retry-After: 0.6
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1717700060
Renders are deterministic and cached (see Idempotency & caching) — a cache hit returns instantly and does not count against your concurrency cap, so retrying an identical render is cheap and safe. Track live counters any time with `GET /v1/usage`.
When things go wrong

Errors#

Maddox 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 body
{
  "error": {
    "type": "invalid_request_error",
    "message": "Unknown template_id 'tpl_nope'.",
    "param": "template_id"
  }
}
StatustypeMeaning
400invalid_request_errorMalformed JSON, an invalid DPI or font profile, or an unrecognized field.
401authentication_errorMissing, malformed, or revoked API key.
402invalid_request_errorMonthly quota exhausted with overages off (Sandbox hard cap), or the subscription has lapsed.
403permission_errorKey is valid but the template is out of scope, or the plan lacks the feature (e.g. a DPI above your plan's max).
404not_found_errorUnknown template_id, webhook id, or a label that has aged out of retention.
409invalid_request_errorIdempotency-Key replayed with a different body, or your plan's API-key cap is reached.
413invalid_request_errorRequest body exceeds the 5 MB limit.
422validation_errorA required field is missing or a value is malformed (including ZPL that can't be parsed).
429rate_limit_errorRequest-rate cap exceeded. Honor Retry-After.
5xxapi_errorTransient server error (502/503/504). Safe to retry with backoff — renders are idempotent.
Synthesis never fails on low-quality input — a hard-to-read label returns 200 with a low confidence_score so you can decide whether to commit it. Only unparseable ZPL is rejected.
Safe retries

Idempotency & caching#

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 fieldDescription
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.

Change any field value, or commit a new template version, and the hash changes — that is a new, billable render. Re-sending the exact same payload does not.
Stability

Versioning#

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.

POST /v1/templates/synthesize

Synthesize a template#

POSThttps://api.maddoxapi.dev/v1/templates/synthesize

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.

ParameterDescription
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.
cURLPythonJavaScriptTypeScriptGoRubyPHPJavaC#RustZapier
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" }'
200 OK
{
  "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 fieldDescription
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.

Registry

Templates & scopes#

A committed template renders by id forever. Templates come in two scopes:

ScopeDescription
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.

Lifecycle

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.

Synthesize once, render forever. Most teams commit a handful of templates and render thousands of labels against them — which is why labels are the metered unit, not templates.
POST /v1/labels/from-data

Render a label#

POSThttps://api.maddoxapi.dev/v1/labels/from-data

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.

ParameterDescription
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.
cURLPythonJavaScriptTypeScriptGoRubyPHPJavaC#RustZapier
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" }
  }'
200 OK
{
  "status": "ok",
  "content_hash": "9f2c…a1b7",
  "url": "https://api.maddoxapi.dev/v1/labels/9f2c…?sig=…",
  "cached": false
}

Rendering raw ZPL

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.

POST · GET · DELETE /v1/keys

API keys#

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.

POSThttps://api.maddoxapi.dev/v1/keys
ParameterDescription
labeloptional
string
An optional human label (≤ 64 chars) so you can tell keys apart — e.g. "production" or "ci".
response fieldDescription
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.

Each plan caps how many active keys you can hold (Sandbox 5 · Pro 10 · Warehouse 25 · Scale 50). Creating past the cap returns 409 — revoke an unused key or upgrade first.
Storage

Label retention#

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.

SandboxLabels retained 14 days · 203 DPI max
ProLabels retained 90 days · 300 DPI max
WarehouseLabels retained 365 days · 600 DPI max
ScaleLabels retained 365 days · 600 DPI max
A signed URL's lifetime and a label's retention window are two different clocks. The URL signature expires fairly quickly; the object is kept for your plan's full retention window — re-list it with GET /v1/labels to get a fresh signed URL any time before it's purged.
label.rendered

Webhooks#

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.

POSThttps://api.maddoxapi.dev/v1/webhooks/:id
ParameterDescription
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.

DeliveryZapier trigger
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=…"
  }
}

Verifying signatures

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:

cURLPythonJavaScriptTypeScriptGoRubyPHPJavaC#Rust
#!/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"
Plans & billing

Plans & upgrades#

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.

PlanPriceBest for
SandboxFreeKick the tires. Full-fidelity output, hard caps, no surprise charges.
Pro$49/moFor production label pipelines. Metered label overages, 300 DPI, 90-day retention.
Warehouse$199/moMulti-site fulfillment. 600 DPI, higher concurrency, 365-day archival.
Scale$399/moHigh-volume operations. Top concurrency and the lowest per-unit overage.
Plans & billing

Usage & metering#

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:

OveragesDescription
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):

PlanPer synthesis over allowancePer label over allowance
SandboxHard capHard cap
Pro$0.50$0.020
Warehouse$0.40$0.015
Scale$0.30$0.010
Cached renders don't bill. Because rendering is content-addressed (see Idempotency & caching), re-sending an identical payload returns "cached": true and counts as zero labels. Track live usage in your console dashboard.
Plans & billing

Billing & invoices#

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.

WhatDescription
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.
Billing questions? [email protected] — typically within 24 hours. Card and subscription edits always happen in Stripe's PCI-compliant portal, never via the API.
Accounts & trust

Accounts & sign-in#

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:

MethodDescription
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.
Your tenant is derived from whichever identity you use — every key, template, and label belongs to that one tenant.
Accounts & trust

Security#

Security defaults are built into the surface you've already seen on this page:

TransportHTTPS only for the API; webhook endpoints must be public TLS — loopback and cloud-metadata addresses are rejected.
API keysServer-side only. Stored only as a SHA-256 hash; the full secret is shown once. Rotate with create-then-revoke.
WebhooksEvery delivery is HMAC-SHA256 signed; verify the signature and reject stale timestamps to block replays.
PaymentsCard data is handled by Stripe (PCI-DSS). Maddox never sees or stores full card numbers.
DisclosureReport vulnerabilities to [email protected] — typically acknowledged in under 24 hours, and we credit your find.
Accounts & trust

Support & SLAs#

Four direct lines to the team — a real person reads every message. Typical first-response times:

ChannelEmailResponseFor
Support[email protected]< 24 hoursRenders, templates, integrations
Billing[email protected]< 24 hoursInvoices, plans, receipts, refunds
Security[email protected]< 24 hoursVulnerability disclosure (credited)
Legal[email protected]1–2 daysContracts, DPAs, compliance
Reference

Language support#

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.

LanguageHow the examples call Maddox
cURLany shell
Python3.8+ · requests
JavaScriptNode 18+ · fetch
TypeScriptNode 18+ · fetch
Go1.21+ · net/http
Ruby3.0+ · net/http
PHP8.1+ · cURL ext
Java11+ · java.net.http
C#.NET 6+ · HttpClient
Rustreqwest + tokio
ZapierPlatform CLI · no-code automations
Need a language that is not listed? The team is happy to add a snippet — the call is the same three lines everywhere: set the Bearer header, POST JSON, read the url.
Reference

ZPL Field Mapping#

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.

Template body (after synthesis)field_mapRender fields
^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
^XZ
A field can appear more than once — asset_id above feeds both the headline text and the barcode from a single value, so they can never drift.
Reference

Troubleshooting#

Quick answers to the questions and failure modes that come up most often.

My render returns 403 — why?

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.

I got a 402 — is something broken?

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.

Why did my second identical render not bill?

Because rendering is content-addressed — identical inputs return the cached object with "cached": true and count as zero labels. See Idempotency & caching.

A label URL stopped working.

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.

My webhook isn't firing.

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.

Synthesis returned low confidence.

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.

Do you provide an SDK?

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.