Document Function API

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.

create_expense

Writes one expenses row and N expense_items rows.

create_invoice

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.

§ 02 · Requests

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

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 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.
Tip — encoding the payload

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.

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 ↗.

Example request

curl create_expense
curl -X POST 'https://<INSTANCE>.obs2go.com/functions/create_expense?mode=json' \
  --data-urlencode 'token=<TOKEN>' \
  --data-urlencode 'data={"document_no":"0000001234", ... }'
§ 03 · Responses

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.

json 200 · success
{
  "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).

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

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.

§ 04 · Shared behaviour

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.

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, a new one is inserted.
  • 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 (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.

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 (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.

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. 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.

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 (lookup-then-insert).

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 (its our_company_id).
  • name is required by the table; when the object omits it, it inherits the IBAN value.
  • An object without iban raises ... must contain "iban".
§ 05 · Function

create_expense

Creates one expenses row plus the 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
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

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). 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

json 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
    }
  ]
}
curl full request
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"] ] } }
§ 06 · Function

create_invoice

Creates one invoices row plus the 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_nobigintoptionalunique per issuer_id
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 so it keeps its own database default (193).

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 (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

json data payload
{
  "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 full request
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"] ] } }
§ 07 · Appendix

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.

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 (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 …
cost_locations
1
Devices
Deployment

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.

§ 08 · Appendix

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.

MessageCause
"data" must be a JSON objectThe data param was empty, not valid JSON, or a JSON array / scalar.
at least one of "company_id" or "contact_id" is requiredcreate_invoice — neither recipient resolved (constraint please_select_recipient).
"issuer_id" (our_companies) is requiredcreate_invoiceissuer_id missing or unresolvable.
"our_companies_bank_account_id" is requiredcreate_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 requiredA 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 constraintA 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.