Skip to content
ZivoBooks

Product

v1.2.0 · REST · JSON

Zivobooks API reference

Read a business’s customers, invoices, quotes, payments, products and stock, create customers and draft invoices, and hear about changes the moment they happen.

Base URL https://zivobooks.com/api

Introduction

The Zivobooks API gives software you build safe, scoped access to one business’s books. Each set of keys belongs to a single business, which decides what the keys may read or create and can regenerate, suspend or revoke them at any time.

Sync

Pull customers, invoices, payments and products into your CRM, warehouse or BI tool, incrementally.

Create

Add customers and raise draft invoices from your shop, booking system or field app.

React

Get a signed webhook the moment an invoice is paid or a quote accepted.

Quick start

From nothing to your first response in three steps. Use the test key while you build: it returns realistic sample data and can’t touch real books.

  1. 1 Get a key ID and secret

    A business creates them under Settings → Integrations & API and either copies them to you or shares them with your free developer account. Store them as environment variables:

    .env
    ZIVOBOOKS_KEY_ID=zbk_test_3k9xq…
    ZIVOBOOKS_SECRET=zb_test_…
  2. 2 Sign in for a session token

    Exchange the key ID and secret for a token that lasts one hour. This is the only call that sees your secret.

    Sign in
    curl -X POST https://zivobooks.com/api/v1/auth/token \
      -H "Content-Type: application/json" \
      -H "Accept: application/json" \
      -d "{\"key_id\": \"$ZIVOBOOKS_KEY_ID\", \"secret\": \"$ZIVOBOOKS_SECRET\"}"
    
    # → {"access_token": "zbat_…", "expires_in": 3600, …}
    export ZIVOBOOKS_TOKEN=zbat_…
    <?php
    $session = Http::acceptJson()
        ->post('https://zivobooks.com/api/v1/auth/token', [
            'key_id' => getenv('ZIVOBOOKS_KEY_ID'),
            'secret' => getenv('ZIVOBOOKS_SECRET'),
        ])
        ->throw()
        ->json();
    
    $token = $session['access_token']; // valid until $session['expires_at']
    const res = await fetch('https://zivobooks.com/api/v1/auth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({
        key_id: process.env.ZIVOBOOKS_KEY_ID,
        secret: process.env.ZIVOBOOKS_SECRET,
      }),
    });
    if (!res.ok) throw new Error(`Sign-in failed: ${res.status}`);
    const { access_token: token, expires_at } = await res.json();
    import os, requests
    
    session = requests.post(
        'https://zivobooks.com/api/v1/auth/token',
        json={'key_id': os.environ['ZIVOBOOKS_KEY_ID'], 'secret': os.environ['ZIVOBOOKS_SECRET']},
        timeout=30,
    )
    session.raise_for_status()
    token = session.json()['access_token']  # valid until session.json()['expires_at']
  3. 3 Make a call

    Send the token as a bearer token and the key ID in X-Zivobooks-Key.

    Request
    curl https://zivobooks.com/api/ping \
      -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
      -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
      -H "Accept: application/json"
    <?php
    // $token comes from POST /api/v1/auth/token (see Authentication)
    $response = Http::withToken($token)
        ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
        ->acceptJson()
        ->get('https://zivobooks.com/api/ping');
    
    $data = $response->throw()->json();
    // token comes from POST /api/v1/auth/token (see Authentication)
    const response = await fetch('https://zivobooks.com/api/ping', {
      headers: {
        Authorization: `Bearer ${token}`,
        'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
        Accept: 'application/json',
      },
    });
    
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    const data = await response.json();
    import os, requests
    
    # token comes from POST /api/v1/auth/token (see Authentication)
    response = requests.get(
        'https://zivobooks.com/api/ping',
        headers={
            'Authorization': f'Bearer {token}',
            'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
            'Accept': 'application/json',
        },
        timeout=30,
    )
    response.raise_for_status()
    data = response.json()
    Response · 200
    {
        "ok": true,
        "mode": "test",
        "business": {
            "id": 12,
            "name": "Moyo Hardware"
        },
        "session_expires_at": "2026-10-03T11:00:00+02:00"
    }

Getting access

Keys always come from the business whose data you’re reading. Nobody at Zivobooks has to approve you, and you never ask us for a key.

Your own business

An owner or admin opens Settings → Integrations & API, creates a set of keys, chooses what they can do and copies the key IDs and secrets. Available on the Pro plan and above.

Building for another business

  1. Create a free developer account. It’s ready straight away.
  2. Give the business the email you signed up with.
  3. They create keys and share them with your account. We email you a notice, never the keys.
  4. Reveal each secret once in your developer console and store it safely.

Authentication

Every key has two parts: a key ID (zbk_live_…), which identifies it and isn’t secret, and a secret (zb_live_…), shown once and stored by us only as a hash. Your integration signs in with both and gets a short-lived session token (zbat_…). The secret never travels with ordinary calls, so a leaked log line or proxy capture exposes at most an hour of access.

The flow

  1. Step 1

    POST /api/v1/auth/token

    Send key_id + secret. Get access_token, expires_at (1 hour), mode and scopes.

  2. Step 2

    Every call

    Authorization: Bearer zbat_… and X-Zivobooks-Key: zbk_… (the key the session was issued for).

  3. Step 3

    Before expiry, or on 401

    Sign in again for a fresh token. Optional: DELETE /api/v1/auth/token to sign out early.

Headers on every call

Headers
Authorization: Bearer zbat_8sJ2…
X-Zivobooks-Key: zbk_live_3k9xq…
Accept: application/json

A session only works with the key ID it was issued for, so a stolen token is useless without it. Each response carries X-Zivobooks-Mode (live or test) and X-Zivobooks-Session-Expires.

A client that handles sessions for you

Sign in once, reuse the token, sign in again two minutes before it expires, and retry once on a 401 (the business may have ended the session).

