API Sync & Safe Retries

Keep a copy of your Beeswax data in sync with updated_since, and make every write safe to retry with Idempotency-Key.

Overview

Two headers-worth of machinery make an integration robust: updated_since for pulling changes without re-reading everything, and Idempotency-Key so that a timeout or a retry can never create a second invoice, payment or journal. Use both from day one; retrofitting them after a duplicate payment is no fun.

There are no webhooks yet. Polling with updated_since on a schedule (every few minutes for operational sync, hourly or nightly for reporting) is the supported pattern, and it is cheap: a run with no changes is one small page per resource.


Keeping a copy in sync

Every listing accepts updated_since=<ISO 8601 timestamp> and returns only rows whose updated_at is on or after that instant. Every list row carries updated_at, and the response meta echoes the filter and the server clock:

curl "https://app.beeswaxapp.com/new_api/v1/invoices?updated_since=2026-09-05T00:00:00Z&include_drafts=true&per_page=100" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
"meta": {
  "filter": { "posted": null, "updated_since": "2026-09-05T00:00:00.000Z" },
  "server_time": "2026-09-06T01:02:03.456Z",
  "current_page": 1, "total_pages": 1, "total_count": 14, "per_page": 100
}

The pattern

  1. First run. Fetch each listing with no updated_since, walk every page, and store the server_time from the response as your cursor for that resource.
  2. Every later run. Pass the stored cursor minus one minute as updated_since, walk the pages, then store the new server_time. The one-minute overlap means a row saved while a page was being built is never missed. Your import must therefore treat a row it has already seen as an update, not a duplicate: upsert on id.
  3. Sync documents with include_drafts=true so voids and draft changes arrive too, and use posted to decide what belongs in your ledger (see the ledger rule).
  4. Use the server's clock, not yours. server_time is what the filter is compared against; your machine's clock may drift.

What counts as a change

  • Any header edit, state change (finalise, void, payment applied) or restore of a document.
  • Line and section edits on an invoice, expense, quote or manual journal: they touch the parent, so a changed line surfaces the document.
  • A new or edited contact person on a company touches the company.
  • Deleting a draft does not produce a change; the row simply stops appearing. Because ledger documents are voided rather than deleted, nothing on the ledger ever vanishes silently.

Where it works

updated_since is available on invoices, expenses, quotes, payments, journal entries, manual journals, companies, projects, the chart of accounts, products & services, tasks, time entries, milestones, events and project documents. A malformed timestamp is a 400.

A minimal sync loop

import requests, datetime as dt

BASE = "https://app.beeswaxapp.com/new_api/v1"
H = {"Authorization": f"Bearer {TOKEN}", "User-Agent": "acme-sync/1.0"}

def sync(resource, cursor):
    params = {"per_page": 100, "include_drafts": "true"}
    if cursor:
        since = dt.datetime.fromisoformat(cursor.replace("Z", "+00:00")) - dt.timedelta(minutes=1)
        params["updated_since"] = since.isoformat()
    page, server_time = 1, None
    while True:
        r = requests.get(f"{BASE}/{resource}", headers=H, params={**params, "page": page})
        r.raise_for_status()
        body = r.json()
        for row in body[resource]:
            upsert(resource, row)            # keyed on row["id"]; keep row["posted"]
        server_time = body["meta"]["server_time"]
        if page >= body["meta"]["total_pages"]:
            return server_time                # store this as the next cursor
        page += 1

Retrying writes safely

Any POST, PATCH or DELETE accepts an Idempotency-Key header. Put a fresh UUID on each logical operation and reuse it if you have to retry:

POST /new_api/v1/payments
Idempotency-Key: 6d1f2c1e-9b6a-4c0f-8f1e-2b3c4d5e6f70
Content-Type: application/json

The first request runs and its response is stored against your token for 24 hours. A retry with the same key and the same request gets the stored response back, with Idempotent-Replayed: true, and nothing is written again. So a timeout, a dropped connection or an agent re-issuing a tool call cannot post an invoice, a payment or a journal twice.

Rules

Situation Result
Same key, same request, within 24 hours The stored response, plus Idempotent-Replayed: true. Nothing is written.
Same key, different body or path 422. A key belongs to one operation; use a new key for a new operation.
Retry arrives while the original is still running 409. Wait a moment and retry with the same key. A claim abandoned for 90 seconds is released so a stuck original cannot block you forever.
Original returned 4xx The 4xx is stored and replayed too. Fix the payload and send with a new key.
Original returned 5xx Nothing is stored. Retry with the same key and the request runs afresh.
Two integrations use the same key No collision: keys are scoped to the token.

How to generate keys

Derive the key from your side's identity for the operation, so a crashed process that restarts produces the same key for the same intent:

  • Payment for bank line 88213 → payment:bank-line:88213
  • Invoice for your order 5541 → invoice:order:5541
  • A generic uuid4() stored with the job record before the request is sent

Anything up to 255 characters works. Do not reuse a key for a semantically different call, and do not derive it from the request body alone, since two legitimately identical payments on the same day would then collapse into one.

A retry loop that is actually safe

async function post(path, body, key, attempt = 0) {
  const res = await fetch(`${BASE}${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json",
               "Idempotency-Key": key, "User-Agent": "acme-billing/2.1" },
    body: JSON.stringify(body),
  });
  if (res.status === 409 || res.status === 429 || res.status >= 500) {
    if (attempt >= 5) throw new Error(`gave up after ${attempt} retries`);
    const wait = res.status === 429
      ? Number(res.headers.get("Retry-After") || 5) * 1000
      : Math.min(30000, 500 * 2 ** attempt) + Math.random() * 250;
    await new Promise(r => setTimeout(r, wait));
    return post(path, body, key, attempt + 1);          // same key, always
  }
  return res;                                            // 2xx or a 4xx to handle
}

Retry only on 409, 429 and 5xx. A 400, 401, 403, 404 or 422 will not change on retry; log it and stop.


Putting the two together

A payment-recording job, end to end:

  1. Poll /invoices?outstanding=true&updated_since=… to refresh what is owed.
  2. For each bank line you want to settle, build the payment body and a key such as payment:bank-line:<id>.
  3. POST /payments with the key. On 2xx, store the returned payment id against the bank line. On a replay, the stored response carries the same id, so the mapping is stable.
  4. Next run, updated_since brings back the invoices whose paid and state changed, confirming the loop closed.
Browse Topics