Перейти к содержимому
Nexoro

Nexoro REST API

v1

Everything you need to connect Nexoro to another system — authentication, error handling, and every endpoint, with examples you can copy.

Базовый URLhttps://<mandant>.nexoro.net/rest/v1

Introduction

The Nexoro REST API v1 gives you the same access to contacts, orders, partners, products and commercial documents the interface itself uses — as JSON, over HTTPS, authenticated with a bearer token.

Every tenant has its own base URL. The subdomain selects the tenant; a key issued for one tenant does not work against another.

Base URL
http
https://<mandant>.nexoro.net/rest/v1
PrincipleОписание
JSONRequests and responses are application/json; charset=utf-8.
snake_caseEvery API field name is English snake_case — regardless of which language the interface runs in. The translation happens at the API boundary, in both directions.
ISO 8601Dates and timestamps are expected as 2026-05-07T14:30:00Z. Other common notations are accepted too, but ISO 8601 is the reliable one.
dataSuccessful responses always sit under data, with lists carrying a pagination object alongside.
Success envelope
json
{
  "data": { "id": 42, "first_name": "Max", "last_name": "Mustermann" }
}
Success envelope of a list
json
{
  "data": [ { "id": 42, "...": "..." } ],
  "pagination": { "limit": 50, "offset": 0, "total": 137 }
}

Authentication

Every request carries an API key in the Authorization header. Keys are created in the admin center under Integrationen → REST-API.

http
Authorization: Bearer nx_<prefix>_<secret>
A complete request
bash
curl -H "Authorization: Bearer nx_xxx_yyy" \
     -H "Content-Type: application/json" \
     "https://your-tenant.nexoro.net/rest/v1/contacts?limit=20"

The secret half of a key is shown only once, at creation. Treat it like a password — server-side only, never in frontend code, never in a repository. A key that has been exposed belongs revoked and reissued in the admin center.

Authentication errors

СтатусКогда
401The Authorization header is missing or malformed, or the token does not belong to a valid key — because it was revoked, or belongs to a different tenant.
403The key is valid but does not carry the scope this endpoint requires. See Scopes & permissions.

Scopes & permissions

Every key carries a set of scopes. Always issue a key with the narrowest set the integration actually needs — a key that only reads has no business holding orders:write.

ScopeОписание
contacts:read / contacts:writeRead / write contacts.
orders:read / orders:writeRead / write orders, including status, communications and attached files.
partners:read / partners:writeRead / write partners.
catalog:readThe status and task catalogs, plus products, categories and price lists.
documents:read / documents:writeCommercial documents (offer, invoice, …) read / write, including confirm and PDF rendering.
documents:sendSend a commercial document to a customer.

documents:send is deliberately not implied by documents:write. It is the only action that leaves the building and reaches a customer — so a key allowed to create documents is not automatically allowed to send them.

When the required scope is missing, the API answers 403 and names exactly what was missing — rather than quietly returning less:

json
{
  "type": "about:blank",
  "title": "Forbidden",
  "status": 403,
  "detail": "Dieser API-Key hat den Scope \"documents:write\" nicht.",
  "required_scope": "documents:write",
  "granted_scopes": ["orders:read", "contacts:read"]
}

Acting as a user

A key can optionally be bound to a CRM user. Writes then run with that user's rights and appear in the audit log under their name (rest:<username>) instead of the key label.

If that user is later deactivated, the key stops working and returns 403. That is deliberate: a departing employee's integration token must not outlive their account. Keys without a bound user behave as before — there, the scope alone decides.

Errors

Errors come back as RFC 7807 problem details, with the content type application/problem+json.

json
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Contact 42 not found"
}

Some errors carry extra fields naming exactly what was missing — for example required_scope and granted_scopes on a 403. Always branch on the status code, and use detail for logs rather than for program logic.

СтатусКогда
200Success, with a body.
201Created. The Location header points at the new resource.
204Success, no body.
400A parameter was unusable, typically an unparseable date.
401Not authenticated — see Authentication.
403Authenticated, but the required scope is missing — see Scopes & permissions.
404The resource does not exist, or is soft-deleted.
405The HTTP method is not defined for this path.
409Conflict with the current state — a document already sent, a delete blocked by a relationship, or a request with the same idempotency key still in flight.
422The body was syntactically fine but failed validation.
500Unexpected server error. Retry with exponential backoff.

Request volume

Throttle client-side to a rate that matches your use case rather than polling endpoints in a tight loop. For recurring syncs use updated_since and fetch only what changed since the last run. Retry 5xx responses with exponential backoff and a ceiling, not immediately.

Pagination

Every list endpoint paginates the same way: limit and offset in, a pagination object out.

ИмяТипПо умолчаниюОписание
limitint50Page size. Capped at 200 on most endpoints, at 500 on the communications timeline.
offsetint0Number of entries to skip.
json
"pagination": { "limit": 50, "offset": 0, "total": 137 }

total is the overall number of matches for the filters you asked for, not the length of the current page. As long as offset + limit is less than total, there is another page.

Idempotency

Send an Idempotency-Key header on POST, PUT or PATCH to make a retry safe.

This matters most for anything that consumes a number range: without it, a retried POST /v1/commercial_documents creates a second document and burns a number.

CaseОписание
Erster AufrufProcessed normally. The response is stored for 30 days.
Wiederholung, gleicher BodyThe stored response comes back verbatim, with an Idempotent-Replay: true header. Nothing is created twice.
Wiederholung, während der erste Aufruf noch läuft409 Request In Progress. Retry later — a first call that never finished is released after five minutes.
Gleicher Key, anderer Body422 Idempotency-Key Mismatch. Keys are scoped per API key, so two integrations cannot collide.
Serverfehler (5xx)Not stored. So a retry after a crash really does retry.

Keys are free-form strings up to 255 characters. A UUID per logical operation is the usual choice.