Client
# In a shell script, sign in once per run and reuse the token:
export ZIVOBOOKS_TOKEN=$(curl -s -X POST https://zivobooks.com/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d "{\"key_id\":\"$ZIVOBOOKS_KEY_ID\",\"secret\":\"$ZIVOBOOKS_SECRET\"}" | jq -r .access_token)

curl https://zivobooks.com/api/v1/invoices \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID"
<?php
final class Zivobooks
{
    private ?string $token = null;
    private int $expiresAt = 0;

    public function get(string $path, array $query = []): array
    {
        $response = $this->request()->get('https://zivobooks.com/api'.$path, $query);

        if ($response->status() === 401) { // ended early: sign in again once
            $this->token = null;
            $response = $this->request()->get('https://zivobooks.com/api'.$path, $query);
        }

        return $response->throw()->json();
    }

    private function request(): \Illuminate\Http\Client\PendingRequest
    {
        if ($this->token === null || time() > $this->expiresAt - 120) {
            $session = Http::post('https://zivobooks.com/api/v1/auth/token', [
                'key_id' => getenv('ZIVOBOOKS_KEY_ID'),
                'secret' => getenv('ZIVOBOOKS_SECRET'),
            ])->throw()->json();
            $this->token = $session['access_token'];
            $this->expiresAt = strtotime($session['expires_at']);
        }

        return Http::withToken($this->token)
            ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
            ->acceptJson();
    }
}

$invoices = (new Zivobooks)->get('/v1/invoices', ['status' => 'paid']);
let session = null;

async function signIn() {
  const res = await fetch('https://zivobooks.com/api/v1/auth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ key_id: process.env.ZIVOBOOKS_KEY_ID, secret: process.env.ZIVOBOOKS_SECRET }),
  });
  if (!res.ok) throw new Error(`Sign-in failed: ${res.status}`);
  const body = await res.json();
  session = { token: body.access_token, expiresAt: Date.parse(body.expires_at) };
}

