← Blog
odooaccountinginvoicingintegrationjson-rpc

Real-Time Accounting in Odoo: Posting Entries via the API

Post revenue into Odoo as it happens: create the draft invoice, post it, register the payment — with the idempotency and error checks a ledger demands.

ODXProxy Team · Aug 10, 2026 · 13 min read

Real-Time Accounting in Odoo: Posting Entries via the API — ODXProxy blog cover

The checkout finished, the card settled, the money is real. In Odoo it does not exist yet — not until something writes it to the ledger. Real-time accounting in Odoo means closing that gap: your transactional system creates and posts each invoice through the Odoo API as the event lands, instead of dropping a CSV at 2am that somebody reconciles on Thursday. This guide walks the customer-invoice path end to end through ODXProxy — resolve the customer, create the draft, post it, register the payment — and then spends most of its words on the parts that actually bite: idempotency, the HTTP 200 that carries an error, and what to do when a post times out and you don't know whether it landed.

What "real-time" can and can't mean in Odoo

Odoo has no streaming ingest and no push API. "Real-time" here means one synchronous write per business event, seconds behind reality — not sub-second, and not a firehose.

That distinction matters because posting an accounting entry is expensive ORM work. A single action_post allocates a sequence number, computes taxes, converts currency, builds the balancing lines, and fires whatever automation the customer has bolted on. It is nothing like a search_read. Two consequences shape everything below:

  • Keep it off the user's critical path. Enqueue the event, return to the checkout, and let a worker do the Odoo write. A customer should never wait on a journal entry.
  • Bound it. Give the call an explicit timeout so a slow ERP degrades your queue rather than your storefront. ODXProxy takes an x-request-timeout header in seconds (default 15).

The models you'll touch

  • account.move — the accounting document itself. move_type decides what it is: out_invoice (customer invoice), in_invoice (vendor bill), out_refund (credit note), or entry (a plain journal entry). state runs draftpostedcancel.
  • account.move.line — the lines, created through the invoice_line_ids one2many on the move.
  • account.payment.register — a wizard, not a stored document. It is how Odoo turns "this invoice got paid" into an account.payment plus the reconciliation.

Two fields on account.move earn their keep in an integration: ref (a free-text Reference, indexed, and the natural home for your external transaction id) and invoice_origin (the source document, e.g. your order number).

Flow: a settled payment event goes through ODXProxy to Odoo - create the draft invoice, then post it to the ledger

The shape of the flow: draft, then post

Resist the urge to make invoicing one call. Odoo splits it deliberately, and so should you:

  1. create the move — it lands in draft, which is cheap, editable, and deletable.
  2. Verify the draft says what you think it says.
  3. action_post it — now it has a sequence number and it's in the books.

A draft you got wrong is a write away from correct. A posted move you got wrong needs a reset or a credit note, and on a locked period you may not be able to touch it at all. The two-phase shape is your last cheap checkpoint.

Step 1 — resolve the customer

Start by mapping your customer to a res.partner id. Match on whatever stable key you sync on — an email, or better, an external reference you wrote into ref:

