Webshop integration
A practical recipe for connecting a webshop to ZERPY. ZERPY stays the master of products, prices and stock; the shop is the master of its own orders and online payments.
ZERPY ──products, prices, stock──▶ Shop
ZERPY ◀──────── orders ─────────── Shop
ZERPY ──shipment tracking, invoices (webhooks)──▶ Shop1. Create a key and a webhook
- In ZERPY: Integrations → API keys → New API key, preset Webshop, name "Website". Store the key on the shop's server as
ZERPY_API_KEY. - Integrations → Webhooks → New webhook, URL
https://shop.example.com/zerpy-webhook, eventsstock.changed,product.created,product.updated,order.shipped,order.cancelled(andinvoice.issuedif you want the invoice number in the shop). Store the signing secret asZERPY_WEBHOOK_SECRET. - Implement the webhook receiver with signature verification (Webhooks) and press Test — you should get a
ping.
2. Initial product import
The snippets on this page use a small helper zerpy(path, init) that calls https://zerpy.app/api/v1 + path with the Authorization header and returns the parsed JSON (full version in Code samples).
Page through the whole catalog once:
let page = 1, more = true
while (more) {
const res = await zerpy(`/products?page=${page}&limit=200`)
for (const p of res.data) await upsertShopProduct(p) // match on p.sku
more = res.has_more
page++
}For each product store at least: sku (the key you send back in orders), name, description, price.gross (or price.net + price.vat_rate), images[].url, options, stock.available, active, updated_at.
- Multi-language shop: read
translationsfrom the same response, or call once per language with?lang=en,?lang=fi… - Services (
type: "service") havestock: null— e.g. installation you sell in the shop. - Inactive: products archived or no longer sold are not in the default list. On a full sync, hide shop products whose SKU is missing from the response.
3. Keep products and stock current
Choose one:
A. Webhooks (recommended)
| Event | Shop action |
|---|---|
product.created, product.updated | Upsert the product from data |
stock.changed | For each data.items[]: set the shop stock of sku to available |
Plus a nightly full sync (step 2) as a safety net — it also picks up option changes, which do not send webhooks.
B. Polling
| How often | Call |
|---|---|
| Every few minutes | GET /stock (up to 500 SKUs per call) |
| Every 15–60 minutes | GET /products?updated_since=<last updated_at you saw> |
| Nightly | Full sync (step 2) |
Mind the rate limit of 120 requests per minute — one GET /stock call covers 500 products.
Which number to show
Show available, not on_hand. available already subtracts what other orders and production have reserved, and it counts only your own warehouses.
4. Send orders
When the customer completes the checkout, send the order from the shop's server:
const res = await zerpy('/orders', {
method: 'POST',
body: JSON.stringify({
external_id: String(shopOrder.id),
customer: { email: shopOrder.email, name: shopOrder.name, phone: shopOrder.phone },
shipping_address: shopOrder.shippingAddress, // line1, city, postal_code, country, pickup_point …
lines: shopOrder.items.map(i => ({ sku: i.sku, quantity: i.qty, unit_price: i.priceGross })),
shipping: { description: shopOrder.shippingMethod, price: shopOrder.shippingGross }
})
})
await saveZerpyOrderId(shopOrder.id, res.data.id, res.data.number)
for (const w of res.warnings) log(`Short on ${w.sku}: needed ${w.needed}, available ${w.available}`)- Retry safely.
external_idmakesPOST /ordersidempotent: if the request times out, send it again — you will get the same order back (200,Idempotent-Replayed: true). A queue with retries is the most robust setup. - Gross prices are the default (
prices_include_vat: true). Compare your total withdata.totals.gross; rounding may differ by 0.01. - Business customers: send
company_name,reg_codeandvat_number— the customer is matched by registry code first, then by e-mail. - VAT-free sales (EU business with a valid VAT number, export): send
vat_rate: 0on every line and onshipping. - Configurable products: send
optionswith value ids or{ group, value }names, and aunit_pricethat already includes the option prices. - Stock shortages do not reject the order — they come back as
warnings(backorder). - Manual review:
confirm: falsecreates a draft that someone confirms in ZERPY.
5. Payment
Pick the flow that matches your checkout.
Paid in the shop (card, bank link via your own payment provider) — add payment to the order:
"payment": { "amount": 59.79, "method": "online", "reference": "PSP-TRANSACTION-ID" }ZERPY records the payment; when the invoice is issued it is marked paid automatically.
Pay through ZERPY's Montonio account (the company has Montonio connected in ZERPY; no payment keys in the shop):
- Create the order without
payment. POST /orders/{id}/payment-linkwithreturn_url(your thank-you page) and optionallylocale.- Redirect the customer to
data.url. - When the customer pays, Montonio notifies ZERPY and the payment is recorded on the order. Read
payment.statuswithGET /orders/{id}(e.g. on the thank-you page) — it becomespaid.
Calling payment-link again for the same amount returns the same unpaid link, so a "Pay again" button can simply call it again.
Bank transfer after ordering — create the order without payment. Show the customer data.payment.reference (the order's payment reference) and data.totals.gross. The payment is recorded in ZERPY when the company imports its bank statement or records it by hand.
INFO
The API has no endpoint for recording a payment after the order was created — send payment with the order, use the payment link, or let ZERPY match the bank transfer.
6. Shipment and invoice back to the shop
The company ships and invoices in ZERPY. The shop learns about it from webhooks:
| Event | Shop action |
|---|---|
order.shipped | Find the shop order by data.order.external_id; mark it shipped and show data.shipment.carrier + tracking_number to the customer |
order.cancelled | Cancel the shop order (data.external_id) |
invoice.issued | Store data.number with the shop order (data.order.external_id); order is null for invoices without an order |
Without webhooks, poll GET /orders?updated_since=… and read status, shipments and invoices.
7. Cancellations from the shop
If the customer cancels before shipping: POST /orders/{id}/cancel (ZERPY id or number). It works while nothing is shipped and no invoice exists; otherwise you get 409 cannot_cancel and the company handles it in ZERPY (for example with a credit note).
Checklist
- Key stored only on the server, never in browser code
- Webhook signature verified, timestamps older than 5 minutes rejected, deliveries deduplicated by
id - Webhook answers
2xxwithin 10 seconds; slow work done afterwards POST /ordersretried on network errors (idempotent onexternal_id)warningslogged or shown to staff- Nightly full product sync as a safety net
Full code in Code samples.