export async function zivobooks(path, init = {}, retried = false) {
  if (!session || Date.now() > session.expiresAt - 120_000) await signIn();
  const res = await fetch(`https://zivobooks.com/api${path}`, {
    ...init,
    headers: {
      ...init.headers,
      Authorization: `Bearer ${session.token}`,
      'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
      Accept: 'application/json',
    },
  });
  if (res.status === 401 && !retried) { session = null; return zivobooks(path, init, true); }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

const invoices = await zivobooks('/v1/invoices?status=paid');
import os, time, requests

class Zivobooks:
    BASE = 'https://zivobooks.com/api'

    def __init__(self):
        self.token, self.expires_at = None, 0
        self.http = requests.Session()

    def _sign_in(self):
        r = self.http.post(f'{self.BASE}/v1/auth/token', timeout=30, json={
            'key_id': os.environ['ZIVOBOOKS_KEY_ID'],
            'secret': os.environ['ZIVOBOOKS_SECRET'],
        })
        r.raise_for_status()
        body = r.json()
        self.token = body['access_token']
        self.expires_at = time.time() + body['expires_in']

    def get(self, path, **params):
        for attempt in (1, 2):
            if not self.token or time.time() > self.expires_at - 120:
                self._sign_in()
            r = self.http.get(f'{self.BASE}{path}', params=params, timeout=30, headers={
                'Authorization': f'Bearer {self.token}',
                'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
            })
            if r.status_code == 401 and attempt == 1:
                self.token = None  # ended early: sign in again once
                continue
            r.raise_for_status()
            return r.json()

invoices = Zivobooks().get('/v1/invoices', status='paid')

When sign-in or a call is refused

  • 401: wrong key ID or secret; the token expired, was signed out or ended by the business; or X-Zivobooks-Key doesn’t match. Sign in again.
  • 403: the key set is suspended, the developer account is suspended, or the plan doesn’t include the API. Signing in again won’t help.
  • 429 on sign-in: more than 10 attempts a minute from one IP address.
  • Sending a secret directly as a bearer token is refused with a message pointing you to sign-in.

Test and live keys

Every set has a test key and a live key. They work the same way and return the same shapes; only the data differs.

KeyKey ID / secretReadsWrites
Testzbk_test_ / zb_test_A fixed set of sample customers, products, invoices, quotes and payments. Filters, pagination and updated_since all work.Validated exactly like live, and a realistic record is returned, but nothing is saved.
Livezbk_live_ / zb_live_The business’s real records.Saved to the business’s books.

Scopes

When a business creates keys it chooses what they may do. If it ticks nothing, the keys can read everything but write nothing: writing always has to be granted explicitly. A call outside the key’s scopes returns 403 with the scope you need.

ScopeAccessAllows
customers.read read Names, contact details, tax numbers and addresses of the business’s customers.
invoices.read read Invoices with their lines, totals, balances, status and customer link.
quotes.read read Quotes with totals, status and validity.
payments.read read Customer payments and the invoices they were allocated to.
products.read read Products and services with prices, barcodes and stock on hand per branch.
customers.write write Add new customers.
invoices.write write Create invoices as drafts. Someone in the business still reviews and sends them.

Requests & responses

  • Base URL: https://zivobooks.com/api/v1. HTTPS only.
  • Send Accept: application/json, and Content-Type: application/json with a body. Every response is JSON in UTF-8.
  • Single records come wrapped in data; lists add links and meta for paging.
  • Money is a string with full precision (for example "1280.00"), never a float. Each record carries its own currency.
  • Dates are YYYY-MM-DD; timestamps are ISO 8601 with a time zone offset.
  • IDs are integers and stable for the life of the record. Ignore fields you don’t recognise; we add new ones without notice.

Creating records

With the customers.write or invoices.write scope you can create customers and draft invoices. Invoices are always created as drafts in the business’s base currency and numbered in its own sequence; someone in the business reviews and sends them, so nothing reaches a customer without a person seeing it.

Retry safely with Idempotency-Key

Networks fail. Send a unique Idempotency-Key header (a UUID is ideal) with each create. If you retry the same request with the same key within 24 hours, you get the original response back with Idempotent-Replayed: true, and no duplicate is created. Reusing a key with a different body returns 422.

Create a draft invoice
curl -X POST https://zivobooks.com/api/v1/invoices \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": 42,
    "issue_date": "2026-10-03",
    "items": [
        {
            "product_id": 7,
            "quantity": 1
        },
        {
            "description": "Call-out fee",
            "quantity": 1,
            "unit_price": "25.00"
        }
    ]
}'
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID'), 'Idempotency-Key' => (string) Str::uuid()])
    ->acceptJson()
    ->post('https://zivobooks.com/api/v1/invoices', [
        'customer_id' => 42,
        'issue_date' => '2026-10-03',
        'items' => [
            [
                'product_id' => 7,
                'quantity' => 1,
            ],
            [
                'description' => 'Call-out fee',
                'quantity' => 1,
                'unit_price' => '25.00',
            ],
        ],
    ]);

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/invoices', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
      "customer_id": 42,
      "issue_date": "2026-10-03",
      "items": [
          {
              "product_id": 7,
              "quantity": 1
          },
          {
              "description": "Call-out fee",
              "quantity": 1,
              "unit_price": "25.00"
          }
      ]
  }),
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, uuid, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.post(
    'https://zivobooks.com/api/v1/invoices',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
        'Idempotency-Key': str(uuid.uuid4()),
    },
    json={
        "customer_id": 42,
        "issue_date": "2026-10-03",
        "items": [
            {
                "product_id": 7,
                "quantity": 1
            },
            {
                "description": "Call-out fee",
                "quantity": 1,
                "unit_price": "25.00"
            }
        ]
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()

Validation errors

Invalid input returns 422 with an errors object keyed by field, so you can show each problem next to the right input.

{
    "message": "The selected customer id is invalid. (and 1 more error)",
    "errors": {
        "customer_id": [
            "The selected customer id is invalid."
        ],
        "items.0.quantity": [
            "The items.0.quantity field must be greater than 0."
        ]
    }
}

Pagination

List endpoints return 25 records per page by default, up to 100 with per_page. Follow links.next until it’s null. Records come in ID order, oldest first, so new records always land on the last page.

{
    "data": [
        "…"
    ],
    "links": {
        "first": "…/api/v1/invoices?page=1",
        "last": "…/api/v1/invoices?page=4",
        "prev": null,
        "next": "…/api/v1/invoices?page=2"
    },
    "meta": {
        "current_page": 1,
        "last_page": 4,
        "per_page": 25,
        "total": 87
    }
}

Syncing changes

Save the time of your last sync and pass it as updated_since to get only records created or changed since then. Encode a + in an offset as %2B, or use UTC with Z.

GET /api/v1/invoices?updated_since=2026-10-01T00:00:00Z&per_page=100

For changes as they happen, combine this with webhooks, and use updated_since to catch up after any downtime.

A product counts as changed whenever its stock moves (a sale, a delivery, a count or a transfer), so syncing products with updated_since also keeps stock levels current.

Recipes

Complete examples for common jobs. They use the small client from Authentication, which signs in and reuses the session for you.

Keep an online shop’s stock in sync

Every few minutes, fetch the tracked products that changed since the last run and set each one’s quantity in your shop. A sale, delivery or count changes the product’s updated_at, so nothing is missed. Note the time before you ask, so changes made during the run are picked up next time.

Keep an online shop’s stock in sync
curl "https://zivobooks.com/api/v1/products?track_stock=true&per_page=100&updated_since=2026-10-04T08:00:00Z" \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID"

# For each product: set the shop quantity for its sku/barcode to stock.on_hand
<?php
$api = new Zivobooks; // the client from “Authentication”
$startedAt = now()->toIso8601String();
$page = 1;

do {
    $result = $api->get('/v1/products', [
        'track_stock' => 'true',
        'updated_since' => cache('zivobooks.stock_synced_at', '2000-01-01T00:00:00Z'),
        'per_page' => 100,
        'page' => $page,
    ]);

    foreach ($result['data'] as $product) {
        Shop::setQuantity($product['sku'] ?? $product['barcode'], (float) $product['stock']['on_hand']);
    }
} while ($page++ < $result['meta']['last_page']);

cache(['zivobooks.stock_synced_at' => $startedAt]);
import { zivobooks } from './zivobooks.js'; // the client from “Authentication”

export async function syncStock(lastSyncedAt) {
  const startedAt = new Date().toISOString();
  let page = 1, lastPage = 1;

  do {
    const params = new URLSearchParams({ track_stock: 'true', updated_since: lastSyncedAt, per_page: '100', page: String(page) });
    const { data, meta } = await zivobooks(`/v1/products?${params}`);
    for (const product of data) {
      await shop.setQuantity(product.sku ?? product.barcode, Number(product.stock.on_hand));
    }
    lastPage = meta.last_page;
  } while (page++ < lastPage);

  return startedAt; // save it for the next run
}
from datetime import datetime, timezone

api = Zivobooks()  # the client from “Authentication”

def sync_stock(last_synced_at: str) -> str:
    started_at = datetime.now(timezone.utc).isoformat()
    page, last_page = 1, 1
    while page <= last_page:
        result = api.get('/v1/products', track_stock='true', updated_since=last_synced_at, per_page=100, page=page)
        for product in result['data']:
            shop.set_quantity(product['sku'] or product['barcode'], float(product['stock']['on_hand']))
        last_page = result['meta']['last_page']
        page += 1
    return started_at  # save it for the next run

Look up a scanned barcode

Find the product behind a barcode, with its shelf price and what’s on hand at each branch.

Look up a scanned barcode
curl "https://zivobooks.com/api/v1/products?barcode=6009880000017" \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID"
<?php
$product = (new Zivobooks)->get('/v1/products', ['barcode' => $scanned])['data'][0] ?? null;

if ($product === null) {
    return 'Not in the catalogue';
}

return "{$product['name']}: {$product['stock']['on_hand']} on hand";
const { data } = await zivobooks(`/v1/products?barcode=${encodeURIComponent(scanned)}`);
const product = data[0];

console.log(product ? `${product.name}: ${product.stock?.on_hand ?? 'not tracked'} on hand` : 'Not in the catalogue');
result = Zivobooks().get('/v1/products', barcode=scanned)
product = result['data'][0] if result['data'] else None

print(f"{product['name']}: {product['stock']['on_hand']} on hand" if product else 'Not in the catalogue')

Act when stock runs low

Subscribe an endpoint to stock.low and you’ll hear once when a product drops to its reorder level. Verify the signature first (see Webhooks), then alert whoever does the buying. For a daily list instead, call GET /v1/products?low_stock=true.

Act when stock runs low
# A daily reorder list instead of a webhook:
curl "https://zivobooks.com/api/v1/products?low_stock=true&per_page=100" \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID"
<?php
// routes/web.php: Route::post('/webhooks/zivobooks', StockLowWebhook::class);
public function __invoke(Request $request)
{
    verifyZivobooksSignature($request); // see Webhooks: verifying signatures

    if ($request->input('event') === 'stock.low') {
        $product = $request->input('data.product');
        Notification::route('mail', 'buyer@example.com')
            ->notify(new ReorderNeeded($product['name'], $product['stock']['on_hand'], $product['stock']['reorder_level']));
    }

    return response()->noContent();
}
app.post('/webhooks/zivobooks', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verify(req.body, req.get('X-Zivobooks-Signature'), process.env.ZIVOBOOKS_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  if (event.event === 'stock.low') {
    const { name, stock } = event.data.product;
    notifyBuyer(`Reorder ${name}: ${stock.on_hand} left (reorder at ${stock.reorder_level})`);
  }
  res.sendStatus(204);
});
@app.post('/webhooks/zivobooks')
def zivobooks_webhook():
    if not verify(request.get_data(), request.headers['X-Zivobooks-Signature'], os.environ['ZIVOBOOKS_WEBHOOK_SECRET']):
        abort(400)
    event = request.get_json()
    if event['event'] == 'stock.low':
        product = event['data']['product']
        notify_buyer(f"Reorder {product['name']}: {product['stock']['on_hand']} left")
    return '', 204

Pull till sales into another system

Till sales are ordinary paid invoices with sales_channel set to pos. List the new ones since your last run, then fetch each for its lines.

Pull till sales into another system
curl "https://zivobooks.com/api/v1/invoices?sales_channel=pos&updated_since=2026-10-04T00:00:00Z&per_page=100" \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID"

# Then GET /api/v1/invoices/{id} for each one's lines
<?php
$api = new Zivobooks;
$sales = $api->get('/v1/invoices', ['sales_channel' => 'pos', 'updated_since' => $since, 'per_page' => 100]);

foreach ($sales['data'] as $sale) {
    $invoice = $api->get("/v1/invoices/{$sale['id']}")['data'];
    Warehouse::recordSale($invoice['number'], $invoice['items'], $invoice['total']);
}
const { data: sales } = await zivobooks(`/v1/invoices?sales_channel=pos&updated_since=${since}&per_page=100`);

for (const sale of sales) {
  const { data: invoice } = await zivobooks(`/v1/invoices/${sale.id}`);
  await warehouse.recordSale(invoice.number, invoice.items, invoice.total);
}
api = Zivobooks()
sales = api.get('/v1/invoices', sales_channel='pos', updated_since=since, per_page=100)

for sale in sales['data']:
    invoice = api.get(f"/v1/invoices/{sale['id']}")['data']
    warehouse.record_sale(invoice['number'], invoice['items'], invoice['total'])

Errors

Errors use standard HTTP status codes and a JSON body with a message written for people.

StatusMeaning
401 UnauthorizedWrong key ID or secret, or the session token is missing, expired or ended, or the X-Zivobooks-Key header doesn’t match it. Sign in again.
403 ForbiddenSigned in but not allowed this: the key lacks the scope, the business suspended the key set, the developer account is suspended, or the business’s plan doesn’t include the API.
404 Not foundNo record with that ID in this business.
422 UnprocessableThe request is invalid: see the errors object for each field. Also returned when an Idempotency-Key is reused with a different body.
429 Too many requestsMore than 120 calls a minute from one key, or more than 10 sign-ins a minute from one IP. Wait for the seconds in the Retry-After header.
500 Server errorSomething went wrong on our side. Retry with backoff; if it persists, contact us.
{
    "message": "This key doesn't have the payments.read scope. Ask the business to grant it.",
    "required_scope": "payments.read"
}

Rate limits

120 / minute

calls per key

10 / minute

sign-in attempts per IP address

Responses include X-RateLimit-Limit and X-RateLimit-Remaining. Past the limit you get 429 with Retry-After in seconds. Reuse sessions rather than signing in per call, and for bulk syncs use per_page=100 with updated_since.

Sign-ins & request logs

Every sign-in and every call is recorded, including refused ones, and kept for 90 days. The business sees them on each key set’s page under Integrations & API, and you see the same for keys shared with you in your developer console:

  • Sign-in history: when each session started, from which IP and client, how many calls it made, and whether it’s active, expired or ended.
  • Request log: method, path, status, response time and the error message for failures.
  • Usage: calls per day, error rate, average response time and the busiest endpoints.

The business can end a single session, sign out everything, suspend the key set or regenerate a key at any time. Build your client to sign in again on 401.

Security checklist

  • Call the API from your server only. Never put a secret or session token in a browser, mobile app or public repository.
  • Keep the key ID and secret in a secrets manager or environment variables, not in code.
  • Reuse the session token for its hour instead of signing in for every call; it keeps the secret off the wire.
  • Use the test key in development and CI. Give live keys only to production.
  • Ask for the narrowest scopes you need. Writing is never granted unless the business ticks it.
  • If a secret may have leaked, ask the business to regenerate that key: the old key and all its sessions stop immediately.
  • Verify every webhook signature and reject requests older than five minutes.

Authentication

Sign in

POST /api/v1/auth/token auth: key ID + secret

Exchanges a key ID and secret for a session token that lasts one hour. Send the credentials in the JSON body (or as HTTP Basic, key ID as the username). Limited to 10 attempts a minute per IP address.

Body

key_id string · required
The key ID, starting zbk_live_ or zbk_test_. Not secret.
secret string · required
The secret, starting zb_live_ or zb_test_. Shown once when the key was created.
Request
curl -X POST https://zivobooks.com/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d "{\"key_id\": \"$ZIVOBOOKS_KEY_ID\", \"secret\": \"$ZIVOBOOKS_SECRET\"}"

# → {"access_token": "zbat_…", "expires_in": 3600, …}
export ZIVOBOOKS_TOKEN=zbat_…
<?php
$session = Http::acceptJson()
    ->post('https://zivobooks.com/api/v1/auth/token', [
        'key_id' => getenv('ZIVOBOOKS_KEY_ID'),
        'secret' => getenv('ZIVOBOOKS_SECRET'),
    ])
    ->throw()
    ->json();

$token = $session['access_token']; // valid until $session['expires_at']
const res = await fetch('https://zivobooks.com/api/v1/auth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
  body: JSON.stringify({
    key_id: process.env.ZIVOBOOKS_KEY_ID,
    secret: process.env.ZIVOBOOKS_SECRET,
  }),
});
if (!res.ok) throw new Error(`Sign-in failed: ${res.status}`);
const { access_token: token, expires_at } = await res.json();
import os, requests