{
  "id": "partner-lookup-1",
  "action": "search_read",
  "model_id": "res.partner",
  "params": [[["email", "=", "ada@example.com"]]],
  "keyword": { "fields": ["name", "email", "property_account_receivable_id"], "limit": 1 },
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

That odoo_instance block carries the Odoo user's API key. The proxy's own key rides in the x-api-key header and is a completely separate secret — never interchange the two. Subsequent examples omit the block for brevity; every request still carries it.

If the lookup returns nothing, create the partner first. Do not let Odoo auto-create one as a side effect of invoicing — you'll end up with duplicate contacts and a receivable ledger nobody can read.

Step 2 — create the draft invoice

create is one of the nine directly callable actions, so the invoice and all of its lines go in a single round trip. One2many lines use Odoo's command-triple form — [0, 0, {…}] means "create a new related record from this dict":

{
  "id": "inv-create-1",
  "action": "create",
  "model_id": "account.move",
  "params": [{
    "move_type": "out_invoice",
    "partner_id": 214,
    "invoice_date": "2026-08-10",
    "ref": "stripe:pi_3PqX2s1e2fGh",
    "invoice_origin": "WEB-10482",
    "invoice_line_ids": [
      [0, 0, { "product_id": 87, "quantity": 2, "price_unit": 49.0 }],
      [0, 0, { "name": "Expedited shipping", "quantity": 1, "price_unit": 12.5 }]
    ]
  }],
  "keyword": {}
}

The response is the new move's id:

{ "jsonrpc": "2.0", "id": "inv-create-1", "result": 5183 }

Two things worth knowing about that payload. First, you did not have to specify accounts or taxes. On modern Odoo, account_id, tax_ids, and price_unit on a move line are computed fields declared with store=True, readonly=False, precompute=True, so they are derived from the product at create time even over the API, with no onchange emulation on your side. Pass them explicitly only when you need to override the product defaults — taxes take the replace command, [[6, 0, [tax_id]]].

Second, the move has no invoice number yet. name only takes its sequence value when the move is posted; in draft it is empty. If you need a reference to show the customer before posting, use your own ref.

Put your external transaction id in ref on the way in, not in a comment or a custom field you add later. It is indexed, it survives upgrades, and every idempotency check in this article keys off it.

Step 3 — read the draft back before you post

This is the checkpoint people skip and regret. Read the totals Odoo computed and compare them against the amount you actually charged:

{
  "id": "inv-verify-1",
  "action": "read",
  "model_id": "account.move",
  "params": [[5183], ["name", "state", "amount_untaxed", "amount_tax", "amount_total", "currency_id"]],
  "keyword": {}
}

If amount_total doesn't match the settled payment to the cent, stop. A mismatch means a fiscal position, price list, or tax configuration on the Odoo side disagrees with your checkout — and the draft is still free to fix or delete. Posting first and reconciling later turns a config bug into an accounting correction.

Step 4 — post it

Posting is not one of the nine direct actions, so it goes through call_method with an explicit fn_name. Like every recordset method, the id list is the first positional argument:

{
  "id": "inv-post-1",
  "action": "call_method",
  "model_id": "account.move",
  "fn_name": "action_post",
  "params": [[5183]],
  "keyword": {}
}

On success it returns true, state flips to posted, and name picks up its sequence (INV/2026/00042). The revenue is now in the books. The mechanics of call_method — why params starts with a list of ids, what comes back — are covered in calling custom Odoo methods with call_method.

Idempotency: a ledger is not a table you can dedupe later

Every queue you will ever build delivers twice. In most integrations a duplicate is an untidy row; in accounting it is overstated revenue and a customer who thinks they were billed twice.

Odoo will not save you here. There is no unique SQL constraint on ref — newer versions compute a duplicated_ref_ids warning for the UI, but nothing blocks a duplicate arriving over the API. The guard is yours, and it is one cheap call before the create:

{
  "id": "idem-1",
  "action": "search_count",
  "model_id": "account.move",
  "params": [[
    ["ref", "=", "stripe:pi_3PqX2s1e2fGh"],
    ["move_type", "=", "out_invoice"],
    ["state", "!=", "cancel"]
  ]],
  "keyword": {}
}

A non-zero count means this event already produced an invoice: skip it, don't create a second one.

Check-then-create is not atomic. Two workers handling the same event can both read 0 and both create an invoice. Serialize on the idempotency key in your queue — a unique index on the event id in your own database, or a per-key lock — and treat the search_count as a second line of defence, not the only one.

The HTTP 200 trap, with money on it

Odoo returns logic errors with HTTP 200 and a populated error object. Only proxy-layer failures get a non-200 status. Accounting is where this hurts most, because the failures are routine: a closed period, a missing receivable account, a tax that doesn't exist in that company.

Post into a locked period and you get a perfectly successful-looking HTTP response:

{
  "jsonrpc": "2.0",
  "id": "inv-post-1",
  "error": {
    "code": 200,
    "message": "You cannot add/modify entries prior to and inclusive of the lock date.",
    "data": { "name": "odoo.exceptions.UserError" }
  }
}

Code that checks response.ok and moves on has just dropped an invoice on the floor, silently, and your month-end will be short by exactly that amount. So the check is always two steps: status first (a non-200 with -32003/-32004 is a timeout or gateway problem, -32000 is auth), then the error field even on a 200, and only then trust result. The reusable handler is in Odoo API error handling.

Timeouts and the duplicate-posting hazard

A timeout is the one failure where you genuinely do not know what happened. Odoo may have committed the move and simply not answered in time.

Bound the call explicitly, sized to the work — a post deserves more headroom than a read:

curl -X POST https://your-proxy.example.com/api/odoo/execute \
  -H "Content-Type: application/json" \
  -H "x-api-key: $ODX_PROXY_KEY" \
  -H "x-request-timeout: 30" \
  -d '{
    "id": "inv-post-1",
    "action": "call_method",
    "model_id": "account.move",
    "fn_name": "action_post",
    "params": [[5183]],
    "keyword": {},
    "odoo_instance": {
      "url": "https://erp.example.com", "db": "prod",
      "user_id": 2, "api_key": "<the Odoo user API key>"
    }
  }'

