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/balance before large batch operations
  • Use /v1/estimate to pre-calculate costs without executing
  • Monitor over-budget in the billing dashboard to avoid unexpected charges
  • Cancel stale jobs in created status 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_zpl
  • convert:zpl_to_pdf
  • jobs:create
  • jobs:read
  • jobs:write
  • account: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:read
  • account: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_pdf
  • convert:estimate (so the CI can pre-budget)
  • pipelines:apply only 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:read
  • account: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 scopes array 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:export should not carry account: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