session = requests.post(
    'https://zivobooks.com/api/v1/auth/token',
    json={'key_id': os.environ['ZIVOBOOKS_KEY_ID'], 'secret': os.environ['ZIVOBOOKS_SECRET']},
    timeout=30,
)
session.raise_for_status()
token = session.json()['access_token']  # valid until session.json()['expires_at']
Response · 201
{
    "access_token": "zbat_8sJ2…",
    "token_type": "Bearer",
    "expires_in": 3600,
    "expires_at": "2026-10-03T11:00:00+02:00",
    "mode": "live",
    "key_id": "zbk_live_3k9xq…",
    "scopes": [
        "invoices.read"
    ],
    "business": {
        "id": 12,
        "name": "Moyo Hardware"
    }
}

Sign out

DELETE /api/v1/auth/token auth: session token + key ID

Ends the current session token straight away. Optional: tokens expire on their own after an hour.

Request
curl -X DELETE https://zivobooks.com/api/v1/auth/token \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->delete('https://zivobooks.com/api/v1/auth/token');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/auth/token', {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.delete(
    'https://zivobooks.com/api/v1/auth/token',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "ok": true
}

Check a session

GET /api/ping auth: session token + key ID

Confirms the session works, whether it is test or live, which business it belongs to and when it expires. Needs no scope.

