For a Ukrainian online store or distributor, two integrations decide whether Odoo saves time or creates manual work: delivery and payments. Most orders ship with Nova Poshta, and a growing share is paid online through bank acquiring such as monobank's. Without integration, managers copy addresses into the carrier's cabinet, paste waybill numbers back into Odoo and reconcile payments by hand. This article shows how we connect Odoo to both services: the architecture, the actual API calls, webhook security and the pitfalls that cost time on real projects.
What the integration should do
A good integration removes every manual step between "order confirmed" and "money reconciled":
- Address entry with validation. The customer or manager selects a city and a branch (warehouse or parcel locker) from Nova Poshta's directory instead of typing free text.
- Shipping cost estimate on the quotation or website checkout.
- Waybill creation (in Ukrainian, ТТН, the express waybill) when the delivery order is validated, with the number stored on the picking.
- Status tracking that updates Odoo automatically: in transit, arrived at branch, received, returned.
- Cash on delivery reconciliation, so COD amounts are matched to invoices.
- Online payment through an acquiring invoice link, with the order marked paid by a verified webhook.
Architecture: where the logic lives
We implement both integrations as custom Odoo modules that extend standard models rather than separate middleware:
| Odoo model | Extension |
|---|---|
delivery.carrier |
New delivery type "Nova Poshta" with API credentials and pricing method |
stock.picking |
Waybill number, status, label printing, tracking URL |
res.partner |
Nova Poshta city and branch references for recipients |
payment.provider |
New provider "monobank" with merchant token and webhook endpoint |
payment.transaction |
Invoice ID from the bank, status mapping, fiscal receipt reference |
Odoo's delivery and payment frameworks already handle the order flow, so a custom carrier and payment provider plug into the website checkout, sales orders and accounting without changing core code. That matters at every upgrade, as we explain in Odoo 17 vs Odoo 18.
Nova Poshta: how the API works
Nova Poshta exposes a JSON API at https://api.novaposhta.ua/v2.0/json/. Every call is a POST with the same envelope: your API key, a model name, a method name and method properties. The key is generated in the business cabinet.
Searching branches in a city looks like this:
import requests
NP_URL = "https://api.novaposhta.ua/v2.0/json/"
def np_call(api_key, model, method, props):
r = requests.post(NP_URL, json={
"apiKey": api_key,
"modelName": model,
"calledMethod": method,
"methodProperties": props,
}, timeout=15)
r.raise_for_status()
data = r.json()
if not data.get("success"):
raise UserError("Nova Poshta: " + "; ".join(data.get("errors", [])))
return data["data"]
branches = np_call(key, "Address", "getWarehouses",
{"CityRef": city_ref, "FindByString": "15", "Limit": "20"})
The same envelope is used to create a waybill with model InternetDocument and method save, passing sender and recipient references, cargo type, weight, declared value, payer and payment method, and optionally a backward delivery for cash on delivery. Tracking uses model TrackingDocument and method getStatusDocuments with up to 100 waybill numbers per call.
Practical rules we follow:
- Cache directories locally. Cities and branches change rarely; sync them nightly into Odoo tables instead of calling the API on every keystroke at checkout.
- Store references, not names. Nova Poshta identifies cities, branches and counterparties by
RefUUIDs. Names change and duplicate; references don't. - Batch status updates. A scheduled action polls statuses for open waybills in batches, rather than one request per picking.
- Map statuses explicitly. Translate carrier status codes into a small set of Odoo states and decide which ones trigger actions, such as creating a return or notifying the customer.
monobank acquiring: invoices and webhooks
monobank's merchant API (monobank acquiring documentation) follows an invoice model:
- Your server creates an invoice with
POST /api/merchant/invoice/create, authenticated with the merchant token in theX-Tokenheader. The amount is passed in minor units (kopiykas) together with the currency code, a reference to your order, aredirectUrlfor the customer and awebHookUrlfor status notifications. - The response contains an invoice ID and a payment page URL. Odoo redirects the customer there.
- When the payment status changes, monobank sends a POST to your webhook, signed with an ECDSA signature in the
X-Signheader. - Your server verifies the signature with the public key from
/api/merchant/pubkey, then updates the transaction. Odoo confirms the order and, depending on configuration, creates and reconciles the payment.
import base64, hashlib
from ecdsa import VerifyingKey, BadSignatureError
from ecdsa.util import sigdecode_der
def verify_mono_webhook(raw_body: bytes, x_sign: str, pubkey_b64: str) -> bool:
# /api/merchant/pubkey returns the PEM public key encoded in base64
vk = VerifyingKey.from_pem(base64.b64decode(pubkey_b64))
try:
return vk.verify(base64.b64decode(x_sign), raw_body,
hashfunc=hashlib.sha256, sigdecode=sigdecode_der)
except BadSignatureError:
return False
Never mark an order as paid based on the customer returning to redirectUrl — that can be faked. Only the verified webhook, or a server-side status request to /api/merchant/invoice/status, is proof of payment.
Fiscalization and accounting
Ukrainian retail sales generally require a fiscal receipt from a cash register or a software cash register (PRRO). The acquiring API includes endpoints related to fiscal receipts, but whether you use the bank's fiscalization or a separate PRRO provider depends on your setup and accountant. Decide this early: it affects which system is the source of truth for receipts, and Odoo's standard Ukrainian localization covers only the chart of accounts, as we noted in Migrating from 1C to Odoo.
On the accounting side, map each flow to journals explicitly:
- Online card payments through acquiring: a bank journal for the acquiring account, with the bank's fee posted separately.
- Cash on delivery: a transit account for Nova Poshta money transfers, reconciled when the carrier pays out.
Pitfalls we hit on real projects
- Address quality. Free-text addresses from old systems rarely match carrier directories. Plan a one-time cleanup and enforce directory selection afterwards.
- Rate limits and timeouts. External APIs slow down at peak hours. Use timeouts, retries with backoff and queue jobs so a slow carrier doesn't block Odoo workers — a common cause of the slowdowns covered in Odoo performance tuning.
- Idempotency. Webhooks can arrive twice or out of order. Process them idempotently by invoice ID and status.
- Test environments. Use separate API keys and merchant tokens for staging, and never let a staging database send real waybills or charge real cards.
- Returns. Model return shipments and refunds from the start; retrofitting them later is painful.
Timeline and budget
For a typical store on Odoo with one warehouse, the Nova Poshta module (directories, waybills, tracking, COD) and the monobank payment provider (invoices, webhooks, refunds) take three to five weeks together, including testing with real accounts. That is usually a small part of a broader project, whose overall economics we break down in How much does Odoo implementation cost.
Testing checklist before go-live
- Directory sync of cities and branches runs nightly and handles renamed or closed branches
- Waybills are created with correct weight, declared value and payer for each shipping method
- Cash on delivery amounts match invoices, including partial payments
- Status updates move pickings through the expected states, including returns
- Payment webhooks are verified, idempotent and tested with duplicates and out-of-order delivery
- Refunds work end to end and are reflected in accounting
- Staging uses separate API keys and cannot create real shipments or charges
FAQ
Are there ready-made modules on the Odoo App Store? Yes, community and commercial modules exist. Check compatibility with your Odoo version, code quality and maintenance before relying on one for core logistics.
Can customers choose a parcel locker at checkout? Yes. Lockers are part of the carrier's branch directory and can be offered as a separate delivery option on the website.
Do we need webhooks if we can poll payment status? Webhooks give instant confirmation; polling is a useful backup for missed notifications. Use both.
Can the same approach work for other carriers and banks? Yes. The carrier and payment provider frameworks in Odoo are generic, so Ukrposhta, Meest or other acquirers follow the same pattern.
Sources
- Nova Poshta. API endpoint and developer documentation (available in the Nova Poshta business cabinet).
- monobank. Acquiring API documentation.
- Odoo. Upgrade documentation.
- Odoo. Fiscal localizations.
- Odoo on GitHub. Ukrainian localization module.