When that comes back -32003, never blind-retry the create. Re-run the search_count on ref first: if the invoice exists, resume from wherever it got to (read its state; post it if it's still draft). Retrying a post is safer than retrying a create — posting an already-posted move raises a UserError rather than duplicating anything — but the lookup costs one call and removes the guesswork entirely.

Step 5 — register the payment

The invoice is posted and payment_state reads not_paid. Marking it paid means driving the same wizard the accountant would use, in two calls.

First create the wizard record, passing the invoice through the context — the wizard reads active_model and active_ids in its default_get to work out what it's paying:

{
  "id": "pay-1",
  "action": "create",
  "model_id": "account.payment.register",
  "params": [{
    "payment_date": "2026-08-10",
    "amount": 110.5,
    "journal_id": 7,
    "communication": "stripe:pi_3PqX2s1e2fGh"
  }],
  "keyword": {
    "context": { "active_model": "account.move", "active_ids": [5183] }
  }
}

Then call action_create_payments on the wizard id that came back:

{
  "id": "pay-2",
  "action": "call_method",
  "model_id": "account.payment.register",
  "fn_name": "action_create_payments",
  "params": [[91]],
  "keyword": {}
}

That method returns an ir.actions.act_window dictionary describing the payments it created — the same window Odoo would open for a human. Don't parse it for status. Read the invoice instead:

{
  "id": "pay-verify-1",
  "action": "read",
  "model_id": "account.move",
  "params": [[5183], ["payment_state", "amount_residual"]],
  "keyword": {}
}

payment_state is the answer you want: paid, partial, in_payment (received, not yet reconciled against the bank), or still not_paid. Pair it with amount_residual and you have the whole picture.

Omit journal_id and Odoo picks a default journal. On a multi-currency or multi-journal setup that default is rarely the one you meant — pass it explicitly and map your payment providers to journals once, in config, not in code.

Pure journal entries, for ledger-style systems

If you're not invoicing — a payments platform posting settlement, fees, and payouts — you want move_type: "entry" and the line_ids one2many, with debits and credits you balance yourself:

{
  "id": "je-1",
  "action": "create",
  "model_id": "account.move",
  "params": [{
    "move_type": "entry",
    "date": "2026-08-10",
    "journal_id": 3,
    "ref": "settlement:2026-08-10:acct_19f2",
    "line_ids": [
      [0, 0, { "account_id": 17, "name": "Settlement received", "debit": 980.0, "credit": 0.0 }],
      [0, 0, { "account_id": 42, "name": "Processor fee",       "debit": 20.0,  "credit": 0.0 }],
      [0, 0, { "account_id": 58, "name": "Gross sales",         "debit": 0.0,   "credit": 1000.0 }]
    ]
  }],
  "keyword": {}
}

Same rules apply, with one addition: the entry must balance. Total debits ≠ total credits raises a UserError on post — arriving, as always, as an HTTP 200 with a populated error.

When Odoo says no: queue, don't drop

Sort every failure into two buckets, because they need opposite handling:

  • Transient-32003 (timeout), -32004 (bad gateway), Odoo restarting, a worker pool saturated at month-end. Retry with exponential backoff, reusing the same ref, after the existence check.
  • Deterministic — locked period, missing account, tax not found, access denied (-32000 is the proxy's auth failure; Odoo's own access errors arrive as a 200 with an AccessError). Retrying changes nothing. Dead-letter it with the full request and the error payload attached, and page a human.

The one thing you must never do is drop the event. An accounting integration's real contract is that every settled transaction eventually reaches the ledger exactly once — late and visible beats fast and lossy.

Where the proxy actually helps

None of the above is magic, and a gateway doesn't make Odoo faster. What it does change:

  • x-request-timeout per call, so a heavy post degrades your queue rather than hanging a worker indefinitely.
  • An allowlist of nine actions, so the service that posts revenue can't be repurposed to unlink half a database if its credentials leak.
  • Two separate secrets — the proxy key your payments service holds, and the Odoo user key behind it — so the app tier never carries ERP-wide credentials.
  • One error catalog across every instance you write to, instead of per-integration fault decoding.

ODXProxy is early (v0.1.0), so treat anything beyond the shipped request/response contract as roadmap rather than something to design against today.

Putting it together

  1. Resolve the partner — never let invoicing auto-create contacts.
  2. search_count on ref before you write anything.
  3. create the account.move in draft, lines as [0, 0, {…}] triples.
  4. Read it back and reconcile amount_total against what you charged.
  5. call_methodaction_post to put it in the books.
  6. Register the payment via the wizard: create with active_ids in context, then action_create_payments.
  7. Verify with payment_state and amount_residual.
  8. Check error on every 200, and classify failures before retrying.

The happy path is six calls. The other 90% of the work — the idempotency key, the two-step error check, the timeout policy — is what separates an integration that closes the month from one that quietly loses a day of revenue.

Where to go next