Request
curl https://zivobooks.com/api/ping \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/ping');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/ping', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/ping',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "ok": true,
    "mode": "live",
    "business": {
        "id": 12,
        "name": "Moyo Hardware"
    },
    "session_expires_at": "2026-10-03T11:00:00+02:00"
}

Customers

List customers

GET /api/v1/customers scope: customers.read auth: session token + key ID

Customers in ID order, oldest first.

Parameters

page integer · query
Page number, starting at 1.
per_page integer · query
Results per page, 1 to 100. Default 25.
updated_since string · query
Only records changed at or after this ISO 8601 date or timestamp. Use it to sync incrementally.
status string · query
active or archived.
search string · query
Part of the customer’s name.
Request
curl https://zivobooks.com/api/v1/customers?per_page=50 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/customers?per_page=50');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/customers?per_page=50', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/customers?per_page=50',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": [
        {
            "id": 42,
            "code": "C0042",
            "name": "Chikomo Builders",
            "email": "accounts@chikomo.co.zw",
            "phone": "+263 77 123 4567",
            "tax_number": "2000123456",
            "address": {
                "line1": "14 Samora Machel Ave",
                "line2": null,
                "city": "Harare",
                "region": "Harare",
                "postal_code": null
            },
            "status": "active",
            "created_at": "2026-09-14T08:12:00+02:00",
            "updated_at": "2026-10-01T15:40:22+02:00"
        }
    ],
    "links": {
        "first": "…?page=1",
        "last": "…?page=4",
        "prev": null,
        "next": "…?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "last_page": 4,
        "per_page": 25,
        "to": 25,
        "total": 87
    }
}

Create a customer

POST /api/v1/customers scope: customers.write auth: session token + key ID Idempotency-Key supported

Adds a customer; the business’s next customer code is assigned. Send an Idempotency-Key header so a retry never creates a duplicate.

Body

name string · required
The customer’s name.
email string
Where invoices are emailed.
phone string
Phone number, ideally with country code.
tax_number string
Tax or VAT number.
address object
line1, line2, city, region and postal_code, all optional.
notes string
Internal notes, up to 2,000 characters.
Request
curl -X POST https://zivobooks.com/api/v1/customers \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Chikomo Builders",
    "email": "accounts@chikomo.co.zw",
    "phone": "+263 77 123 4567",
    "address": {
        "line1": "14 Samora Machel Ave",
        "city": "Harare"
    }
}'
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID'), 'Idempotency-Key' => (string) Str::uuid()])
    ->acceptJson()
    ->post('https://zivobooks.com/api/v1/customers', [
        'name' => 'Chikomo Builders',
        'email' => 'accounts@chikomo.co.zw',
        'phone' => '+263 77 123 4567',
        'address' => [
            'line1' => '14 Samora Machel Ave',
            'city' => 'Harare',
        ],
    ]);

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/customers', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
      "name": "Chikomo Builders",
      "email": "accounts@chikomo.co.zw",
      "phone": "+263 77 123 4567",
      "address": {
          "line1": "14 Samora Machel Ave",
          "city": "Harare"
      }
  }),
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, uuid, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.post(
    'https://zivobooks.com/api/v1/customers',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
        'Idempotency-Key': str(uuid.uuid4()),
    },
    json={
        "name": "Chikomo Builders",
        "email": "accounts@chikomo.co.zw",
        "phone": "+263 77 123 4567",
        "address": {
            "line1": "14 Samora Machel Ave",
            "city": "Harare"
        }
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 201
{
    "data": {
        "id": 42,
        "code": "C0042",
        "name": "Chikomo Builders",
        "email": "accounts@chikomo.co.zw",
        "phone": "+263 77 123 4567",
        "tax_number": "2000123456",
        "address": {
            "line1": "14 Samora Machel Ave",
            "line2": null,
            "city": "Harare",
            "region": "Harare",
            "postal_code": null
        },
        "status": "active",
        "created_at": "2026-09-14T08:12:00+02:00",
        "updated_at": "2026-10-01T15:40:22+02:00"
    }
}

Get a customer

GET /api/v1/customers/{id} scope: customers.read auth: session token + key ID

One customer by ID.

Parameters

id integer · path · required
The customer’s ID.
Request
curl https://zivobooks.com/api/v1/customers/42 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/customers/42');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/customers/42', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/customers/42',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": {
        "id": 42,
        "code": "C0042",
        "name": "Chikomo Builders",
        "email": "accounts@chikomo.co.zw",
        "phone": "+263 77 123 4567",
        "tax_number": "2000123456",
        "address": {
            "line1": "14 Samora Machel Ave",
            "line2": null,
            "city": "Harare",
            "region": "Harare",
            "postal_code": null
        },
        "status": "active",
        "created_at": "2026-09-14T08:12:00+02:00",
        "updated_at": "2026-10-01T15:40:22+02:00"
    }
}

