Recipes

Reference pages tell you what one endpoint does. These describe finishing a job.

Push a storefront order into NolaPro#

The mistake here is treating each web order as an isolated POST. Orders arrive for customers who may or may not exist, referencing items that may or may not be stocked, and your storefront will happily send the same order twice.

1. Resolve the customer before anything else. Look them up by your own reference, not by name.

curl 'https://acme.nolapro.com/!/api/v2/customers?customercode=WEB-88213' \
  -H "Authorization: Bearer $NP_TOKEN"

If total is 0, create them. Store the returned id against your storefront's customer record so you never do this lookup twice.

2. Build the whole document, then send it once. Header and lines go in one body, in one transaction. There is no partial order.

3. Key the idempotency on your order id, not on a timestamp or a random value.

Idempotency-Key: shopify-order-88213

Now a retry after a timeout is safe, and so is your queue redelivering the message, and so is an operator clicking Resync.

4. Treat 422 as a task for a person. Credit limit exceeded, closed period, unknown tax group: these are business conditions. Put the order in an exceptions queue with the error message attached. Do not retry, and do not silently drop it.

Keep stock levels in sync#

Read stock per item per location. There is no global stock endpoint, because a quantity without a location is not a fact about anything.

curl 'https://acme.nolapro.com/!/api/v2/items/3391/stock?location=2' \
  -H "Authorization: Bearer $NP_TOKEN"
{
  "itemid": 3391,
  "location": 2,
  "onhand": "412.000000",
  "committed": "60.000000",
  "available": "352.000000",
  "cost": "7.1100"
}

Publish available, not onhand. onhand is what is physically on the shelf, including units already promised to open orders. Publishing it to a storefront is how you sell the same bracket twice.

Careful

You cannot write onhand. Stock moves through receipts, shipments, and adjustments so that cost layers and the inventory GL account stay consistent with the quantity. An endpoint that let you set a number directly would let you put the balance sheet out of step with the warehouse.

Import historical transactions#

Bringing over a year of history from another system.

Import in dependency order. Customers and vendors, then items, then open documents, then payments. Each step needs the ids from the one before.

Watch the GL periods. Historical dates land in historical periods, and those periods must be open at the moment you post. Coordinate with whoever closes the books, and expect 422 period_closed if you do not.

Do it in a copy first. Ask support for a restore of your data into a test company. An import that goes wrong in production is unpicked entry by entry.

Batch, but not too hard. Sequential requests with a small concurrency limit finish sooner than a burst that trips the rate limiter and then retries.

Retry safely#

The only retry loop worth writing:

import time, uuid, requests

def post(path, body, key, attempts=4):
    headers = {
        'Authorization': f"Bearer {TOKEN}",
        'Content-Type': 'application/json',
        'Idempotency-Key': key,
    }
    for n in range(attempts):
        r = requests.post(BASE + path, json=body, headers=headers)

        if r.status_code < 300:
            return r.json()

        if r.status_code == 429:
            time.sleep(int(r.headers.get('Retry-After', 5)))
            continue

        if r.status_code >= 500:
            time.sleep(2 ** n)
            continue

        raise ApiError(r.json()['error'], r.headers.get('X-Request-Id'))

    raise ApiError('exhausted retries', None)

Three things make this correct:

  • The idempotency key is passed in, not generated inside the function, so every attempt reuses it.
  • Only 429 and 5xx retry. Everything in the 4xx range raises, because none of it will succeed on a second try.
  • The request id is carried into the exception, so a support ticket can name the exact request.

Build a document line by line#

POST /invoices and POST /apbills post to the general ledger as they create. That is what you want when you already know the whole document, and it is exactly what you do not want when you are assembling one from a source system that hands you lines a few at a time.

Send post: false and you get a draft: a real document with no GL voucher.

POST /invoices
Content-Type: application/json
Idempotency-Key: build-4471-header

{ "customerid": 1042, "post": false,
  "lines": [ { "description": "Consulting, March", "quantity": "1", "price": "1200.00" } ] }
{ "id": 119905, "status": "unposted", "total": "1200.0000", "lines": [ ... ] }

