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
- First run. Fetch each listing with no
updated_since, walk every page, and store theserver_timefrom the response as your cursor for that resource. - Every later run. Pass the stored cursor minus one minute as
updated_since, walk the pages, then store the newserver_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 onid. - Sync documents with
include_drafts=trueso voids and draft changes arrive too, and usepostedto decide what belongs in your ledger (see the ledger rule). - Use the server's clock, not yours.
server_timeis 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:
- Poll
/invoices?outstanding=true&updated_since=…to refresh what is owed. - For each bank line you want to settle, build the payment body and a key such as
payment:bank-line:<id>. POST /paymentswith 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.- Next run,
updated_sincebrings back the invoices whosepaidandstatechanged, confirming the loop closed.