Invoices

List invoices

GET /api/v1/invoices scope: invoices.read auth: session token + key ID

Invoices in ID order, without their lines. Amounts are strings in the invoice’s own currency, so no precision is lost.

Parameters

page integer · query
Page number, starting at 1.
per_page integer · query
Results per page, 1 to 100. Default 25.
updated_since string · query
Only records changed at or after this ISO 8601 date or timestamp. Use it to sync incrementally.
status string · query
draft, approved, issued, sent, viewed, part_paid, paid, overdue or void.
customer_id integer · query
Only this customer’s invoices.
sales_channel string · query
pos for sales rung up on the till.
Request
curl https://zivobooks.com/api/v1/invoices?per_page=50 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/invoices?per_page=50');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/invoices?per_page=50', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/invoices?per_page=50',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": [
        {
            "id": 1290,
            "number": "INV-0142",
            "status": "paid",
            "customer": {
                "id": 42,
                "name": "Chikomo Builders"
            },
            "issue_date": "2026-09-20",
            "due_date": "2026-10-04",
            "currency": "USD",
            "subtotal": "1113.04",
            "tax_total": "166.96",
            "total": "1280.00",
            "balance": "0.00",
            "sales_channel": null,
            "portal_url": "https://app.zivobooks.com/invoices/9fK2…",
            "created_at": "2026-09-20T09:01:00+02:00",
            "updated_at": "2026-10-02T11:25:10+02:00"
        }
    ],
    "links": {
        "first": "…?page=1",
        "last": "…?page=4",
        "prev": null,
        "next": "…?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "last_page": 4,
        "per_page": 25,
        "to": 25,
        "total": 87
    }
}

Create a draft invoice

POST /api/v1/invoices scope: invoices.write auth: session token + key ID Idempotency-Key supported

Creates a draft invoice in the business’s base currency, numbered in its own sequence. Lines that name a product take its description, price and tax category unless you send them. Someone in the business reviews and sends the draft. Send an Idempotency-Key header.

Body

customer_id integer · required
One of the business’s customers.
issue_date string
YYYY-MM-DD. Defaults to today.
due_date string
YYYY-MM-DD, on or after issue_date. Defaults to 14 days after it.
items array · required
1 to 200 lines, each with quantity and either product_id or description + unit_price; tax_category_id is optional.
notes string
Shown on the invoice.
terms string
Payment terms shown on the invoice.
Request
curl -X POST https://zivobooks.com/api/v1/invoices \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": 42,
    "issue_date": "2026-10-03",
    "items": [
        {
            "product_id": 7,
            "quantity": 1
        },
        {
            "description": "Call-out fee",
            "quantity": 1,
            "unit_price": "25.00"
        }
    ]
}'
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID'), 'Idempotency-Key' => (string) Str::uuid()])
    ->acceptJson()
    ->post('https://zivobooks.com/api/v1/invoices', [
        'customer_id' => 42,
        'issue_date' => '2026-10-03',
        'items' => [
            [
                'product_id' => 7,
                'quantity' => 1,
            ],
            [
                'description' => 'Call-out fee',
                'quantity' => 1,
                'unit_price' => '25.00',
            ],
        ],
    ]);

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/invoices', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
      "customer_id": 42,
      "issue_date": "2026-10-03",
      "items": [
          {
              "product_id": 7,
              "quantity": 1
          },
          {
              "description": "Call-out fee",
              "quantity": 1,
              "unit_price": "25.00"
          }
      ]
  }),
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, uuid, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.post(
    'https://zivobooks.com/api/v1/invoices',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
        'Idempotency-Key': str(uuid.uuid4()),
    },
    json={
        "customer_id": 42,
        "issue_date": "2026-10-03",
        "items": [
            {
                "product_id": 7,
                "quantity": 1
            },
            {
                "description": "Call-out fee",
                "quantity": 1,
                "unit_price": "25.00"
            }
        ]
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 201
{
    "data": {
        "id": 1290,
        "number": "INV-0142",
        "status": "draft",
        "customer": {
            "id": 42,
            "name": "Chikomo Builders"
        },
        "issue_date": "2026-09-20",
        "due_date": "2026-10-04",
        "currency": "USD",
        "subtotal": "1113.04",
        "tax_total": "166.96",
        "total": "1280.00",
        "balance": "1280.00",
        "sales_channel": null,
        "items": [
            {
                "description": "Roof repair",
                "quantity": "1.000000",
                "unit_price": "1113.04",
                "tax_amount": "166.96",
                "line_total": "1280.00",
                "product_id": 7
            }
        ],
        "portal_url": "https://app.zivobooks.com/invoices/9fK2…",
        "created_at": "2026-09-20T09:01:00+02:00",
        "updated_at": "2026-10-02T11:25:10+02:00"
    }
}

Get an invoice

GET /api/v1/invoices/{id} scope: invoices.read auth: session token + key ID

One invoice with its lines and the link the customer uses to view and pay it.

Parameters

id integer · path · required
The invoice’s ID.
Request
curl https://zivobooks.com/api/v1/invoices/42 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/invoices/42');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/invoices/42', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/invoices/42',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": {
        "id": 1290,
        "number": "INV-0142",
        "status": "paid",
        "customer": {
            "id": 42,
            "name": "Chikomo Builders"
        },
        "issue_date": "2026-09-20",
        "due_date": "2026-10-04",
        "currency": "USD",
        "subtotal": "1113.04",
        "tax_total": "166.96",
        "total": "1280.00",
        "balance": "0.00",
        "sales_channel": null,
        "items": [
            {
                "description": "Roof repair",
                "quantity": "1.000000",
                "unit_price": "1113.04",
                "tax_amount": "166.96",
                "line_total": "1280.00",
                "product_id": 7
            }
        ],
        "portal_url": "https://app.zivobooks.com/invoices/9fK2…",
        "created_at": "2026-09-20T09:01:00+02:00",
        "updated_at": "2026-10-02T11:25:10+02:00"
    }
}