bash
curl -X POST \
  -H "Authorization: Bearer nx_xxx_yyy" \
  -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
  -H "Content-Type: application/json" \
  -d '{"...":"..."}' \
  "https://your-tenant.nexoro.net/rest/v1/orders"

Contacts

A contact is the master record of a person or a company — long-lived, and the source of truth for who someone is.

GET/v1/contactscontacts:read

List contacts

Returns a paginated list of contacts.

Query parameters

ИмяТипПо умолчаниюОписание
limitint50Page size, capped at 200.
offsetint0Pagination offset.
emailstring—Exact-match filter.
companystring—Exact-match filter on the company name.
updated_sinceISO date—Only contacts whose updated_at is greater than or equal to this timestamp.
include_inactive1 / 00Also returns soft-deleted contacts (active = 0).
rolestring—Only contacts carrying this role, by name, case-insensitive. See Contact roles.
Response — 200
json
{
  "data": [
    {
      "id": 42,
      "first_name": "Max",
      "last_name": "Mustermann",
      "email": "max@example.com",
      "phone": "+43 1 12345678",
      "mobile": null,
      "company": "Acme GmbH",
      "salutation": "Herr",
      "title": null,
      "position": "Geschäftsführung",
      "street": "Hauptstraße 1",
      "postal_code": "1010",
      "city": "Wien",
      "country": "AT",
      "website": "https://acme.example",
      "vat_id": "ATU12345678",
      "note": null,
      "partner_id": 79,
      "active": 1,
      "roles": [
        { "id": 3, "name": "Bauleiter", "color": "" }
      ],
      "created_at": "2026-05-01 10:00:00",
      "updated_at": "2026-05-07 14:00:00"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 137 }
}
Example
bash
curl -H "Authorization: Bearer nx_xxx_yyy" \
  "https://your-tenant.nexoro.net/rest/v1/contacts?limit=20&company=Acme%20GmbH"
СтатусКогда
400updated_since could not be parsed as a date.

Contact roles

A contact can carry any number of roles — the function this person has in your processes: site manager, foreman, structural engineer, purchasing. That is distinct from position, the single free-text job title inside their own company.

  • Roles hang on the contact, not on the order. A contact keeps its roles across every order it appears in.
  • The catalogue is per tenant and fills itself. Posting a role name that does not exist yet creates it — there is no separate call to create a role, and therefore no ordering dependency between two requests.

Roles are returned on every contact representation (list, single, create, update) and on the contacts embedded in an order — always in the same shape:

json
"roles": [ { "id": 3, "name": "Bauleiter", "color": "" } ]

To write them, send roles to POST or PATCH /v1/contacts. The list replaces the contact's roles entirely:

json
{ "roles": ["Bauleiter", "Polier"] }

Objects are accepted too, so you can send back what you read: {"roles": [{"name": "Bauleiter"}]}. An empty array removes all roles; omitting the field leaves them untouched. Names are capped at 80 characters and matched case-insensitively — polier and Polier are the same role.

GET/v1/contact_rolescontacts:read

List the role catalogue

Every role name this tenant uses, so a client does not have to guess them. Inactive roles are not listed.

Response — 200
json
{
  "data": [
    { "id": 1, "name": "Auftraggeber", "color": "", "aktiv": true },
    { "id": 3, "name": "Bauleiter",    "color": "", "aktiv": true }
  ]
}
Example
bash
curl -H "Authorization: Bearer nx_xxx_yyy" \
  "https://your-tenant.nexoro.net/rest/v1/contact_roles"
GET/v1/contacts/{id}contacts:read

Fetch a single contact

Returns the contact together with its endpoints — additional email addresses, phone numbers and other channels.

Response — 200
json
{
  "data": {
    "id": 42,
    "first_name": "Max",
    "last_name": "Mustermann",
    "email": "max@example.com",
    "...": "...",
    "endpoints": {
      "email": [
        { "id": 9, "subtype": "primary", "value": "max@example.com" }
      ],
      "phone": [
        { "id": 10, "subtype": null, "value": "+43 1 12345678" }
      ],
      "mobil": [
        { "id": 11, "subtype": null, "value": "+43 660 1234567" }
      ]
    }
  }
}

endpoints is an object keyed by channel type (email, phone, mobil, xmpp, …). Each value is an array, because a contact can have multiple endpoints of the same type. Types with zero entries are omitted from the object entirely.

СтатусКогда
404The contact does not exist.
POST/v1/contactscontacts:write

Create a contact

At least one of email, phone or mobile must be present.

Writable fields

  • first_name
  • last_name
  • email
  • phone
  • mobile
  • company
  • salutation
  • title
  • position
  • street
  • door
  • postal_code
  • city
  • country
  • note
  • website
  • vat_id
  • birthday
  • group_id
  • lat
  • lng
  • roles
  • endpoints

source is read-only. It records the contact's integration origin (graph, google, reonic, imap) and is set internally — it is not a marketing-attribution field. For order or lead attribution use source on POST/PATCH /v1/orders instead.

email, phone and mobile on the contact are the primary values, denormalized for convenience. The full set of channels — multiple emails, phones, mobiles — lives in the endpoints object, keyed by channel type.

Optionally include endpoints as an array of { "type": "email|phone|mobil|...", "subtype": "primary|...", "value": "..." }. The type values are free-form domain strings; legacy data uses mobil for mobile numbers — write new entries using whatever value matches your existing records.

Request
json
{
  "first_name": "Max",
  "last_name": "Mustermann",
  "email": "max@example.com",
  "company": "Acme GmbH",
  "endpoints": [
    { "type": "phone", "value": "+43 1 12345678" }
  ]
}

Response — 201 with the full created contact including endpoints, plus a Location: /rest/v1/contacts/{id} header.

СтатусКогда
422None of email, phone or mobile was present, or the body carried no writable field.
PATCH/v1/contacts/{id}contacts:write

Update a contact

Partial update: only fields present in the body are changed. updated_at advances automatically.

Request
json
{ "company": "Acme Holding GmbH", "city": "Wien" }

Response — 200 with the full updated contact including endpoints.

СтатусКогда
404The contact does not exist or is soft-deleted (active = 0).
422No writable field in the body. roles counts as one, so a patch that only sets roles is accepted.
DELETE/v1/contacts/{id}contacts:write

Delete a contact

The default behavior is a soft delete: active is set to 0. The record remains and disappears from the default list — it is still visible with ?include_inactive=1.

With ?hard=1 (or ?hard=true) the contact is removed permanently instead: the contact itself, its endpoints, its links to orders, its change history and its role assignments. This is irreversible. The role catalogue itself is untouched — a role outlives the last contact carrying it.

Response — 204, no body.

СтатусКогда
404The contact does not exist.
409The hard delete is blocked by an existing relationship in the database.

Orders

An order is the central work unit in the CRM. It can represent a data-recovery case, a photovoltaic project or a construction project — the same structure, shaped differently per tenant.

Contacts and customers are two things

The CRM keeps two parallel records for people and companies, and the API surfaces both. They serve different roles.

ПолеОписание
contactsThe master record. Long-lived, the source of truth for who someone is. Managed through /v1/contacts.
customersAn order-time billing snapshot of the contact. Deliberately decoupled from the master record so historical invoices stay stable when contact data later changes. The order endpoints manage these snapshots automatically; there is intentionally no separate CRUD surface for them.

Every order has its own snapshot. Creating an order creates a fresh one; updating an order's customer block updates only that order's snapshot and never the master contact.

The response model

Most fields on an order are tenant- and industry-specific. The API hides them behind a stable, generic envelope:

FieldОписание
id, order_number, title, active, created_at, updated_at, received_atIdentifiers and timestamps.
current_status{ status_id, status_name, at, user } — the most recent active entry of the status history.
customerThe order's billing snapshot: name, company, address, VAT id, discount, language. Editable per order through the customer block on PATCH.
siteThe execution address — the place of performance, separate from the billing address in customer.
contactsEvery contact person linked to this order's customer.
partner_id, source, responsible, websiteScalar fields that mean the same thing across every tenant.
current_status_id, current_status_at, current_task_id, current_task_atDirectly readable pointers to the current status and current task, kept consistent by the API.
sectionsThe tenant-defined custom fields, with this order's values overlaid.

contacts is built like this: it starts from every contact person linked to the order's customer — each carrying its link_type, often "primary". If the order names a primary contact who is not in that list yet, they are added. That contact is marked is_primary: true and sorted first; the rest follow by ascending id. Each entry carries its roles, read-only here — roles belong to the person, not to this one order, and are written through /v1/contacts. If the order has neither a customer nor a primary contact, contacts is empty.

The site block — the execution address

Where the work happens, as opposed to where the invoice goes. The address prints on offers, order confirmations, invoices, credit notes, delivery notes and service reports. The block is always present on an order.

json
"site": {
  "same_as_billing": false,
  "company": "Baustelle Nord",
  "contact_person": "Herr Huber",
  "street": "Baugasse 1",
  "postal_code": "5020",
  "city": "Salzburg",
  "email": null,
  "phone": "+43 662 1234"
}

same_as_billing: true means the order has no separate execution address and the address in customer applies. That is the default for every order.

To write it, send a site object to POST or PATCH /v1/orders. Two rules matter here:

  • Writing any non-empty address field automatically sets same_as_billing to false. Without that the address would be stored and then ignored everywhere — the CRM and the document generators skip the execution address unless the order is explicitly flagged as having one. So you do not need to send the flag yourself.
  • {"site": {"same_as_billing": true}} clears the address again and returns the order to using the billing address.
json
{ "site": { "street": "Baugasse 1", "postal_code": "5020", "city": "Salzburg" } }

Fields you omit stay untouched; sending an empty string clears a single field. Unknown keys inside site are rejected with 422 rather than silently dropped.

The address only appears on generated PDFs if the tenant has Durchführungsadresse anzeigen switched on in the document settings. That is a CRM setting, not something the API controls.

Tenant-specific fields

The fields defined per tenant live in sections[]. Each section has an ordered list of items shaped { id, name, type, value, options? }. The types are text, textarea, select, checkbox and termin. value is this order's entry and is null until something is written; options is only present on select.

To write them, send a custom_fields object on POST or PATCH. The keys are item ids, which you find under sections[*].items[*].id. null clears a field, and unknown ids are ignored.

json
{ "custom_fields": { "66": "ABC-123", "55": "30 Tage" } }

Repeatable sections

A section can be marked repeatable in the admin center. It then holds a list of entries per order instead of one set of values — three forklifts, ten laptops, several metering points.

json
{
  "id": 12, "heading": "Hardware", "is_repeatable": true,
  "entry_label": { "singular": "Gerät", "plural": "Geräte" },
  "entries": [
    { "id": 7, "sort_order": 0, "values": { "34": "ThinkPad T14", "35": "SN-001" } },
    { "id": 8, "sort_order": 1, "values": { "34": "MacBook Air",  "35": "SN-002" } }
  ],
  "items": [ { "id": 34, "name": "Modell", "type": "text", "value": "ThinkPad T14" } ]
}

items[].value keeps the value of the oldest entry, so a client that only reads the flat shape keeps working. Sections that are not repeatable are unchanged and carry no entries.

Writing with custom_field_entries
json
{ "custom_field_entries": [
    { "section_id": 12, "entries": [
        { "values": { "34": "ThinkPad T14", "35": "SN-001" } },
        { "values": { "34": "MacBook Air",  "35": "SN-002" } } ] } ] }

Per section this replaces rather than appends: the list you send becomes that section's entries for this order, in exactly that order. Entry ids are assigned by the server; you do not send them. custom_fields cannot address a repeatable section — an item id belonging to one returns 422, naming the ids.

422 is also returned for an unknown section_id, a section that is not repeatable, an item id that does not belong to the named section, a malformed entry, or more than 200 entries in one section. Validation runs to completion before anything is replaced, so a rejected request leaves the existing entries intact.

The status model

Every order has a current status. Status changes are append-only, never overwritten — the history stays complete, and the API keeps the directly readable pointers on the order in sync automatically.

A status change also writes the matching task entry so the CRM's task pipeline stays consistent. This mirrors exactly what the status button in the interface does.

GET/v1/ordersorders:read

List orders

Query parameters

ИмяТипПо умолчаниюОписание
limitint50Page size, capped at 200.
offsetint0Pagination offset.
customer_idint—Exact-match filter.
customer_emailstring—Exact match on the email address of the billing snapshot.
contact_emailstring—Exact match on the email address of any linked contact. Use this to find all orders belonging to a person across individual snapshots.
partner_idint—Exact-match filter.
order_numberstring—Exact-match filter.
status_idint—Only orders whose current status equals this id.
updated_sinceISO date—Only orders whose updated_at is greater than or equal to this timestamp.
include_inactive1 / 00Also returns soft-deleted orders.
includecsv—Comma-separated extras to embed. Currently: sections.

The list does not include the per-order sections block by default — that would be N extra rows per order. Ask for it with ?include=sections when you need it.

Response — 200
json
{
  "data": [
    {
      "id": 7421,
      "order_number": "EXT-7421",
      "title": "Neubau Halle 3",
      "active": 1,
      "partner_id": null,
      "source": "API",
      "responsible": "rest-api",
      "website": null,
      "received_at": "2026-05-07 14:00:00",
      "created_at": "2026-05-07 14:00:00",
      "updated_at": "2026-05-07 14:00:00",
      "current_status_id": 1,
      "current_status_at": "2026-05-07 14:00:00",
      "current_task_id": 1,
      "current_task_at": "2026-05-07 14:00:00",
      "current_status": {
        "status_id": 1,
        "status_name": "Eingegangen",
        "at": "2026-05-07 14:00:00",
        "user": "rest-api"
      },
      "customer": {
        "id": 4612, "customer_number": "EXT-4612",
        "first_name": null, "last_name": null, "company": "Acme GmbH",
        "email": "office@acme.example", "phone": "+43 1 12345678",
        "street": "Hauptstraße 1", "postal_code": "1010",
        "city": "Wien", "country": "AT",
        "vat_id": "ATU12345678", "vat_exempt": 0, "language": "de",
        "partner_id": 79,
        "created_at": "2026-05-07 14:00:00",
        "updated_at": "2026-05-07 14:00:00"
      },
      "contacts": [
        {
          "id": 11034, "company": null,
          "first_name": "Anna", "last_name": "Müller",
          "email": "anna.mueller@acme.example",
          "position": "Geschäftsführung", "partner_id": 79,
          "is_primary": true, "link_type": "primary"
        },
        {
          "id": 11202, "company": null,
          "first_name": "Bernd", "last_name": "Schmid",
          "email": "b.schmid@acme.example",
          "position": "Bauleitung", "partner_id": 79,
          "is_primary": false, "link_type": "primary"
        }
      ]
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 1 }
}
GET/v1/orders/{id}orders:read

Fetch a single order

Always includes the tenant's sections array, with this order's values overlaid.

Response — 200
json
{
  "data": {
    "id": 7421,
    "order_number": "EXT-7421",
    "title": "Neubau Halle 3",
    "...": "...",
    "current_status": {
      "status_id": 1, "status_name": "Eingegangen",
      "at": "...", "user": "rest-api"
    },
    "customer": { "id": 4012, "company": "Acme GmbH", "...": "..." },
    "contacts": [
      { "id": 11034, "first_name": "Anna", "last_name": "Müller",
        "is_primary": true,  "link_type": "primary", "...": "..." },
      { "id": 11202, "first_name": "Bernd", "last_name": "Schmid",
        "is_primary": false, "link_type": "primary", "...": "..." }
    ],
    "sections": [
      {
        "id": 8,
        "heading": "Lead Details",
        "order": 1,
        "items": [
          { "id": 66, "name": "Lead Nummer bei Leadportal",
            "type": "text", "order": 1, "value": "L-9912" },
          { "id": 55, "name": "Alter des Leads im Portal",
            "type": "select", "order": 2, "value": "30 Tage",
            "options": ["1 Tag", "7 Tage", "30 Tage"] },
          { "id": 58, "name": "Informationen Portal",
            "type": "textarea", "order": 6, "value": null }
        ]
      }
    ]
  }
}
POST/v1/ordersorders:write

Create an order

Creating an order always runs the same flow:

  • Resolve a contact — find or create. If primary_contact_id is supplied it is used as is. Otherwise an active contact with the same email address as customer.email is reused. Otherwise a new contact is created from the customer data, provided at least one of email, phone or mobile is present. Otherwise no contact is linked — the rare case of a pure address block.
  • Materialize the billing snapshot. Name, company, address, email, phone and language are copied from the resolved contact first; an inline customer block then overrides that copy.
  • Link the contact to the customer and set the resolved contact as the order's primary contact.
  • Insert the order pointing at the snapshot. With no number supplied, order_number is set to EXT-<id>.
  • Write the initial status and task entries.
  • Persist any custom_fields that were sent.

You must supply at least one of primary_contact_id or customer — otherwise the snapshot would have nothing to record, and the call is rejected with 422.

Writable fields on the order

  • title
  • order_number
  • partner_id
  • primary_contact_id
  • inquiry_contact_id
  • company
  • website
  • source
  • responsible
  • received_at

customer_id is not writable directly — the server sets it when it creates the snapshot.

Writable fields in the customer block

  • first_name
  • last_name
  • email
  • phone
  • mobile
  • company
  • salutation
  • title
  • street
  • door
  • postal_code
  • city
  • country
  • vat_id
  • website
  • discount
  • discount_code
  • send_sms
  • send_email
  • vat_exempt
  • language
  • note
  • partner_id

Writable fields in the site block

  • company
  • contact_person
  • street
  • postal_code
  • city
  • email
  • phone
  • same_as_billing
Request — minimum, snapshot from an existing contact
json
{ "primary_contact_id": 11034 }
Request — minimum, customer fields inline
json
{
  "customer": {
    "first_name": "Max",
    "last_name": "Mustermann",
    "email": "max@example.com"
  }
}
Request — full
json
{
  "title": "Neubau Halle 3",
  "primary_contact_id": 11034,
  "customer": {
    "company": "Acme Holding GmbH",
    "vat_id": "ATU12345678",
    "street": "Hauptstraße 1",
    "postal_code": "1010",
    "city": "Wien",
    "vat_exempt": 1,
    "language": "de"
  },
  "site": {
    "company": "Baustelle Halle 3",
    "contact_person": "Herr Huber",
    "street": "Baugasse 1",
    "postal_code": "5020",
    "city": "Salzburg",
    "phone": "+43 662 1234"
  },
  "source": "API",
  "custom_fields": {
    "66": "L-9912",
    "55": "30 Tage"
  }
}

Response — 201 with the created order including the full customer block, contacts, current_status and sections, plus a Location: /rest/v1/orders/{id} header.

PATCH/v1/orders/{id}orders:write

Update an order

Partial update, in any combination: core fields such as title, responsible or source on the order itself, custom_fields for tenant-specific values, customer for this order's billing snapshot, and site for the execution address. Every change is recorded in the order's change history.

json
{
  "title": "Halle 3 — Erweiterung",
  "custom_fields": { "66": "L-9912-B" },
  "customer": {
    "company": "Acme Holding GmbH (umbenannt)",
    "vat_exempt": 1
  },
  "site": { "street": "Baugasse 1a" }
}
  • Clearing a custom field: send null as the value — the underlying entry is removed.
  • Updating billing data: only the fields you supply in customer are written, the rest of the snapshot is untouched. The linked master contact does not change in any way — that is exactly what the separate snapshot is for.
СтатусКогда
404The order does not exist or is soft-deleted.
422custom_fields or customer is not an object, custom_field_entries is not an array or was rejected, or there was nothing to update. A body carrying only custom_field_entries counts as an update.
DELETE/v1/orders/{id}orders:write

Delete an order

The default behavior is a soft delete: the order is set inactive, and the status and task history are preserved.

With ?hard=1 (or ?hard=true) the order is removed permanently, along with its status, task and change history; the tenant-specific field values go with it. This is irreversible.

СтатусКогда
404The order does not exist.
409The hard delete is blocked by an existing relationship in the database.
POST/v1/orders/{id}/statusorders:write

Change status

Appends a new entry to the status history, writes the matching task entry and updates the directly readable pointers on the order.

Request
json
{ "status_id": 4 }

Response — 200 with the order and its new current_status.

If no task in the catalog maps to this status, the task entry is still written — with task 0, exactly how the CRM itself behaves in that case.

СтатусКогда
404The order does not exist.
422status_id is missing or unknown.
GET/v1/orders/{id}/status_logorders:read

Full status history

Returns the last 200 entries, newest first, each with the name of the status.

Response — 200
json
{
  "data": [
    { "id": 99012, "status_id": 4, "status_name": "Analyse",
      "workflow": 1, "user": "rest-api",
      "at": "2026-05-07 15:30:00", "inactive": 0 },
    { "id": 99011, "status_id": 1, "status_name": "Eingegangen",
      "workflow": 1, "user": "rest-api",
      "at": "2026-05-07 14:00:00", "inactive": 0 }
  ]
}
GET/v1/orders/{id}/communicationsorders:read

Communications timeline

Returns every communication attached to this order across all channels — emails, WhatsApp messages and incoming and outgoing calls — merged into one chronological feed, newest first.

Every entry is normalized into the same envelope, no matter which channel it came from:

json
{
  "type": "email | whatsapp | call",
  "source": "<Quellbezeichner des Kanals>",
  "direction": "inbound | outbound",
  "at": "2026-05-07 14:30:00",
  "id": 12345,
  "subject": "...",
  "preview": "Kurzer Textauszug, HTML entfernt, max. ~240 Zeichen",
  "from": "Name <adresse>",
  "to":   "Name <adresse>",
  "user": "CRM-Benutzer, der es bearbeitet hat, falls vorhanden",
  "meta": { "...kanalspezifische Zusätze..." }
}

Pagination is applied after the merge across all channels — limit and offset therefore operate on the unified timeline, not on any single channel.

Query parameters

ИмяТипПо умолчаниюОписание
typecsvalleLimit the channels. Any subset of email, whatsapp, call — e.g. type=email,call.
directionstringbeideinbound or outbound.
sinceISO date—Only entries at or after this timestamp.
limitint50Page size, capped at 500.
offsetint0Offset within the merged timeline.

meta per channel

ChannelОписание
emailbody, html, folder, kontoid, contact_id, unread, answered, forwarded, read_date, plus attachments when the message was sent through the CRM.
whatsappmessage, whatsapp_number_id, endpoint_id, plus unread on inbound messages.
callduration_seconds, ring_seconds, caller_type, answer_time, hangup_time, notes, contact_id, partner_id, herkunft, plus caller_email or callee_email.
СтатусКогда
404The order does not exist.
400since could not be parsed as a date.
GET/v1/orders/{id}/documentsorders:read

List the order's files

Not to be confused with commercial documents. This is about the files in the order's folder; the documents themselves, with positions and totals, live under /v1/commercial_documents.

An order's files live in a directory tree whose root is resolved per order. The whole tree is flattened, one entry per file. Read-only — no upload, no delete.

A file has no numeric id. Its stable identifier is its path relative to the order root, returned both readably as path and as an opaque token id used in the download URL.

Response — 200
json
{
  "data": [
    {
      "id": "UmVjaG51bmdlbi8yMDI2L1JFLTEucGRm",
      "filename": "RE-1.pdf",
      "path": "Rechnungen/2026/RE-1.pdf",
      "mime_type": "application/pdf",
      "size": 24831,
      "created_at": "2026-06-09T11:42:13+02:00",
      "download_url": "https://<mandant>.nexoro.net/rest/v1/orders/7421/documents/UmVjaG51bmdlbi8yMDI2L1JFLTEucGRm"
    }
  ]
}
FieldОписание
idThe path's token. Pass it to the download endpoint.
filenameThe file's basename.
pathPath relative to the order root, with folders preserved.
mime_typeDetected from the file content, falling back to application/octet-stream.
sizeSize in bytes.
created_atISO 8601 of the file's modification time.
download_urlAbsolute URL of the download endpoint for this file.

An order with no storage or no files returns { "data": [] }.

СтатусКогда
404The order does not exist.
GET/v1/orders/{id}/documents/{docId}orders:read

Download one file

Streams the file itself, not a JSON envelope. docId is the id token from the list. Content-Type is the detected MIME type, Content-Disposition is attachment with the filename, Content-Length is set, and X-Content-Type-Options: nosniff is sent.

The file is always resolved inside that order's own root. A forged token returns 404 — never a file from another order or another tenant.

СтатусКогда
404The order does not exist, has no storage, or the file is not inside its root.

Embedding files in the order

GET /v1/orders/{id}?include=documents returns the same array under a documents key on the order object. Deliberately opt-in, so an ordinary order read stays lean.

json
{
  "data": {
    "id": 7421,
    "order_number": "EXT-7421",
    "...": "...",
    "documents": [
      { "id": "UmVjaG51bmdlbi8yMDI2L1JFLTEucGRm",
        "filename": "RE-1.pdf", "...": "..." }
    ]
  }
}

Catalogs

The tenant-defined catalogs the order workflow uses. External integrations typically fetch these once at startup and work with the ids from then on. Read-only, scope catalog:read.

GET/v1/staticatalog:read

List status definitions

The tenant's status catalog, as used by orders.

Query parameters

ИмяТипПо умолчаниюОписание
aktiv1 / 01Filter by the active flag. aktiv=0 shows archived statuses.
ablaufint—Filter by workflow: 1 is the primary one, 2 the secondary.
Response — 200
json
{
  "data": [
    {
      "id": 1,
      "name": "Eingegangen",
      "name_partner": "Eingegangen",
      "workflow": 1,
      "sort_order": 1,
      "group": 0,
      "next_step": "Lead prüfen",
      "color": "#9ca3af",
      "priority": 5,
      "days": 0,
      "hours": 0,
      "manual": 1,
      "main_responsible": 1,
      "active": 1
    }
  ]
}
GET/v1/taskscatalog:read

List task definitions

The tenant's task catalog. Each task is linked to a follow-up status through next_status_id — that is how POST /v1/orders/{id}/status resolves which task entry to write.

Query parameters

ИмяТипПо умолчаниюОписание
aktiv1 / 01Filter by the active flag.
ablaufint—Filter by workflow: 1 or 2.
status_idint—Only tasks whose next_status_id equals this id.
Response — 200
json
{
  "data": [
    {
      "id": 1,
      "action": "Lead prüfen",
      "sort_order": 1,
      "workflow": 1,
      "next_status_id": 2,
      "help": "Vor dem Erstkontakt prüfen",
      "requires_upload": 0,
      "active": 1
    }
  ]
}

Products & prices

The product catalog, its categories and the price lists. Read-only, scope catalog:read.

GET/v1/productscatalog:read

List and search products

Query parameters

ИмяТипПо умолчаниюОписание
querystring—Alias q. Matches name, SKU, manufacturer, manufacturer number, GTIN, DATANORM article number and description.
category_idint—Exact category.
manufacturerstring—Exact match.
gtinstring—Exact match.
skustring—Exact match.
favorites_only1 / 00Only products flagged as favourites.
updated_sinceISO date—Only products whose updated_at is greater than or equal to this timestamp.
limitint50Page size, capped at 200.
offsetint0Pagination offset.

Ordering is favourites first, then name — the same order the product catalog shows in the interface.

Response — 200
json
{
  "data": [
    {
      "id": 11,
      "sku": "ART001",
      "name": "Ersteinbindung ERP Phase 1",
      "description": null,
      "manufacturer": null,
      "manufacturer_number": null,
      "gtin": null,
      "category_id": 2,
      "category_name": "Leistungen",
      "unit": "h",
      "selling_price": 85.0,
      "stock_quantity": null,
      "delivery_time_days": null,
      "is_favorite": true,
      "assembly_minutes": null,
      "created_at": "2026-03-02 09:12:00",
      "updated_at": "2026-06-18 11:04:00"
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 27 }
}
СтатусКогда
400updated_since could not be parsed as a date.
GET/v1/products/{id}catalog:read

Fetch a single product

The same fields as the list, plus attributes and technical_specs as decoded JSON — both null when empty or unparseable. 404 if the product is unknown.

GET/v1/products/{id}/pricecatalog:read

Resolve the selling price

The price a customer actually gets. The price list is resolved in this order: an explicitly supplied price_list_id, then the contact's list, then the order's list, then the default list. If the product is not in the winning list, the selling price on the product itself applies.

Query parameters

ИмяТипПо умолчаниюОписание
order_idint—Resolve in the context of this order, i.e. through its customer's list.
contact_idint—Resolve for this customer directly.
price_list_idint—Force a specific list. Ignored if it is inactive or unknown.
quantityint1Quantity, for tiered prices.
Response — 200
json
{
  "data": {
    "product_id": 11,
    "price_net": 85.0,
    "currency": "EUR",
    "source": "product",
    "price_list": {
      "id": 3, "name": "Großhandel", "currency": "EUR",
      "vat_rate": 20.0, "is_default": false
    },
    "quantity": 1
  }
}

source says where the number came from: price_list when a list matched, product when it fell back to the price on the product, and none when there is no price at all. Always show or log it — a wrong price on an offer is otherwise impossible to explain without a debugger. price_list is null on tenants with no price lists.

GET/v1/product_categoriescatalog:read

List categories

A flat list, parents before children, with id, parent_id, name, description, external_code and product_count.

GET/v1/price_listscatalog:read

List active price lists

Per list: id, name, currency, vat_rate and is_default. On tenants without price lists the answer is { "data": [] } — a valid state, not an error.

Commercial documents

Offers, order confirmations, invoices, delivery notes and service reports — with positions and totals.

Not the same as /v1/orders/{id}/documents. Those are the files in an order's folder; these are the documents themselves.

{type} is one of offer, order_confirmation, invoice, delivery_note or service_report. The German names angebot, auftragsbestaetigung, rechnung, lieferschein and servicebericht are accepted too. The type is part of the key, not decoration: ids are only unique per type.

Recurring contracts are not available here and are rejected with 422. They carry their own interval and billing configuration and are managed in the interface. Also deliberately not available through the API: position groups, markup calculation, partial and final invoices with deductions, dunning, and text templates. Those are interface features — the API does not silently ignore the fields, it does not offer them.

The draft rule

A new document is a draft: status is entwurf, number is null. The number is drawn from the tenant's number range only on confirm. This is deliberate — a discarded draft must not leave a gap in a sequential invoice number.

Send an Idempotency-Key on create and on confirm. A retried create otherwise means a second document, and a retried confirm means a burnt number.

GET/v1/commercial_documentsdocuments:read

List documents

Query parameters: order_id, type, status, include_inactive, limit (capped at 200) and offset. Without type, all five types are merged, newest first.

Fields per entry

  • id
  • type
  • order_id
  • number
  • status
  • is_draft
  • document_title
  • net
  • gross
  • currency
  • sent
  • created_by
  • created_at
  • updated_at
POST/v1/commercial_documentsdocuments:write

Create a document

Fields

ИмяТипПо умолчаниюОписание
typeОбязательноstring—See above.
order_idОбязательноint—Must be an active order.
positions[]Обязательноarray—At least one position, see below.
confirmboolfalsetrue draws the number immediately. The default is a draft.
datedateheuteDocument date.
valid_untildate—Default: the document date plus the tenant's configured validity period, usually 14 days.
document_titlestring—Optional per-document title.
currencystring—Default: the currency of the company behind the order.
tax_ratenumber—Default: the company's tax rate, usually 20.
tax_regulationstring—One of mwst_ausweisen, kleinunternehmer, reverse_charge, innergemeinschaftlich, nicht_steuerbar. Default: the customer's setting.
price_list_idint—Forces a price list when resolving position prices.
text_above / text_belowstring—Free text above and below the positions.
letterheadstring—Address block. Not available for invoice.
work_descriptionstring—service_report only.
source_document_idint—The originating document, e.g. the offer behind an order confirmation.

Positions

ИмяТипПо умолчаниюОписание
product_idint—Optional. If set, name, unit, description and the price are filled from the catalog.
namestring—Required unless product_id is given.
descriptionstring—Long text under the position.
quantitynumber1Must be greater than 0.
unitstringSTOr the product's unit.
unit_pricenumber—Net unit price. If omitted it is resolved via the price list, which requires a product_id.
tax_ratenumber—Per-position tax rate. Defaults to the document's.
is_optionalbool—Shown on the document but not counted in the totals.
quantity_plannednumber—service_report only: the planned quantity.

A negative unit_price is how a discount is expressed.

?dry_run=1 returns the fully validated and calculated result under data.preview and writes nothing. It uses the same arithmetic the real create uses — exactly what a confirmation screen should render.

Response — 201 with the document as a GET would return it: including positions, totals with net, gross and by_rate, and the customer snapshot.

СтатусКогда
422Any validation problem: unknown type, inactive order, empty positions, unknown product, quantity less than or equal to 0, or a position without a resolvable price.
GET/v1/commercial_documents/{type}/{id}documents:read

Fetch a single document

The full document: head data, the customer snapshot, positions without the summary rows, and totals read back from the stored summary rows.

PATCH/v1/commercial_documents/{type}/{id}documents:write

Update a document

Accepts the same fields as create, except type and order_id.

positions replaces the whole list — there is no per-row merge. Totals and summary rows are recalculated.

СтатусКогда
409The document was already sent, or it is a confirmed delivery note — that documents a handover and is no longer edited.
DELETE/v1/commercial_documents/{type}/{id}documents:write

Delete a document

A soft delete, exactly as in the interface. 204 on success, 404 if the document is unknown or already inactive. The positions are kept.

POST/v1/commercial_documents/{type}/{id}/confirmdocuments:write

Confirm a document

Turns the draft into a document with a number and a final status, and returns it.

This is the only call that consumes a number. 409 if the document already has a number or is not a draft.

POST/v1/commercial_documents/{type}/{id}/pdfdocuments:write

Render the PDF

Renders the PDF with the tenant's own template and saves it into the order's file folder. Returns { path, filename, size }.

?inline=1 streams the PDF back as application/pdf and saves nothing. Use it for previews, so an unconfirmed document never lands in a customer folder.

One technical limit is worth knowing: only one PDF per request. Render a second one as a separate request.

СтатусКогда
409The order has no file folder — none is created on the fly.
POST/v1/commercial_documents/{type}/{id}/senddocuments:send

Send a document

This call requires documents:send, deliberately separate from documents:write: it is the only call that reaches a customer.

Fields

ИмяТипПо умолчаниюОписание
tostring / array—Recipient. Defaults to the address on the order.
ccstring / array—Optional.
subjectstring—Default: type and number. The order number is appended in square brackets so replies can be matched back.
bodystring—HTML. Defaults to a plain cover text.
from_account_idint—The sending account. Defaults to the first configured one.
attach_pdfbooltrueRenders the PDF and attaches it.
allow_foreign_recipientboolfalseRequired to send to an address that is not on the order.

Recipients are resolved from the order by default, and an explicitly named address is checked against the addresses known for that order unless allow_foreign_recipient is set. That guard matters precisely when the caller is an assistant acting on text it did not author. Set the flag only where the recipient comes from a source you trust.

Only SMTP accounts are supported. A mailbox configured for Microsoft Graph is rejected with a clear message rather than sent from the wrong identity.

A draft cannot be sent (409) — confirm it first. On success the document is flagged as sent and the message is logged, so it appears in the order's communications timeline.

СтатусКогда
409The document is still a draft.
502The mail server refused the message.

Partners

A partner is a company's B2B agreement record: commercial terms, partner tier, lifecycle dates.

The partner does not carry the company's name, email or phone itself — those live on the linked contacts. One partner can have many: typically one record representing the company itself, plus several contact persons.

To create or update the people at a partner, use the /v1/contacts endpoints with partner_id set. The partner endpoints only touch the agreement record.

Every contact the partner endpoints return carries its roles — the function that person has in your processes. Because a contact person is an ordinary contact, this needs no partner-specific model: read them here, write them through PATCH /v1/contacts/{id}.

GET/v1/partnerspartners:read

List partners

Query parameters

ИмяТипПо умолчаниюОписание
limitint50Page size, capped at 200.
offsetint0Pagination offset.
partner_numberstring—Exact-match filter.
partner_typestring—Exact-match filter, e.g. Vertriebspartner, Servicepartner or Lieferant.
updated_sinceISO date—Only partners whose updated_at is greater than or equal to this timestamp.
include_inactive1 / 00Also returns soft-deleted partners.

Each row carries a denormalized display_company — the first non-empty company name from its linked contacts — plus contact_count, so a typical list view does not need a second call per row.

Response — 200
json
{
  "data": [
    {
      "id": 79,
      "partner_number": "P000004",
      "partner_type": "Servicepartner",
      "partner_tier": "Gold",
      "active": 1,
      "display_company": "Acme GmbH",
      "contact_count": 3,
      "...": "..."
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 12 }
}
GET/v1/partners/{id}partners:read

Fetch a single partner

Returns the partner together with its contacts array — active contacts only, the company record sorted first.

Response — 200
json
{
  "data": {
    "id": 79,
    "partner_number": "P000004",
    "partner_type": "Servicepartner",
    "display_company": "Acme GmbH",
    "contacts": [
      { "id": 8213, "first_name": null, "last_name": null,
        "company": "Acme GmbH", "email": "office@acme.example",
        "...": "..." },
      { "id": 8214, "first_name": "Anna", "last_name": "Müller",
        "company": null, "email": "a.mueller@acme.example",
        "position": "Geschäftsführung", "...": "..." }
    ],
    "...": "..."
  }
}
POST/v1/partnerspartners:write

Create a partner, optionally with contacts

Creates the agreement record. Optionally it also creates one or more linked contacts in the same transaction, by passing a contacts array. Each item is a full contact object — the same shape and the same validation as POST /v1/contacts. The new partner's id is forced onto each nested contact; any partner_id sent there is ignored.

If any nested contact fails validation, the whole call is rolled back — neither the partner nor any contact is persisted.

Writable partner fields

  • partner_number
  • accounting_number
  • partner_type
  • partner_tier
  • registration_number
  • website
  • note
  • title
  • discount
  • discount_code
  • acquired_by
  • partnership_started_at
  • partnership_ended_at
  • webinar_date
  • webinar_type
  • send_sms
  • send_email
  • territorial_protection
  • exclusivity

If partner_type is omitted, Vertriebspartner applies.

Request — partner only
json
{
  "partner_number": "P-EXT-001",
  "partner_type": "Servicepartner",
  "website": "https://example.com"
}
Request — partner with company and contact person in one call
json
{
  "partner_number": "P-EXT-002",
  "partner_type": "Servicepartner",
  "website": "https://acme.example",
  "contacts": [
    {
      "company": "Acme GmbH",
      "email": "office@acme.example",
      "phone": "+43 1 12345678",
      "street": "Hauptstraße 1",
      "postal_code": "1010",
      "city": "Wien",
      "country": "AT"
    },
    {
      "first_name": "Anna",
      "last_name": "Müller",
      "position": "Geschäftsführung",
      "email": "anna.mueller@acme.example",
      "mobile": "+43 660 1234567",
      "endpoints": [
        { "type": "phone", "value": "+43 1 12345678" }
      ]
    }
  ]
}

Response — 201 with the created partner and a Location header. When nested contacts were sent, the response also carries a created_contact_ids array and the full contacts list, in the same shape as GET /v1/partners/{id}.

СтатусКогда
422contacts is not an array, or a nested contact failed validation — for example because none of email, phone or mobile was present.
PATCH/v1/partners/{id}partners:write

Update a partner

Partial update. Every change is recorded in the partner's change history, and updated_at is set explicitly.

DELETE/v1/partners/{id}partners:write

Delete a partner

The default behavior is a soft delete: the partner is set inactive and an entry is written to the change history. Linked contacts are untouched — soft-delete them separately through /v1/contacts/{id} if needed.

With ?hard=1 (or ?hard=true) the partner and its change history are removed permanently. Linked contacts are not deleted, and their reference to the partner is left as it is. This is irreversible.

СтатусКогда
404The partner does not exist.
409The hard delete is blocked by an existing relationship in the database.
GET/v1/partners/{id}/contactspartners:read

A partner's contacts

Exactly the contacts array from the single-partner response, without the rest of the partner data. Each entry carries its roles. Combined with GET /v1/contacts?role=Bauleiter you search across all partners instead of within one.