Best Practices
Idempotency
Always include an Idempotency-Key header on mutation endpoints (POST that creates/charges). This prevents duplicate operations on network retries.
Note: Idempotency keys expire after 24 hours. Reusing the same key within this window returns the original response instead of creating a duplicate resource.
# Safe to retry — second call returns the same job
curl -X POST https://api.zplflow.io/v1/jobs \
-H "Idempotency-Key: my-unique-key-123" \
-H "Authorization: Bearer lb_xxxx" \
-H "Content-Type: application/json" \
-d '{"operation":"pdf_to_zpl","params":{"dpi":203},"documents":[{"content_type":"application/pdf"}]}'
import requests
resp = requests.post(
"https://api.zplflow.io/v1/jobs",
headers={
"Authorization": "Bearer lb_xxxx",
"Idempotency-Key": "my-unique-key-123",
},
json={
"operation": "pdf_to_zpl",
"params": {"dpi": 203},
"documents": [{"content_type": "application/pdf"}]
}
)
package main
import (
"bytes"
"encoding/json"
"net/http"
)
func main() {
body := map[string]interface{}{
"operation": "pdf_to_zpl",
"params": map[string]int{"dpi": 203},
"documents": []map[string]string{{"content_type": "application/pdf"}},
}
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "https://api.zplflow.io/v1/jobs", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer lb_xxxx")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "my-unique-key-123")
http.DefaultClient.Do(req)
}
<?php
$ch = curl_init("https://api.zplflow.io/v1/jobs");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"operation" => "pdf_to_zpl",
"params" => ["dpi" => 203],
"documents" => [["content_type" => "application/pdf"]]
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer lb_xxxx",
"Content-Type: application/json",
"Idempotency-Key: my-unique-key-123"
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_exec($ch);
AS400 / SQL Native (IBM i)
SELECT SYSTOOLS.HTTPPOSTCLOB(
'https://api.zplflow.io/v1/jobs',
'{"operation":"pdf_to_zpl","params":{"dpi":203},"documents":[{"content_type":"application/pdf"}]}',
'{"Authorization":"Bearer lb_xxxx","Content-Type":"application/json","Idempotency-Key":"my-unique-key-123"}'
) AS response
FROM SYSIBM.SYSDUMMY1;
AS400 / HTTPAPI (RPG)
dcl-s body varchar(500);
dcl-s response varchar(32000);
dcl-s rc int(10);
body = '{"operation":"pdf_to_zpl","params":{"dpi":203},"documents":[{"content_type":"application/pdf"}]}';
rc = http_req('POST'
: 'https://api.zplflow.io/v1/jobs'
: *null
: %trim(response)
: 'Authorization: Bearer lb_xxxx'
: 'Content-Type: application/json'
: 'Idempotency-Key: my-unique-key-123'
: 30000
: %trim(body));
| Scenario | Behavior |
|---|---|
| Same key + same body | Existing resource returned |
| Same key + different body | 409 IDEMPOTENCY_CONFLICT |
| No key provided | 400 INVALID_PARAMS |
Generate unique keys server-side (UUID, ULID) or use a deterministic hash of the request body. Keys expire after 24 hours.
Pagination
List endpoints use cursor-based pagination:
# First page
curl -H "Authorization: Bearer lb_xxxx" \
"https://api.zplflow.io/v1/jobs?limit=50"
# Subsequent pages — use the next_cursor from the response
curl -H "Authorization: Bearer lb_xxxx" \
"https://api.zplflow.io/v1/jobs?limit=50&cursor=eyJsYXN0X2lkIjoiLi4uIn0="
import requests
headers = {"Authorization": "Bearer lb_xxxx"}
# First page
resp = requests.get("https://api.zplflow.io/v1/jobs?limit=50", headers=headers)
data = resp.json()
cursor = data.get("next_cursor")
# Subsequent pages
while cursor:
resp = requests.get(f"https://api.zplflow.io/v1/jobs?limit=50&cursor={cursor}", headers=headers)
data = resp.json()
cursor = data.get("next_cursor")
package main
import (
"encoding/json"
"net/http"
)
func main() {
client := &http.Client{}
cursor := ""
for {
url := "https://api.zplflow.io/v1/jobs?limit=50"
if cursor != "" {
url += "&cursor=" + cursor
}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer lb_xxxx")
resp, _ := client.Do(req)
var data struct {
NextCursor string `json:"next_cursor"`
}
json.NewDecoder(resp.Body).Decode(&data)
resp.Body.Close()
cursor = data.NextCursor
if cursor == "" {
break
}
}
}
<?php
$cursor = null;
do {
$url = "https://api.zplflow.io/v1/jobs?limit=50";
if ($cursor) $url .= "&cursor=" . urlencode($cursor);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer lb_xxxx"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$data = json_decode(curl_exec($ch), true);
$cursor = $data['next_cursor'] ?? null;
} while ($cursor);
AS400 / SQL Native (IBM i)
-- First page
SELECT SYSTOOLS.HTTPGETCLOB(
'https://api.zplflow.io/v1/jobs?limit=50',
NULL,
'{"Authorization":"Bearer lb_xxxx"}'
) AS response
FROM SYSIBM.SYSDUMMY1;
-- Subsequent pages: replace CURSOR_VALUE with the next_cursor value
SELECT SYSTOOLS.HTTPGETCLOB(
'https://api.zplflow.io/v1/jobs?limit=50&cursor=CURSOR_VALUE',
NULL,
'{"Authorization":"Bearer lb_xxxx"}'
) AS response
FROM SYSIBM.SYSDUMMY1;
AS400 / HTTPAPI (RPG)
dcl-s cursor varchar(200);
dcl-s url varchar(500);
dcl-s response varchar(32000);
dcl-s rc int(10);
cursor = '';
dou cursor = *blanks;
url = 'https://api.zplflow.io/v1/jobs?limit=50';
if cursor <> *blanks;
url = %trim(url) + '&cursor=' + %trim(cursor);
endif;
rc = http_req('GET'
: %trim(url)
: *null
: %trim(response)
: 'Authorization: Bearer lb_xxxx'
: *null
: *null
: 30000
: *null);
// Parse next_cursor from response, set cursor
cursor = ''; // set to next_cursor value or leave blank when done
enddo;
An empty next_cursor means the last page.
Token Management
- Check your balance with
GET /v1/account/balancebefore large batch operations - Use
/v1/estimateto pre-calculate costs without executing - Monitor over-budget in the billing dashboard to avoid unexpected charges
- Cancel stale jobs in
createdstatus to release reserved tokens
Error Handling
Always check the error_code field, not just the HTTP status code.
resp = requests.post("https://api.zplflow.io/v1/jobs", ...)
data = resp.json()
if resp.status_code == 409 and data["error_code"] == "INSUFFICIENT_TOKENS":
# Recharge account or reduce operation size
pass
elif resp.status_code == 409 and data["error_code"] == "IDEMPOTENCY_CONFLICT":
# Different request with the same idempotency key — use a new key
pass
package main
import (
"encoding/json"
"net/http"
)
func main() {
resp, _ := http.Post("https://api.zplflow.io/v1/jobs", "application/json", nil)
var data struct {
ErrorCode string `json:"error_code"`
}
json.NewDecoder(resp.Body).Decode(&data)
resp.Body.Close()
if resp.StatusCode == 409 && data.ErrorCode == "INSUFFICIENT_TOKENS" {
// Recharge account or reduce operation size
} else if resp.StatusCode == 409 && data.ErrorCode == "IDEMPOTENCY_CONFLICT" {
// Different request with the same idempotency key — use a new key
}
}
<?php
$ch = curl_init("https://api.zplflow.io/v1/jobs");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$resp = curl_exec($ch);
$data = json_decode($resp, true);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 409 && $data['error_code'] === 'INSUFFICIENT_TOKENS') {
// Recharge account or reduce operation size
} elseif ($httpCode === 409 && $data['error_code'] === 'IDEMPOTENCY_CONFLICT') {
// Different request with the same idempotency key — use a new key
}
### AS400 / HTTPAPI (RPG)
dcl-s httpStatus int(10);
dcl-s response varchar(32000);
dcl-s rc int(10);
rc = http_req('POST'
: 'https://api.zplflow.io/v1/jobs'
: *null
: %trim(response)
: *null
: *null
: *null
: 30000
: '{"operation":"pdf_to_zpl","params":{"dpi":203},"documents":[{"content_type":"application/pdf"}]}');
// Parse response JSON to extract error_code
// http_req returns HTTP status in rc
if rc = 409;
// Check error_code in response
endif;
All Error Codes
| HTTP | Code | Meaning |
|---|---|---|
| 400 | INVALID_PARAMS |
Invalid request body or parameters |
| 401 | UNAUTHORIZED |
Missing or invalid API key |
| 403 | FORBIDDEN |
Tenant suspended or key revoked |
| 404 | NOT_FOUND / JOB_NOT_FOUND |
Resource not found |
| 409 | IDEMPOTENCY_CONFLICT |
Same key, different body |
| 409 | INSUFFICIENT_TOKENS |
Balance too low |
| 409 | BUDGET_EXCEEDED |
Over-budget limit reached |
| 409 | JOB_NOT_STARTABLE |
Job not in created state |
| 409 | JOB_INPUT_NOT_FOUND |
Document missing from storage |
| 409 | ALREADY_FINALIZED |
Job in terminal state |
| 413 | INPUT_TOO_LARGE |
Exceeds size limit |
| 415 | UNSUPPORTED_MEDIA_TYPE |
Wrong Content-Type |
| 429 | RATE_LIMITED |
Rate limit exceeded |
| 500 | INTERNAL_ERROR |
Unexpected server error |
Rate Limits
Rate limits are applied per tenant using a sliding window. If you hit a limit, the response includes a Retry-After header with the number of seconds to wait.
Implement exponential backoff with jitter in your client:
import time, random
def api_call(url, headers, max_retries=5):
for attempt in range(max_retries):
resp = requests.get(url, headers=headers)
if resp.status_code != 429:
return resp
wait = (2 ** attempt) + random.uniform(0, 1)
time.sleep(wait)
raise Exception("max retries exceeded")
package main
import (
"math"
"math/rand"
"net/http"
"time"
)
func apiCall(url string, headers map[string]string, maxRetries int) (*http.Response, error) {
for attempt := 0; attempt < maxRetries; attempt++ {
req, _ := http.NewRequest("GET", url, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 429 {
return resp, nil
}
wait := time.Duration(math.Pow(2, float64(attempt))*1000+rand.Float64()*1000) * time.Millisecond
time.Sleep(wait)
}
return nil, http.ErrAbortHandler
}
<?php
function apiCall($url, $headers, $maxRetries = 5) {
for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 429) {
return $resp;
}
$wait = pow(2, $attempt) + mt_rand() / mt_getrandmax();
sleep($wait);
}
throw new Exception("max retries exceeded");
}
AS400 / HTTPAPI (RPG)
dcl-s attempt int(10);
dcl-s response varchar(32000);
dcl-s rc int(10);
dcl-s wait float(8);
for attempt = 0 to 4;
rc = http_req('GET'
: 'https://api.zplflow.io/v1/jobs'
: *null
: %trim(response)
: 'Authorization: Bearer lb_xxxx'
: *null
: *null
: 30000
: *null);
if rc <> 429;
// success
leave;
endif;
wait = 2 ** attempt + %rand() / 1000.0;
rc = sleep(wait);
endfor;
Security
- Rotate API keys regularly from the Admin UI
- Use separate keys for development and production
- Never log the full API key — only log the last 4 characters
- Set size limits appropriate for your use case: max 5 MB for PDF, 512 KB for ZPL
Least-Privilege API Keys
Since v1.1, every API key carries an explicit list of scopes. The right pattern is to give each consumer only the scopes it actually needs.
When to issue a scoped key vs the master key
| Scenario | Recommended pattern |
|---|---|
| Single service with full access | Master key (wildcard legacy), all features |
| Multiple distinct consumers (WMS, ERP, partner) | One scoped key per consumer |
| External integration owned by a third party | Scoped key with an expires_at matching the contract |
| Read-only audit or reporting | Scoped key, narrow read-only scopes, possibly a locked key plus tenant-level read scopes |
| Throwaway CI run | Scoped key with expires_at set to the build window |
If a consumer only needs one endpoint, give them only the matching scope. A job-poll loop does not need convert:pdf_to_zpl; a CI label printer does not need audit:read or account:export.
Concrete recipes
WMS production key
The warehouse management system runs sync conversion on label print and occasional job creation for bulk reprints.
convert:pdf_to_zplconvert:zpl_to_pdfjobs:createjobs:readjobs:writeaccount:read(for cost dashboards embedded in the WMS)
Anti-pattern: do not add apikeys:manage, audit:read, or any gdpr:* scope to a WMS key — those belong on the operator key.
Partner read-only key
A 3PL needs visibility into jobs created on its behalf but cannot mutate state.
jobs:readaccount:read
Optionally pipelines:read if the partner can browse shared templates. Lock expires_at to the partner contract end date.
CI bot key
A CI service runs convertZPLToPDF previews and never touches billing or PII.
convert:zpl_to_pdfconvert:estimate(so the CI can pre-budget)pipelines:applyonly if the CI applies review pipelines
Set expires_at to 90 days. Rotate every build-agent fleet change.
Audit exporter key
A nightly job exports the tenant’s full audit log and account bundle for an external SIEM.
audit:readaccount:export
That is it. Explicit empty scope list is not appropriate here because audit endpoints are not public — they require the two scopes above.
Anti-patterns to avoid
- Star-scoped master copy. Do not paste the same
scopesarray used for the WMS into a partner key. Issue a key per consumer with only the scopes that consumer uses. - Scope over-grant for “future-proofing”. A key never used for
account:exportshould not carryaccount:export. Revocation is cheaper than breach forensics. - Long-lived CI keys without
expires_at. Always set a deadline. - Sharing one key across environments. Use different keys per environment (
label: "wms-prod",label: "wms-staging") so a leaked key can be revoked without touching production traffic. - Storing raw keys in CI secrets in plaintext. Even with scoped keys, the raw value belongs in the platform’s secret manager, not in YAML.
Note on platform rollout
If your account predates deny-by-default mode, contact your platform operator to schedule the transition. After the transition, every existing key must be re-issued with an explicit scope list via POST /v1/apikeys or the admin UI “Generate Key” modal. The procedure is reversible on request until each operator key has been re-issued.
See Also
- Conversions — Sync conversion endpoints
- Jobs API — Async workflow for batch processing
- Pipelines — ZPL transformation step reference
- Code Examples — Multi-language snippets