Quotes

List quotes

GET /api/v1/quotes scope: quotes.read auth: session token + key ID

Quotes in ID order.

Parameters

page integer · query
Page number, starting at 1.
per_page integer · query
Results per page, 1 to 100. Default 25.
updated_since string · query
Only records changed at or after this ISO 8601 date or timestamp. Use it to sync incrementally.
status string · query
draft, sent, viewed, accepted, declined, expired, converted or cancelled.
Request
curl https://zivobooks.com/api/v1/quotes?per_page=50 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/quotes?per_page=50');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/quotes?per_page=50', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/quotes?per_page=50',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": [
        {
            "id": 311,
            "number": "QUO-0057",
            "status": "accepted",
            "customer": {
                "id": 42,
                "name": "Chikomo Builders"
            },
            "issue_date": "2026-09-10",
            "valid_until": "2026-10-10",
            "total": "1280.00",
            "accepted_at": "2026-09-12T10:03:00+02:00",
            "updated_at": "2026-09-12T10:03:00+02:00"
        }
    ],
    "links": {
        "first": "…?page=1",
        "last": "…?page=4",
        "prev": null,
        "next": "…?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "last_page": 4,
        "per_page": 25,
        "to": 25,
        "total": 87
    }
}

Get a quote

GET /api/v1/quotes/{id} scope: quotes.read auth: session token + key ID

One quote by ID.

Parameters

id integer · path · required
The quote’s ID.
Request
curl https://zivobooks.com/api/v1/quotes/42 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/quotes/42');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/quotes/42', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/quotes/42',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": {
        "id": 311,
        "number": "QUO-0057",
        "status": "accepted",
        "customer": {
            "id": 42,
            "name": "Chikomo Builders"
        },
        "issue_date": "2026-09-10",
        "valid_until": "2026-10-10",
        "total": "1280.00",
        "accepted_at": "2026-09-12T10:03:00+02:00",
        "updated_at": "2026-09-12T10:03:00+02:00"
    }
}

Payments

List payments

GET /api/v1/payments scope: payments.read auth: session token + key ID

Customer payments in ID order, with the invoices each one paid.

Parameters

page integer · query
Page number, starting at 1.
per_page integer · query
Results per page, 1 to 100. Default 25.
updated_since string · query
Only records changed at or after this ISO 8601 date or timestamp. Use it to sync incrementally.
customer_id integer · query
Only this customer’s payments.
Request
curl https://zivobooks.com/api/v1/payments?per_page=50 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/payments?per_page=50');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/payments?per_page=50', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/payments?per_page=50',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": [
        {
            "id": 988,
            "customer": {
                "id": 42,
                "name": "Chikomo Builders"
            },
            "amount": "1280.00",
            "currency": "USD",
            "payment_date": "2026-10-02",
            "method": "mobile_money",
            "reference": "EC-77812",
            "allocations": [
                {
                    "invoice_id": 1290,
                    "invoice_number": "INV-0142",
                    "amount": "1280.00"
                }
            ],
            "created_at": "2026-10-02T11:25:09+02:00"
        }
    ],
    "links": {
        "first": "…?page=1",
        "last": "…?page=4",
        "prev": null,
        "next": "…?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "last_page": 4,
        "per_page": 25,
        "to": 25,
        "total": 87
    }
}

Get a payment

GET /api/v1/payments/{id} scope: payments.read auth: session token + key ID

One payment by ID.

Parameters

id integer · path · required
The payment’s ID.
Request
curl https://zivobooks.com/api/v1/payments/42 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/payments/42');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/payments/42', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/payments/42',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": {
        "id": 988,
        "customer": {
            "id": 42,
            "name": "Chikomo Builders"
        },
        "amount": "1280.00",
        "currency": "USD",
        "payment_date": "2026-10-02",
        "method": "mobile_money",
        "reference": "EC-77812",
        "allocations": [
            {
                "invoice_id": 1290,
                "invoice_number": "INV-0142",
                "amount": "1280.00"
            }
        ],
        "created_at": "2026-10-02T11:25:09+02:00"
    }
}

Products

List products

GET /api/v1/products scope: products.read auth: session token + key ID

Products and services in ID order. Tracked products include a stock object with the quantity on hand in total and per branch; services and untracked products have stock: null. A stock movement counts as a change, so updated_since picks up stock changes.

Parameters

page integer · query
Page number, starting at 1.
per_page integer · query
Results per page, 1 to 100. Default 25.
updated_since string · query
Only records changed at or after this ISO 8601 date or timestamp. Use it to sync incrementally.
active boolean · query
true for products still sold, false for archived ones.
barcode string · query
Exact barcode, for looking up a scanned item.
sku string · query
Exact SKU or product code.
track_stock boolean · query
true for products whose stock is tracked.
low_stock boolean · query
true for tracked products at or below their reorder level.
Request
curl https://zivobooks.com/api/v1/products?per_page=50 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/products?per_page=50');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/products?per_page=50', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/products?per_page=50',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": [
        {
            "id": 7,
            "name": "Cement 50kg",
            "sku": "CEM-50",
            "barcode": "6009880000017",
            "type": "product",
            "description": "Portland cement, 50kg bag.",
            "default_price": "12.50",
            "is_active": true,
            "track_stock": true,
            "stock": {
                "on_hand": "36.000000",
                "reorder_level": "40.000000",
                "low": true,
                "branches": [
                    {
                        "branch_id": 1,
                        "branch": "Head office",
                        "on_hand": "24.000000"
                    },
                    {
                        "branch_id": 2,
                        "branch": "Bulawayo",
                        "on_hand": "12.000000"
                    }
                ]
            },
            "updated_at": "2026-10-04T09:12:44+02:00"
        }
    ],
    "links": {
        "first": "…?page=1",
        "last": "…?page=4",
        "prev": null,
        "next": "…?page=2"
    },
    "meta": {
        "current_page": 1,
        "from": 1,
        "last_page": 4,
        "per_page": 25,
        "to": 25,
        "total": 87
    }
}

Get a product

GET /api/v1/products/{id} scope: products.read auth: session token + key ID

