zplflow logo

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/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/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