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/v1/jobs \
-H "Idempotency-Key: my-unique-key-123" \
-H "Authorization: Bearer lb_live_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/v1/jobs",
headers={
"Authorization": "Bearer lb_live_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/v1/jobs", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer lb_live_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/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_live_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/v1/jobs',
'{"operation":"pdf_to_zpl","params":{"dpi":203},"documents":[{"content_type":"application/pdf"}]}',
'{"Authorization":"Bearer lb_live_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/v1/jobs'
: *null
: %trim(response)
: 'Authorization: Bearer lb_live_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_live_xxxx" \
"https://api.zplflow/v1/jobs?limit=50"
# Subsequent pages — use the next_cursor from the response
curl -H "Authorization: Bearer lb_live_xxxx" \
"https://api.zplflow/v1/jobs?limit=50&cursor=eyJsYXN0X2lkIjoiLi4uIn0="
import requests
headers = {"Authorization": "Bearer lb_live_xxxx"}
# First page
resp = requests.get("https://api.zplflow/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/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/v1/jobs?limit=50"
if cursor != "" {
url += "&cursor=" + cursor
}
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer lb_live_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/v1/jobs?limit=50";
if ($cursor) $url .= "&cursor=" . urlencode($cursor);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer lb_live_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/v1/jobs?limit=50',
NULL,
'{"Authorization":"Bearer lb_live_xxxx"}'
) AS response
FROM SYSIBM.SYSDUMMY1;
-- Subsequent pages: replace CURSOR_VALUE with the next_cursor value
SELECT SYSTOOLS.HTTPGETCLOB(
'https://api.zplflow/v1/jobs?limit=50&cursor=CURSOR_VALUE',
NULL,
'{"Authorization":"Bearer lb_live_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/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_live_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/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/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/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/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/v1/jobs'
: *null
: %trim(response)
: 'Authorization: Bearer lb_live_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
See Also
- Conversions — Sync conversion endpoints
- Jobs API — Async workflow for batch processing
- Pipelines — ZPL transformation step reference
- Code Examples — Multi-language snippets