Document Function API

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.

create_expense

Creates or updates one expenses row and replaces its expense_items rows.

create_invoice

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.

§ 02 · Requests

Calling convention

Both functions are reached over HTTP with POST form-encoded parameters. Append ?mode=json to receive a structured JSON response.

Endpoints

POST https://<INSTANCE>.obs2go.com/functions/create_expense?mode=json
POST https://<INSTANCE>.obs2go.com/functions/create_invoice?mode=json

Request parameters

ParamRequiredDescription
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.
Request format

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.

Important

Do not send the document as a direct data=... form field. The OBS2GO function wrapper expects the payload inside params_=[{"data":{"payload":{...}}}].

See also

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 function call
curl -d 'token=<TOKEN>&params_=[{"data":{"payload":{...}}}]' \
  'https://<INSTANCE>.obs2go.com/functions/<FUNCTION_NAME>?mode=json'

Generic create_expense request

curl create_expense
curl -d 'token=<TOKEN>&params_=[{"data":{"payload":{"document_no":"0000001234", ... }}}]' \
  'https://<INSTANCE>.obs2go.com/functions/create_expense?mode=json'

Generic create_invoice request

curl create_invoice
curl -d 'token=<TOKEN>&params_=[{"data":{"payload":{"invoice_no":3840003027, ... }}}]' \
  'https://<INSTANCE>.obs2go.com/functions/create_invoice?mode=json'
§ 03 · Responses

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.

json success
{
  "ExitCode": 0,
  "Error": "",
  "Data": {
    "data": [
      [ "9" ],
      [ "create_invoice" ]
    ]
  }
}
UPSERT behaviour

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.

json error
{
  "ExitCode": 1,
  "Error": "create_invoice: \"issuer_id\" (our_companies) is required",
  "Data": null
}
Note

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.

§ 04 · Shared behaviour

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.

json object shape
{ "eik": "123123123", "name": "Acme Inc", "vat_number": "BG123123123" }
  • When eik is present it is the conflict key. An existing company is reused, and a new one is inserted when no match exists.
  • When eik is absent the resolver falls back to an exact name match, and inserts a new company if none is found.
  • vat_number and abbreviation are 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).

json object shape
{ "name": "Ivan", "surname": "Petrov", "family_name": "Ivanov", "email": "[email protected]" }
Watch out

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.

json accepted forms
"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.

json accepted forms
{ "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.

json object shape
{ "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.

json object shape
{ "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 as our_company_id.
  • name is required by the table. When the object omits it, it inherits the IBAN value.
  • An object without iban raises an error.
§ 05 · Function

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

FieldTypeNotes
paid_by_company_idobjectresolvedour_companies — { eik, name }
recipient_companies_idobjectresolvedcompanies — { eik, name, vat_number }
recipient_contacts_idobjectresolvedcontacts — { name, surname, family_name, email }
currencystring | intresolvedmatched on currency.name
tax_option_idintegerplainsee reference · tax_options
vat_rate_idobject | intresolved{ rate }
vat_purchases_type_idintegerplainsee reference · vat_purchases_types
expense_categories_idintegerplainexpenses_limitations__
document_datedateoptionalYYYY-MM-DD
document_nostringoptionalsupplier document number; used by document_number_already_exists UPSERT logic
document_type_idintegerplainaccounting_document_type
due_datedateoptionalYYYY-MM-DD
purchase_order_idintegeroptionalexisting purchase order id
commentstringoptionalfree text
status_idintegerplainexpense status id
expense_itemsarrayoptionalline items — see below
Update behaviour

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[]

FieldTypeNotes
qtynumericrequiredquantity
price_per_unitnumericrequiredunit price
unit_idintegerdefault 1units table
discountnumericoptional
line_base_amountnumericoptional
line_tax_amountnumericoptional
line_total_amountnumericoptional
line_totalnumericoptional
material_idobject | intresolvedmaterials
service_idobject | intresolvedservices
product_idobject | intresolvedproducts
machinery_idobject | intresolvedmachinery
vat_rate_idobject | intresolved{ rate }
tax_option_idintegerplain
line_expense_categories_idintegerrequiredexpenses_limitations__
line_cost_location_idintegerdefault 1cost_locations
Constraint — chk_expense_items_at_least_one

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

json payload inside params_[0].data.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 create_expense
curl -d 'token=<TOKEN>&params_=[{"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"]]}}
§ 06 · Function

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

FieldTypeNotes
company_idobjectresolvedcompanies — one of company_id / contact_id required
contact_idobjectresolvedcontacts — one of company_id / contact_id required
issuer_idobjectrequiredour_companies — no DB default
our_companies_bank_account_idobjectrequired{ iban, bank, bic_code } — no DB default
invoice_nobigintoptionalused by invoice_number_already_exists UPSERT logic
currencystring | intdefault 2matched on currency.name
issue_datedatedefault todayYYYY-MM-DD
due_datedatedefault +30dYYYY-MM-DD
monthdateoptionalaccounting period, e.g. 2026-05-01
payment_method_idintegerdefault 1payment_methods
status_idintegerdefault 1invoices_statuses
category_idintegerdefault 1invoices_categories
tax_option_idintegerdefault 1tax_options
vat_rate_idobject | intdefault 3{ rate }
income_category_idintegerdefault 1invoices_income_categories
invoice_itemsarrayoptionalline items — see below
Note — template_id

template_id is intentionally not accepted. It is left out of the insert and update logic so it keeps its own database default.

Update behaviour

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[]

FieldTypeNotes
price_per_unitnumericrequiredunit price
qtynumericdefault 1quantity
unit_idintegerdefault 1units table
material_idobject | intresolvedmaterials
product_idobject | intresolvedproducts
service_idobject | intresolvedservices
vat_rate_idobject | intresolved{ rate }
invoices_income_category_idintegerdefault 1invoices_income_categories
tax_option_idintegerplaintax_options
discountnumericoptional
line_cost_location_idintegeroptionalcost_locations
Required by the function

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

json payload inside params_[0].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
    }
  ]
}

Working curl

curl create_invoice
curl -d 'token=<TOKEN>&params_=[{"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"]]}}
§ 07 · Appendix

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.

tax_options
1
Tax Excluded
2
Tax Included
3
Tax Exempt
accounting_document_type
1
Invoice
2
Credit Note
3
Debit Note
4
Proforma
5
Memorial order
6
GOP protocol / VOP
7
Depreciation
currency · by name
1
BGN
2
EUR
3
USD
9
JPY
23
HRK
38
RUB
45
XAU
vat_rates · name = rate
1
0.00
2
9.00
4
10.00
3
20.00
17 / 19 / 21 / 22 / 23 / 25 / 27
-20.00 for credit notes
vat_purchases_types
1
Покупки без ДК
2
Покупки с право на ДК
3
Покупки с частичен ДК
4
Внос с ДК
5
ДО посредник, тристранна
6
Право на ДК от ВОП
7
Право на част. ДК от ВОП
8
Право на ДК от сметка ДДС
9
Покупки с ДДС в ЕС
10
Не се отразява в дневника
11
Протокол самоначисляване
expenses_limitations__
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
cost_locations
1
Devices
Deployment

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.

§ 08 · Appendix

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.

MessageCause
"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_invoiceissuer_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.
Not an error anymore

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.