Creating 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.
Writes one expenses row and N expense_items rows.
Writes one invoices row and N invoice_items rows.
Each call is fully transactional. If any part fails — a bad cast, a missing required field, a check-constraint violation — the whole document and all of its items roll back together. A call either creates everything or nothing.
Both functions share the same set of resolver helpers (see Field resolution), so the object format for a company, a contact, a currency or a VAT rate is identical across the two.
Calling convention
Both functions are reached over HTTP with POST
form-encoded parameters. Append ?mode=json to receive a
structured JSON response instead of raw text.
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 every row the call writes. The
uid argument of the underlying function is supplied
by the API layer; callers do not pass it.
See Generate an access token ↗. |
| data | required | The document payload, a JSON object. Passed as a single
form field and cast to json inside the function body. |
Because data is JSON, send it with
--data-urlencode rather than plain -d. Raw
-d leaves &, + and spaces in
the JSON unescaped, which truncates or corrupts the 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 ↗.
Example request
curl -X POST 'https://<INSTANCE>.obs2go.com/functions/create_expense?mode=json' \ --data-urlencode 'token=<TOKEN>' \ --data-urlencode 'data={"document_no":"0000001234", ... }'
Response format
With ?mode=json the response is an envelope with three
fields: ExitCode, Error and Data.
Success — ExitCode 0
The new document id is returned inside Data.data, a
two-element array: the first element holds the returned value (the id),
the second holds the function name.
{
"ExitCode": 0,
"Error": "",
"Data": {
"data": [
[ 18 ], // returned id (new expenses.id / invoices.id)
[ "create_expense" ] // function name
]
}
}
Error — ExitCode 1
On failure Data is null and Error
carries the message raised by the function (for example the
RAISE EXCEPTION texts documented under each function below).
{
"ExitCode": 1,
"Error": "create_invoice: \"issuer_id\" (our_companies) is required",
"Data": null
}
Some failures (for example a malformed request that never reaches the
function) 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 passed as raw ids. Instead the payload
describes 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, a new one is inserted. - 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 (SQL NULL never equals
NULL), so partially-filled contacts are always inserted as
new. Supply all four keys to de-duplicate reliably.
Currency — currency
Target table currency, looked up by name
(BGN, EUR, 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 (0.00, 9.00,
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. Note that both 20.00 and
-20.00 exist — pass a signed value for credit-note rates.
Master items — material_id, product_id, service_id, machinery_id
Targets materials, products,
services, 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
(lookup-then-insert).
{ "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(itsour_company_id). nameis required by the table; when the object omits it, it inherits the IBAN value.- An object without
ibanraises... must contain "iban".
create_expense
Creates one expenses row plus the
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 |
| 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 |
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). A line with only machinery_id set does
not satisfy this and will be rejected. A second
constraint, chk_expense_items_only_one_type, allows at most
one of material / service / product per line.
Example call
{
"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
}
]
}
curl -X POST 'https://<INSTANCE>.obs2go.com/functions/create_expense?mode=json' \ --data-urlencode 'token=<TOKEN>' \ --data-urlencode "[email protected]" # => { "ExitCode": 0, "Error": "", "Data": { "data": [ [18], ["create_expense"] ] } }
create_invoice
Creates one invoices row plus the
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 | unique per issuer_id |
| 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 so it keeps its own database default (193).
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
(constraint please_select_recipient); 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 call
{
"company_id": { "eik": "222222222", "name": "Acme Inc", "vat_number": "BG222222222" },
"issuer_id": { "eik": "111111111", "name": "My Company Ltd" },
"contact_id": { "name": "Ivan", "surname": "Petrov",
"family_name": "Ivanov", "email": "[email protected]" },
"invoice_no": 1000000123,
"currency": "EUR",
"issue_date": "2026-05-14",
"due_date": "2026-06-13",
"month": "2026-05-01",
"payment_method_id": 1,
"status_id": 1,
"category_id": 1,
"our_companies_bank_account_id": {
"iban": "BG80BNBG96611020345678",
"bank": "First Investment Bank",
"bic_code": "FINVBGSF"
},
"tax_option_id": 1,
"vat_rate_id": { "rate": 20 },
"income_category_id": 1,
"invoice_items": [
{
"product_id": { "name": "Widget A" },
"qty": 2, "unit_id": 1, "price_per_unit": 100.00,
"invoices_income_category_id": 1,
"discount": 0,
"line_cost_location_id": 1,
"tax_option_id": 1,
"vat_rate_id": { "rate": 20 }
}
]
}
curl -X POST 'https://<INSTANCE>.obs2go.com/functions/create_invoice?mode=json' \ --data-urlencode 'token=<TOKEN>' \ --data-urlencode "[email protected]" # => { "ExitCode": 0, "Error": "", "Data": { "data": [ [4072], ["create_invoice"] ] } }
Reference values
Integer ids accepted by the plain fields. These are the seed values at the time of writing — query the source tables for the authoritative list.
- 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 (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 …
- 1
- Devices
create_invoice reuses the field-resolution helpers
installed by create_expense
(_expense_resolve_company,
_expense_resolve_currency and the rest). Install the
create_expense SQL first, then create_invoice.
Errors
All of the messages below arrive in the Error field with
ExitCode: 1. The function is transactional, so when any of
these fire nothing is written.
| Message | Cause |
|---|---|
| "data" must be a JSON object | The data param was empty, not valid JSON, or a JSON array / scalar. |
| at least one of "company_id" or "contact_id" is required | create_invoice — neither recipient resolved (constraint please_select_recipient). |
| "issuer_id" (our_companies) is required | create_invoice — issuer_id 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 / object did not match any currency.name. |
| ... reference object must contain "name" | A material / service / product / 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 — e.g. 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). |
| duplicate key value violates unique constraint "invoice_number_already_exists" | The invoice_no already exists for that issuer_id. |