Creating and updating expenses & invoices from JSON
Two PL/pgSQL functions, exposed as HTTP endpoints, that build a complete accounting document — header plus line items — from a single JSON payload. Foreign keys are passed as descriptive objects and resolved or upserted to ids server-side, so callers never need to know internal primary keys.
Creates or updates one expenses row and replaces its expense_items rows.
Creates or updates one invoices row and replaces its invoice_items rows.
Each call is fully transactional. If any part fails — a bad cast, a missing required field, or a check-constraint violation — the whole document and all of its items roll back together. If the document number already exists, the function performs an UPSERT: the existing header is updated and its line items are replaced.
Both functions share the same set of resolver helpers. See Field resolution. The object format for a company, a contact, a currency, a VAT rate or a master item is identical across the two functions.
Calling convention
Both functions are reached over HTTP with POST
form-encoded parameters. Append ?mode=json to receive a
structured JSON response.
Endpoints
Request parameters
| Param | Required | Description |
|---|---|---|
| token | required | Session / API token. The server derives the acting user from it.
That user id becomes created_by and modified_by
on the rows written by the function. The uid argument of
the underlying PostgreSQL function is supplied by the API layer;
callers do not pass it directly.
See Generate an access token ↗. |
| params_ | required | JSON array of function parameters. For these endpoints, send one
object with a data property:
[{"data":{"payload":{...}}}]. The API layer passes that
data object to the underlying PostgreSQL function. |
The working request sends two form fields:
token and params_. The params_
value must be a JSON array. Each element contains a data
object, and inside it the actual document is placed under
payload.
Do not send the document as a direct data=... form field.
The OBS2GO function wrapper expects the payload inside
params_=[{"data":{"payload":{...}}}].
This page documents only the create_expense and
create_invoice functions. For authentication, token
lifetime, rate limits and every other endpoint, see the full
obs2go API documentation ↗
— in particular
Generate an access token ↗.
Generic request example
curl -d 'token=<TOKEN>¶ms_=[{"data":{"payload":{...}}}]' \ 'https://<INSTANCE>.obs2go.com/functions/<FUNCTION_NAME>?mode=json'
Generic create_expense request
curl -d 'token=<TOKEN>¶ms_=[{"data":{"payload":{"document_no":"0000001234", ... }}}]' \ 'https://<INSTANCE>.obs2go.com/functions/create_expense?mode=json'
Generic create_invoice request
curl -d 'token=<TOKEN>¶ms_=[{"data":{"payload":{"invoice_no":3840003027, ... }}}]' \ 'https://<INSTANCE>.obs2go.com/functions/create_invoice?mode=json'
Response format
With ?mode=json the response is an envelope with three
fields: ExitCode, Error and Data.
Success — ExitCode 0
The document id is returned inside Data.data, a two-element
array: the first element holds the function return value, which is the id
as text, and the second element holds the function name.
{
"ExitCode": 0,
"Error": "",
"Data": {
"data": [
[ "9" ],
[ "create_invoice" ]
]
}
}
Duplicate document-number errors are not returned by these functions
during normal execution. create_expense uses the
document_number_already_exists constraint for
ON CONFLICT DO UPDATE. create_invoice uses the
invoice_number_already_exists constraint for
ON CONFLICT DO UPDATE.
When a matching document already exists, the function updates the existing header row, deletes the old line items, inserts the new line items from the request, and returns the existing document id.
Error — ExitCode 1
On failure ExitCode is 1. The
Error field carries the message raised by the function or by
PostgreSQL. The document operation is transactional, so failed calls roll
back the header and all line items.
{
"ExitCode": 1,
"Error": "create_invoice: \"issuer_id\" (our_companies) is required",
"Data": null
}
Some failures, for example a malformed HTTP request that never reaches
the function wrapper, may come back as a plain-text body instead of the
JSON envelope. Treat any response where ExitCode is missing
or non-zero as a failure.
Field resolution
Foreign-key fields are not limited to raw ids. The payload may describe
the target row, and a resolver finds the matching id, creating the row
when it does not yet exist. Every resolver also accepts a plain integer,
in which case it is used as-is and no lookup happens. Omitted or
null attributes resolve to null.
Companies — recipient_companies_id, company_id
Target table companies. Upserted on the unique
eik constraint.
{ "eik": "123123123", "name": "Acme Inc", "vat_number": "BG123123123" }
- When
eikis present it is the conflict key. An existing company is reused, and a new one is inserted when no match exists. - When
eikis absent the resolver falls back to an exactnamematch, and inserts a new company if none is found. vat_numberandabbreviationare optional.
Our companies — paid_by_company_id, issuer_id
Target table our_companies. Same { eik, name }
object as above. Upserted on eik, with a name
fallback.
Contacts — recipient_contacts_id, contact_id
Target table contacts. Upserted on the composite unique key
(name, surname, family_name, email).
{ "name": "Ivan", "surname": "Petrov", "family_name": "Ivanov", "email": "[email protected]" }
A null in any of the four key columns makes the row
distinct from every other row, because SQL NULL never equals
NULL. Supply all four keys to de-duplicate contacts reliably.
Currency — currency
Target table currency, looked up by name,
for example BGN, EUR or USD.
Accepts a plain string, an id, or an object.
"EUR" // string, matched on currency.name { "name": "EUR" } // object form 2 // plain id, used as-is
An unknown currency raises Unknown currency: <value>.
VAT rate — vat_rate_id
Target table vat_rates, whose name column is
the numeric rate, for example 0.00, 9.00 or
20.00. The object carries the rate value under
rate or name. A plain integer is treated as an
existing id.
{ "rate": 20 } // looked up where vat_rates.name = 20
{ "name": 20 } // same thing
3 // plain id, used as-is
If the rate value is not found it is inserted as a new
vat_rates row. Both positive and negative rates may exist;
pass a signed value for credit-note rates.
Master items — material_id, product_id, service_id, machinery_id
Targets materials, products,
services and machinery. The resolver looks for
an existing row by code, then by name, and
inserts a new row by name when neither matches.
{ "name": "Widget A" } // or { "code": "...", "name": "..." }
Bank account — our_companies_bank_account_id
Target table our_companies_bank_accounts. The
iban is mandatory and acts as the de-duplication key.
{ "iban": "BG80BNBG96611020345678", "bank": "First Investment Bank", "bic_code": "FINVBGSF" }
- When the IBAN is not found a new account is created under the invoice
issuer_id, using that value asour_company_id. nameis required by the table. When the object omits it, it inherits the IBAN value.- An object without
ibanraises an error.
create_expense
Creates or updates one expenses row using the
document_number_already_exists constraint, then replaces the
related expense_items lines listed under
expense_items.
Header attributes
| Field | Type | Notes | |
|---|---|---|---|
| paid_by_company_id | object | resolved | our_companies — { eik, name } |
| recipient_companies_id | object | resolved | companies — { eik, name, vat_number } |
| recipient_contacts_id | object | resolved | contacts — { name, surname, family_name, email } |
| currency | string | int | resolved | matched on currency.name |
| tax_option_id | integer | plain | see reference · tax_options |
| vat_rate_id | object | int | resolved | { rate } |
| vat_purchases_type_id | integer | plain | see reference · vat_purchases_types |
| expense_categories_id | integer | plain | expenses_limitations__ |
| document_date | date | optional | YYYY-MM-DD |
| document_no | string | optional | supplier document number; used by document_number_already_exists UPSERT logic |
| document_type_id | integer | plain | accounting_document_type |
| due_date | date | optional | YYYY-MM-DD |
| purchase_order_id | integer | optional | existing purchase order id |
| comment | string | optional | free text |
| status_id | integer | plain | expense status id |
| expense_items | array | optional | line items — see below |
If an expense already exists according to the
document_number_already_exists constraint, the header is
updated, existing expense_items rows are deleted, and the
submitted expense_items are inserted again.
Line item attributes — expense_items[]
| Field | Type | Notes | |
|---|---|---|---|
| qty | numeric | required | quantity |
| price_per_unit | numeric | required | unit price |
| unit_id | integer | default 1 | units table |
| discount | numeric | optional | |
| line_base_amount | numeric | optional | |
| line_tax_amount | numeric | optional | |
| line_total_amount | numeric | optional | |
| line_total | numeric | optional | |
| material_id | object | int | resolved | materials |
| service_id | object | int | resolved | services |
| product_id | object | int | resolved | products |
| machinery_id | object | int | resolved | machinery |
| vat_rate_id | object | int | resolved | { rate } |
| tax_option_id | integer | plain | |
| line_expense_categories_id | integer | required | expenses_limitations__ |
| line_cost_location_id | integer | default 1 | cost_locations |
Every line must carry at least one of material_id,
service_id or product_id, or a name /
description if the table and function support those fields. A line with
only machinery_id set does not satisfy this constraint. A
second constraint, chk_expense_items_only_one_type, allows
at most one of material / service / product per line.
Example payload
{
"paid_by_company_id": { "eik": "111111111", "name": "My Company Ltd" },
"recipient_companies_id": { "eik": "222222222", "name": "Acme Inc", "vat_number": "BG222222222" },
"recipient_contacts_id": { "name": "Ivan", "surname": "Petrov",
"family_name": "Ivanov", "email": "[email protected]" },
"currency": "EUR",
"tax_option_id": 1,
"vat_rate_id": { "rate": 20 },
"vat_purchases_type_id": 2,
"expense_categories_id": 6,
"document_date": "2026-05-14",
"document_no": "0000001234",
"document_type_id": 1,
"due_date": "2026-06-13",
"purchase_order_id": null,
"comment": "Imported from supplier portal",
"status_id": 1,
"expense_items": [
{
"qty": 2,
"unit_id": 1,
"price_per_unit": 100.00,
"discount": 0,
"line_base_amount": 200.00,
"line_tax_amount": 40.00,
"line_total_amount": 240.00,
"line_total": 240.00,
"product_id": { "name": "Widget A" },
"vat_rate_id": { "rate": 20 },
"tax_option_id": 1,
"line_expense_categories_id": 6,
"line_cost_location_id": 1
}
]
}
Example curl
curl -d 'token=<TOKEN>¶ms_=[{"data":{"payload":{"paid_by_company_id":{"eik":"111111111","name":"My Company Ltd"},"recipient_companies_id":{"eik":"222222222","name":"Acme Inc","vat_number":"BG222222222"},"currency":"EUR","tax_option_id":1,"vat_rate_id":{"rate":20},"vat_purchases_type_id":2,"expense_categories_id":6,"document_date":"2026-05-14","document_no":"0000001234","document_type_id":1,"due_date":"2026-06-13","comment":"Imported from supplier portal","status_id":1,"expense_items":[{"qty":2,"unit_id":1,"price_per_unit":100.00,"line_base_amount":200.00,"line_tax_amount":40.00,"line_total_amount":240.00,"line_total":240.00,"product_id":{"name":"Widget A"},"vat_rate_id":{"rate":20},"tax_option_id":1,"line_expense_categories_id":6,"line_cost_location_id":1}]}}}]' \ 'https://<INSTANCE>.obs2go.com/functions/create_expense?mode=json' # => {"ExitCode":0,"Error":"","Data":{"data":[["18"],["create_expense"]]}}
create_invoice
Creates or updates one invoices row using the
invoice_number_already_exists constraint, then replaces the
related invoice_items lines listed under
invoice_items.
Header attributes
| Field | Type | Notes | |
|---|---|---|---|
| company_id | object | resolved | companies — one of company_id / contact_id required |
| contact_id | object | resolved | contacts — one of company_id / contact_id required |
| issuer_id | object | required | our_companies — no DB default |
| our_companies_bank_account_id | object | required | { iban, bank, bic_code } — no DB default |
| invoice_no | bigint | optional | used by invoice_number_already_exists UPSERT logic |
| currency | string | int | default 2 | matched on currency.name |
| issue_date | date | default today | YYYY-MM-DD |
| due_date | date | default +30d | YYYY-MM-DD |
| month | date | optional | accounting period, e.g. 2026-05-01 |
| payment_method_id | integer | default 1 | payment_methods |
| status_id | integer | default 1 | invoices_statuses |
| category_id | integer | default 1 | invoices_categories |
| tax_option_id | integer | default 1 | tax_options |
| vat_rate_id | object | int | default 3 | { rate } |
| income_category_id | integer | default 1 | invoices_income_categories |
| invoice_items | array | optional | line items — see below |
template_id is intentionally not accepted. It is left out
of the insert and update logic so it keeps its own database default.
If an invoice already exists according to the
invoice_number_already_exists constraint, the header is
updated, existing invoice_items rows are deleted, and the
submitted invoice_items are inserted again.
Line item attributes — invoice_items[]
| Field | Type | Notes | |
|---|---|---|---|
| price_per_unit | numeric | required | unit price |
| qty | numeric | default 1 | quantity |
| unit_id | integer | default 1 | units table |
| material_id | object | int | resolved | materials |
| product_id | object | int | resolved | products |
| service_id | object | int | resolved | services |
| vat_rate_id | object | int | resolved | { rate } |
| invoices_income_category_id | integer | default 1 | invoices_income_categories |
| tax_option_id | integer | plain | tax_options |
| discount | numeric | optional | |
| line_cost_location_id | integer | optional | cost_locations |
The call raises a descriptive error before touching the database when:
neither company_id nor contact_id resolves;
issuer_id does not resolve; or
our_companies_bank_account_id does not resolve. The bank
account is resolved after the issuer, since a newly created account is
filed under that issuer.
Example payload
{
"company_id": {
"eik": "203836472",
"name": "РОНИН 31 ЕООД",
"vat_number": "BG203836472"
},
"issuer_id": {
"eik": "207087700",
"name": "АРХ СТОУН ООД"
},
"contact_id": {
"name": "РОНИН 31 ЕООД",
"surname": " ",
"family_name": " ",
"email": "[email protected]"
},
"invoice_no": 3840003027,
"currency": "EUR",
"issue_date": "2026-06-22",
"due_date": "2026-06-27",
"payment_method_id": 1,
"status_id": 1,
"category_id": 1,
"our_companies_bank_account_id": {
"iban": "BG36UNCR70001525057090",
"bank": "ProCredit Bank(Bulgaria) LTD",
"bic_code": "PRCBBGSF"
},
"tax_option_id": 1,
"vat_rate_id": { "rate": 20 },
"income_category_id": 1,
"invoice_items": [
{
"product_id": { "name": "Стъпало антикиран гранит бр." },
"qty": 63,
"unit_id": 1,
"price_per_unit": 30.9,
"invoices_income_category_id": 1,
"tax_option_id": 1,
"vat_rate_id": { "rate": 20 },
"line_cost_location_id": 1
}
]
}
Working curl
curl -d 'token=<TOKEN>¶ms_=[{"data":{"payload":{"company_id":{"eik":"203836472","name":"РОНИН 31 ЕООД","vat_number":"BG203836472"},"issuer_id":{"eik":"207087700","name":"АРХ СТОУН ООД"},"contact_id":{"name":"РОНИН 31 ЕООД","surname":" ","family_name":" ","email":"[email protected]"},"invoice_no":3840003027,"currency":"EUR","issue_date":"2026-06-22","due_date":"2026-06-27","payment_method_id":1,"status_id":1,"category_id":1,"our_companies_bank_account_id":{"iban":"BG36UNCR70001525057090","bank":"ProCredit Bank(Bulgaria) LTD","bic_code":"PRCBBGSF"},"tax_option_id":1,"vat_rate_id":{"rate":20},"income_category_id":1,"invoice_items":[{"product_id":{"name":"Стъпало антикиран гранит бр."},"qty":63,"unit_id":1,"price_per_unit":30.9,"invoices_income_category_id":1,"tax_option_id":1,"vat_rate_id":{"rate":20},"line_cost_location_id":1}]}}}]' \ 'https://arhstone.obs2go.com/functions/create_invoice?mode=json' # => {"ExitCode":0,"Error":"","Data":{"data":[["9"],["create_invoice"]]}}
Reference values
Integer ids accepted by the plain fields. These are example seed values. Query the source tables for the authoritative list in each OBS2GO instance.
- 1
- Tax Excluded
- 2
- Tax Included
- 3
- Tax Exempt
- 1
- Invoice
- 2
- Credit Note
- 3
- Debit Note
- 4
- Proforma
- 5
- Memorial order
- 6
- GOP protocol / VOP
- 7
- Depreciation
- 1
- BGN
- 2
- EUR
- 3
- USD
- 9
- JPY
- 23
- HRK
- 38
- RUB
- 45
- XAU
- 1
- 0.00
- 2
- 9.00
- 4
- 10.00
- 3
- 20.00
- …
- 17 / 19 / 21 / 22 / 23 / 25 / 27
- …
- -20.00 for credit notes
- 1
- Покупки без ДК
- 2
- Покупки с право на ДК
- 3
- Покупки с частичен ДК
- 4
- Внос с ДК
- 5
- ДО посредник, тристранна
- 6
- Право на ДК от ВОП
- 7
- Право на част. ДК от ВОП
- 8
- Право на ДК от сметка ДДС
- 9
- Покупки с ДДС в ЕС
- 10
- Не се отразява в дневника
- 11
- Протокол самоначисляване
- 6
- Goods
- 5
- Salaries
- 9
- LTA — long-term assets
- 10
- Depreciation
- 20
- Internal Community Acq.
- 881
- Гориво
- 879
- Spedition / Спедиция
- 884
- Business travel expenses
- 885
- Materials
- …
- МПС / hotels / fines / other categories
- 1
- Devices
create_invoice reuses the field-resolution helpers
installed by create_expense, including
_expense_resolve_company,
_expense_resolve_currency,
_expense_resolve_vat_rate,
_expense_resolve_named and the rest. Install the shared
helper functions before installing either document function.
Errors
All function-level failures arrive in the Error field with
ExitCode: 1. The function is transactional, so when any of
these errors fire, nothing from that call is committed.
| Message | Cause |
|---|---|
| "data" must be a JSON object | The function argument was empty, not valid JSON, or a JSON array / scalar. |
| "payload" must be a JSON object | The wrapper object did not contain a valid payload object. |
| at least one of "company_id" or "contact_id" is required | create_invoice — neither recipient resolved. |
| "issuer_id" (our_companies) is required | create_invoice — issuer_id is missing or unresolvable. |
| "our_companies_bank_account_id" is required | create_invoice — the bank account object did not resolve to a row. |
| ... object must contain "iban" | A bank account object was supplied without the mandatory iban. |
| Cannot create bank account for IBAN ...: our_company_id ... is required | A new IBAN needs an issuer to file it under, but issuer_id was absent. |
| Unknown currency: <value> | The currency string or object did not match any currency.name. |
| ... reference object must contain "name" | A material, service, product or machinery object had neither name nor code. |
| vat_rate_id object must contain "rate" or "name" | A VAT-rate object carried no rate value. |
| null value in column "..." violates not-null constraint | A required column was left unset, for example a line item with no price_per_unit, or an expense line with no line_expense_categories_id. |
| new row ... violates check constraint "chk_expense_items_at_least_one" | An expense line had none of material / service / product set. Machinery alone is not enough for that constraint. |
| new row ... violates check constraint "chk_expense_items_only_one_type" | An expense line supplied more than one of material_id, service_id and product_id. |
Duplicate values covered by invoice_number_already_exists
and document_number_already_exists are handled by UPSERT
logic. They do not produce duplicate-key errors during normal function
execution.