One product or service by ID, with stock on hand per branch when it’s tracked.

Parameters

id integer · path · required
The product’s ID.
Request
curl https://zivobooks.com/api/v1/products/42 \
  -H "Authorization: Bearer $ZIVOBOOKS_TOKEN" \
  -H "X-Zivobooks-Key: $ZIVOBOOKS_KEY_ID" \
  -H "Accept: application/json"
<?php
// $token comes from POST /api/v1/auth/token (see Authentication)
$response = Http::withToken($token)
    ->withHeaders(['X-Zivobooks-Key' => getenv('ZIVOBOOKS_KEY_ID')])
    ->acceptJson()
    ->get('https://zivobooks.com/api/v1/products/42');

$data = $response->throw()->json();
// token comes from POST /api/v1/auth/token (see Authentication)
const response = await fetch('https://zivobooks.com/api/v1/products/42', {
  headers: {
    Authorization: `Bearer ${token}`,
    'X-Zivobooks-Key': process.env.ZIVOBOOKS_KEY_ID,
    Accept: 'application/json',
  },
});

if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
import os, requests

# token comes from POST /api/v1/auth/token (see Authentication)
response = requests.get(
    'https://zivobooks.com/api/v1/products/42',
    headers={
        'Authorization': f'Bearer {token}',
        'X-Zivobooks-Key': os.environ['ZIVOBOOKS_KEY_ID'],
        'Accept': 'application/json',
    },
    timeout=30,
)
response.raise_for_status()
data = response.json()
Response · 200
{
    "data": {
        "id": 7,
        "name": "Cement 50kg",
        "sku": "CEM-50",
        "barcode": "6009880000017",
        "type": "product",
        "description": "Portland cement, 50kg bag.",
        "default_price": "12.50",
        "is_active": true,
        "track_stock": true,
        "stock": {
            "on_hand": "36.000000",
            "reorder_level": "40.000000",
            "low": true,
            "branches": [
                {
                    "branch_id": 1,
                    "branch": "Head office",
                    "on_hand": "24.000000"
                },
                {
                    "branch_id": 2,
                    "branch": "Bulawayo",
                    "on_hand": "12.000000"
                }
            ]
        },
        "updated_at": "2026-10-04T09:12:44+02:00"
    }
}

Webhooks

Instead of polling, a business can send events to your HTTPS endpoint as they happen. The business adds your URL under Settings → Integrations & API → Webhooks and chooses the events; each endpoint gets its own signing secret.

EventWhen it firesdata contains
invoice.sentAn invoice was sent to the customer by email or WhatsApp.invoice
invoice.paidAn invoice was paid in full.invoice
payment.recordedA customer payment was recorded, from any source.payment
quote.acceptedA customer accepted a quote online.quote
stock.lowA tracked product’s total stock dropped to or below its reorder level. Sent once when it crosses, not on every sale after.product

What we send

A POST with a JSON body and these headers: X-Zivobooks-Event, X-Zivobooks-Delivery (unique per delivery, use it to ignore duplicates) and X-Zivobooks-Signature.

{
    "id": "evt_01J9ZQ4V8H2YQ3K6M0T7R5B1CD",
    "event": "invoice.paid",
    "created_at": "2026-10-02T11:25:10+02:00",
    "business_id": 12,
    "data": {
        "invoice": {
            "id": 1290,
            "number": "INV-0142",
            "status": "paid",
            "total": "1280.00",
            "balance": "0.00",
            "currency": "USD",
            "…": "…"
        }
    }
}

Verifying the signature

The signature header looks like t=1727870000,v1=5f2b…. Compute an HMAC-SHA256 of t + "." + raw body with your signing secret and compare it to v1 in constant time. Reject requests older than five minutes to stop replays.

Verify (choose a language above; showing PHP)
<?php
// $secret is the endpoint's signing secret from Settings → Integrations & API
$header = $request->header('X-Zivobooks-Signature'); // t=1727870000,v1=5f2b…
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'].'.'.$request->getContent(), $secret);

if (! hash_equals($expected, $parts['v1'] ?? '') || abs(time() - (int) $parts['t']) > 300) {
    abort(400, 'Invalid signature');
}
import crypto from 'node:crypto';

// Use the raw request body, before any JSON parsing.
function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  return fresh && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ''));
}
import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split('=', 1) for p in header.split(','))
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts['t'])) <= 300
    return fresh and hmac.compare_digest(expected, parts.get('v1', ''))

Responding and retries

Reply with any 2xx status within 10 seconds, then do your work in the background. If we don’t get a 2xx, we retry after 1 minute, 5 minutes, 30 minutes and 2 hours: five attempts in all.

OpenAPI & Postman

Changelog

  1. v1.2.0 4 October 2026

    • Products: barcode, track_stock and a stock object with on_hand, reorder_level, low and quantities per branch. Cost is never exposed.
    • Products list: new barcode, sku, track_stock and low_stock filters.
    • A stock movement now counts as a change to the product, so updated_since picks up stock changes.
    • Invoices: sales_channel is "pos" for sales rung up on the till; filter with ?sales_channel=pos.
    • New stock.low webhook when a product drops to its reorder level.
  2. v1.1.0 3 October 2026

    • Sign-in: exchange a key ID and secret for a one-hour session token at POST /api/v1/auth/token. Secrets can no longer be sent on every call.
    • Every call sends the session token as a bearer token and the key ID in the X-Zivobooks-Key header.
    • Write endpoints: create customers and draft invoices, with the new customers.write and invoices.write scopes.
    • Idempotency-Key header on write calls, so retries never create duplicates.
    • Request log and sign-in history for every key set, visible to the business and the developer.
  3. v1.0.0 28 September 2026

    • Read-only access to customers, invoices, quotes, payments and products.
    • Test keys that read sample data, signed webhooks and an OpenAPI 3.1 document.

Versioning

The version is in the path. Within v1 we make additive changes (new endpoints, optional parameters and response fields), so build your code to ignore fields it doesn’t know. Anything that would break existing code goes in a new version, announced at least six months ahead.

Support

Questions, bugs or a feature you need? Email with the request path, the time and the response headers. Never send us a secret or session token. Found a security issue? See our responsible disclosure policy.