Skip to content

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)──▶ Shop

1. Create a key and a webhook ​

  1. In ZERPY: Integrations → API keys → New API key, preset Webshop, name "Website". Store the key on the shop's server as ZERPY_API_KEY.
  2. Integrations → Webhooks → New webhook, URL https://shop.example.com/zerpy-webhook, events stock.changed, product.created, product.updated, order.shipped, order.cancelled (and invoice.issued if you want the invoice number in the shop). Store the signing secret as ZERPY_WEBHOOK_SECRET.
  3. 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:

js
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 translations from the same response, or call once per language with ?lang=en, ?lang=fi …
  • Services (type: "service") have stock: 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)

EventShop action
product.created, product.updatedUpsert the product from data
stock.changedFor 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 oftenCall
Every few minutesGET /stock (up to 500 SKUs per call)
Every 15–60 minutesGET /products?updated_since=<last updated_at you saw>
NightlyFull 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:

js
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_id makes POST /orders idempotent: 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 with data.totals.gross; rounding may differ by 0.01.
  • Business customers: send company_name, reg_code and vat_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: 0 on every line and on shipping.
  • Configurable products: send options with value ids or { group, value } names, and a unit_price that already includes the option prices.
  • Stock shortages do not reject the order — they come back as warnings (backorder).
  • Manual review: confirm: false creates 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:

json
"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):

  1. Create the order without payment.
  2. POST /orders/{id}/payment-link with return_url (your thank-you page) and optionally locale.
  3. Redirect the customer to data.url.
  4. When the customer pays, Montonio notifies ZERPY and the payment is recorded on the order. Read payment.status with GET /orders/{id} (e.g. on the thank-you page) — it becomes paid.

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:

EventShop action
order.shippedFind the shop order by data.order.external_id; mark it shipped and show data.shipment.carrier + tracking_number to the customer
order.cancelledCancel the shop order (data.external_id)
invoice.issuedStore 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 2xx within 10 seconds; slow work done afterwards
  • POST /orders retried on network errors (idempotent on external_id)
  • warnings logged or shown to staff
  • Nightly full product sync as a safety net

Full code in Code samples.