Code samples
Runnable examples for the most common tasks. Every sample reads the key from the environment variable ZERPY_API_KEY and the webhook secret from ZERPY_WEBHOOK_SECRET.
export ZERPY_API_KEY=sk_live_…
export ZERPY_WEBHOOK_SECRET=whsec_…Helpers
A small client per language, used by the samples below. Each one sends the key, parses JSON and turns API errors into exceptions with the error code.
Node.js
Node.js 18 or newer (built-in fetch).
// zerpy.mjs
const BASE = 'https://zerpy.app/api/v1'
export async function zerpy(path, init = {}) {
const res = await fetch(BASE + path, {
...init,
headers: {
Authorization: `Bearer ${process.env.ZERPY_API_KEY}`,
'Content-Type': 'application/json',
...init.headers
}
})
if (res.status === 429) {
const wait = Number(res.headers.get('Retry-After') ?? 1)
await new Promise(r => setTimeout(r, wait * 1000))
return zerpy(path, init)
}
const body = await res.json()
if (!res.ok) {
const err = new Error(`${body.error?.code}: ${body.error?.message}`)
err.code = body.error?.code
err.details = body.error?.details
err.status = res.status
throw err
}
return body
}PHP
PHP 7.4+ with the curl extension.
<?php
// zerpy.php
function zerpy(string $method, string $path, ?array $body = null): array
{
$ch = curl_init('https://zerpy.app/api/v1' . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('ZERPY_API_KEY'),
'Content-Type: application/json',
],
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($raw === false) {
throw new RuntimeException('Network error');
}
$data = json_decode($raw, true);
if ($status >= 400) {
throw new RuntimeException(($data['error']['code'] ?? 'error') . ': ' . ($data['error']['message'] ?? $raw), $status);
}
return $data;
}Python
Python 3.8+ with requests (pip install requests).
# zerpy.py
import os
import time
import requests
BASE = "https://zerpy.app/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['ZERPY_API_KEY']}"
class ZerpyError(Exception):
def __init__(self, status, error):
super().__init__(f"{error.get('code')}: {error.get('message')}")
self.status = status
self.code = error.get("code")
self.details = error.get("details")
def zerpy(method, path, **kwargs):
res = session.request(method, BASE + path, timeout=30, **kwargs)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", "1")))
return zerpy(method, path, **kwargs)
data = res.json()
if not res.ok:
raise ZerpyError(res.status_code, data.get("error", {}))
return dataList products
Fetch the whole catalog, 200 products per page.
curl "https://zerpy.app/api/v1/products?page=1&limit=200" \
-H "Authorization: Bearer $ZERPY_API_KEY"import { zerpy } from './zerpy.mjs'
const products = []
for (let page = 1; ; page++) {
const res = await zerpy(`/products?page=${page}&limit=200`)
products.push(...res.data)
if (!res.has_more) break
}
for (const p of products) {
console.log(p.sku, p.name, p.price?.gross, p.stock?.available ?? '-')
}<?php
require 'zerpy.php';
$products = [];
$page = 1;
do {
$res = zerpy('GET', "/products?page={$page}&limit=200");
$products = array_merge($products, $res['data']);
$page++;
} while ($res['has_more']);
foreach ($products as $p) {
echo $p['sku'], ' ', $p['name'], ' ', $p['price']['gross'] ?? '-', ' ', $p['stock']['available'] ?? '-', PHP_EOL;
}from zerpy import zerpy
products, page = [], 1
while True:
res = zerpy("GET", "/products", params={"page": page, "limit": 200})
products += res["data"]
if not res["has_more"]:
break
page += 1
for p in products:
price = (p["price"] or {}).get("gross", "-")
stock = (p["stock"] or {}).get("available", "-")
print(p["sku"], p["name"], price, stock)Incremental sync: add updated_since=2026-11-07T10:00:00Z (URL-encode the + of a time-zone offset as %2B).
Read stock
curl "https://zerpy.app/api/v1/stock?skus=P1,P2" \
-H "Authorization: Bearer $ZERPY_API_KEY"import { zerpy } from './zerpy.mjs'
const { data } = await zerpy('/stock?skus=' + encodeURIComponent('P1,P2'))
for (const s of data) console.log(s.sku, s.available)<?php
require 'zerpy.php';
$res = zerpy('GET', '/stock?skus=' . urlencode('P1,P2'));
foreach ($res['data'] as $s) {
echo $s['sku'], ' ', $s['available'], PHP_EOL;
}from zerpy import zerpy
for s in zerpy("GET", "/stock", params={"skus": "P1,P2"})["data"]:
print(s["sku"], s["available"])Create an order
Creates (or, on retry, returns) the order with external_id 1001.
curl -X POST https://zerpy.app/api/v1/orders \
-H "Authorization: Bearer $ZERPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_id": "1001",
"customer": { "email": "mari@example.ee", "name": "Mari Maasikas" },
"shipping_address": { "line1": "Pärnu mnt 10", "city": "Tallinn", "postal_code": "10148", "country": "EE" },
"lines": [{ "sku": "P1", "quantity": 2, "unit_price": 12.40 }],
"shipping": { "description": "Courier", "price": 4.99 }
}'import { zerpy } from './zerpy.mjs'
const order = {
external_id: '1001',
customer: { email: 'mari@example.ee', name: 'Mari Maasikas' },
shipping_address: { line1: 'Pärnu mnt 10', city: 'Tallinn', postal_code: '10148', country: 'EE' },
lines: [{ sku: 'P1', quantity: 2, unit_price: 12.40 }],
shipping: { description: 'Courier', price: 4.99 }
}
// safe to retry: external_id makes the call idempotent
async function sendOrder(body, attempts = 3) {
try {
return await zerpy('/orders', { method: 'POST', body: JSON.stringify(body) })
} catch (err) {
if (attempts > 1 && (!err.status || err.status >= 500)) return sendOrder(body, attempts - 1)
throw err
}
}
const { data, warnings } = await sendOrder(order)
console.log(data.number, data.status, data.totals.gross)
for (const w of warnings) console.warn(`short: ${w.sku} needed ${w.needed}, available ${w.available}`)<?php
require 'zerpy.php';
$order = [
'external_id' => '1001',
'customer' => ['email' => 'mari@example.ee', 'name' => 'Mari Maasikas'],
'shipping_address' => ['line1' => 'Pärnu mnt 10', 'city' => 'Tallinn', 'postal_code' => '10148', 'country' => 'EE'],
'lines' => [['sku' => 'P1', 'quantity' => 2, 'unit_price' => 12.40]],
'shipping' => ['description' => 'Courier', 'price' => 4.99],
];
$res = zerpy('POST', '/orders', $order);
echo $res['data']['number'], ' ', $res['data']['status'], ' ', $res['data']['totals']['gross'], PHP_EOL;
foreach ($res['warnings'] as $w) {
echo "short: {$w['sku']} needed {$w['needed']}, available {$w['available']}", PHP_EOL;
}from zerpy import zerpy, ZerpyError
order = {
"external_id": "1001",
"customer": {"email": "mari@example.ee", "name": "Mari Maasikas"},
"shipping_address": {"line1": "Pärnu mnt 10", "city": "Tallinn", "postal_code": "10148", "country": "EE"},
"lines": [{"sku": "P1", "quantity": 2, "unit_price": 12.40}],
"shipping": {"description": "Courier", "price": 4.99},
}
try:
res = zerpy("POST", "/orders", json=order)
except ZerpyError as e:
if e.code == "unknown_sku":
print("Unknown SKUs:", e.details["skus"])
raise
print(res["data"]["number"], res["data"]["status"], res["data"]["totals"]["gross"])
for w in res["warnings"]:
print(f"short: {w['sku']} needed {w['needed']}, available {w['available']}")Get a payment link
Needs Montonio connected in ZERPY.
curl -X POST https://zerpy.app/api/v1/orders/SO-00042/payment-link \
-H "Authorization: Bearer $ZERPY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "return_url": "https://shop.example.com/thank-you" }'const { data } = await zerpy('/orders/SO-00042/payment-link', {
method: 'POST',
body: JSON.stringify({ return_url: 'https://shop.example.com/thank-you' })
})
// redirect the customer to data.url<?php
$res = zerpy('POST', '/orders/SO-00042/payment-link', ['return_url' => 'https://shop.example.com/thank-you']);
header('Location: ' . $res['data']['url'], true, 303);res = zerpy("POST", "/orders/SO-00042/payment-link", json={"return_url": "https://shop.example.com/thank-you"})
print(res["data"]["url"])Verify a webhook
Each receiver checks the Zerpy-Signature header against the raw body, rejects timestamps older than 5 minutes and answers quickly with 2xx. See Webhooks for the format.
import express from 'express'
import { createHmac, timingSafeEqual } from 'node:crypto'
const app = express()
function verify(raw, header) {
const m = /t=(\d+),v1=([0-9a-f]+)/.exec(header ?? '')
if (!m) return false
const [, t, v1] = m
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', process.env.ZERPY_WEBHOOK_SECRET).update(`${t}.${raw}`).digest('hex')
return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
}
const seen = new Set() // use your database in production
app.post('/zerpy-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8')
if (!verify(raw, req.get('Zerpy-Signature'))) return res.sendStatus(401)
const event = JSON.parse(raw)
res.json({ ok: true }) // answer first, work after
if (seen.has(event.id)) return
seen.add(event.id)
if (event.type === 'stock.changed') {
for (const item of event.data.items) console.log('stock', item.sku, item.available)
}
if (event.type === 'order.shipped') {
console.log('shipped', event.data.order.external_id, event.data.shipment?.tracking_number)
}
})
app.listen(3000)// server/api/zerpy-webhook.post.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
export default defineEventHandler(async (event) => {
const raw = (await readRawBody(event)) ?? ''
const header = getHeader(event, 'zerpy-signature') ?? ''
const [, t, v1] = header.match(/t=(\d+),v1=([0-9a-f]+)/) ?? []
const expected = createHmac('sha256', process.env.ZERPY_WEBHOOK_SECRET!).update(`${t}.${raw}`).digest('hex')
const valid = !!v1 && v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
if (!valid || Math.abs(Date.now() / 1000 - Number(t)) > 300) throw createError({ statusCode: 401 })
const { type, data } = JSON.parse(raw)
if (type === 'stock.changed') {
for (const item of data.items) await updateStock(item.sku, Number(item.available)) // your shop's code
}
if (type === 'order.shipped') {
await markShipped(data.order.external_id, data.shipment?.carrier, data.shipment?.tracking_number)
}
return { ok: true }
})<?php
$secret = getenv('ZERPY_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_ZERPY_SIGNATURE'] ?? '';
if (!preg_match('/t=(\d+),v1=([0-9a-f]+)/', $header, $m)) {
http_response_code(401); exit;
}
[$all, $t, $v1] = $m;
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
if (!hash_equals($expected, $v1) || abs(time() - (int) $t) > 300) {
http_response_code(401); exit;
}
$event = json_decode($raw, true);
switch ($event['type']) {
case 'stock.changed':
foreach ($event['data']['items'] as $item) {
// update_stock($item['sku'], (float) $item['available']);
}
break;
case 'order.shipped':
// mark_shipped($event['data']['order']['external_id'], $event['data']['shipment']['tracking_number'] ?? null);
break;
}
header('Content-Type: application/json');
echo json_encode(['ok' => true]);import hashlib
import hmac
import json
import os
import re
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["ZERPY_WEBHOOK_SECRET"].encode()
def verify(raw: bytes, header: str) -> bool:
m = re.search(r"t=(\d+),v1=([0-9a-f]+)", header or "")
if not m:
return False
t, v1 = m.group(1), m.group(2)
if abs(time.time() - int(t)) > 300:
return False
expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/zerpy-webhook")
def zerpy_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify(raw, request.headers.get("Zerpy-Signature", "")):
abort(401)
event = json.loads(raw)
if event["type"] == "stock.changed":
for item in event["data"]["items"]:
print("stock", item["sku"], item["available"])
elif event["type"] == "order.shipped":
shipment = event["data"]["shipment"] or {}
print("shipped", event["data"]["order"]["external_id"], shipment.get("tracking_number"))
return {"ok": True}