Because there is no voucher, the lines are still yours to change:

POST /invoicelines
{ "invoiceid": 119905, "description": "Expenses, March", "quantity": "1", "price": "310.40" }
{ "id": 442119, "linenumber": 2, "invoicetotal": "1510.4000" }

Every line write hands back invoicetotal, re-derived. When the document is right, post it:

POST /invoices/119905/post
Idempotency-Key: build-4471-post
{ "id": 119905, "status": "open", "total": "1510.4000" }

That is the moment the ledger is told. After it, POST /invoicelines and PATCH /invoicelines/{id} answer 409 invoice_already_posted — the voucher was written for the total as it stood, so a later line would leave the two disagreeing. Credit it or raise another invoice instead.

Vendor bills work the same way, with one extra rule. A posted bill's distribution must equal its total, so a draft bill may be created part-distributed and completed with POST /apbilllines:

POST /apbills
{ "vendorid": 88, "number": "INV-99213", "total": "30.00", "post": false,
  "lines": [ { "glaccountid": 2309, "amount": "20.00" } ] }

Posting it before the remaining 10.00 is distributed is refused, because billpost() builds the voucher from the distribution:

{ "error": {
    "code": "gl_set_unbalanced",
    "field": "lines",
    "message": "The distribution lines sum to 20.0000 but the bill total is 30.0000. Balance it before posting."
} }

Posting twice is not an error worth fearing — the second call answers 409 rather than writing a second voucher, so a retry after a dropped connection is safe.

Correct a document you already sent#

Two different jobs, and picking the wrong one is how books stop reconciling.

If the mistake is a reference, amend it. PATCH accepts the fields the document has not already acted on, and refuses the rest by name:

PATCH /invoices/119905
Content-Type: application/json
If-Match: "ac5c6c6c8ad80269"

{ "ponumber": "PO-4471" }

Send something it will not take and the refusal tells you what it will:

{ "error": {
    "code": "rule_violation",
    "field": "invoicetotal",
    "message": "Cannot change invoicetotal on this document once it exists. Amendable here: ponumber."
} }

That list is state-dependent. A draft invoice will also take duedate; once it posts, it will not, because AR aging is built from it. Use If-Match with the ETag from your last read and a concurrent edit gets 409 stale_record instead of silently winning.

If the mistake is in the money, cancel and re-raise. There is no amending a total, deliberately:

DELETE /invoices/119905
{ "id": 119905, "cancelled": true, "reversed": true }

reversed: true is the part that matters. Cancelling a posted invoice does not erase it — it writes the reversing GL entries and leaves both in the ledger, which is what makes the correction auditable. The same holds across the documents:

Cancelling thisAlso does this
a posted invoicewrites the reversing voucher, rolls back customer stats
an orderreleases the stock it had committed
a shipmentputs the goods back and reopens the order
a payment or depositreverses its voucher; the invoice stops being marked paid
a journal entryunposts it, writing the reversing lines

Then post the corrected document as a new one, with a new idempotency key.

Cancelling is idempotent. A repeat DELETE returns 200 and "Already cancelled; nothing changed.", so a retry after a dropped connection is safe and does not need special handling.

What it will refuse. Anything already depended on downstream, with 409:

  • an invoice with payments applied — refund or cancel the payment first
  • an order that has shipped — cancel the shipment first
  • a shipment that has been invoiced — credit the invoice first
  • a purchase order with receipts — reverse the receipt first

And 422 period_closed if the current GL period is shut, because that is where the reversal has to post — not the period the original landed in.

Reconcile what you sent against what posted#

Run this weekly if you create documents automatically. It catches the failures your queue swallowed.

  1. List invoices in the period you care about.
  2. Compare against the documents your system believes it created.
  3. Investigate anything present on one side and not the other.

Integrations drift quietly. A message that failed six weeks ago in a way nobody watched is discovered either by this job or by an accountant, and the first one is cheaper.