Customers and additional addresses

The company-owned customer register supports list, detail, create and partial update. Additional addresses are a separate subresource with full CRUD. There is no customer DELETE, search, filtering, bulk operation or embedded address write in this contract.

Access and data protection

These routes are PAT-only. Send Authorization: Bearer <PAT>. Legacy Access-Token/Client-Secret receive 404 resource_not_found on every new customer/address route. Existing /customers/{customer_id}/customer-invoices paths retain their invoice scopes, company gate and legacy support.

  • GET requires customers:read and the key owner’s current company read access.

  • POST/PUT/DELETE require both customers:write and customers:read, and company write access. Write cannot be granted at creation/rotation or used without read, because write responses contain the full object.

  • Existing keys do not gain scopes automatically. A key remains bound to one company; rights are checked on each request, independently of sessions. An admin browser cookie cannot elevate the PAT.

Warning

Customer responses include contact and bank data. org_number may be a personal identity number for an individual. The integrator is responsible for GDPR-compliant purpose, access control, retention and protection. Do not log field values, request/response bodies, PATs or idempotency keys. Customer data responses use Cache-Control: no-store.

Only active saved customers are visible and writable. Inactive/deleted and invoice-only one-off customers are excluded. Missing/inactive resources and wrong company/parent give the same neutral 404 resource_not_found.

Routes

All paths below are relative to https://economydesk.econeum.com/edapi/v1.

GET    /user-companies/{company_id}/customers
POST   /user-companies/{company_id}/customers
GET    /user-companies/{company_id}/customers/{customer_id}
PUT    /user-companies/{company_id}/customers/{customer_id}
GET    /user-companies/{company_id}/customers/{customer_id}/addresses
POST   /user-companies/{company_id}/customers/{customer_id}/addresses
GET    /user-companies/{company_id}/customers/{customer_id}/addresses/{address_id}
PUT    /user-companies/{company_id}/customers/{customer_id}/addresses/{address_id}
DELETE /user-companies/{company_id}/customers/{customer_id}/addresses/{address_id}

Detail POST, collection PUT/DELETE and other methods on these paths return 405 method_not_allowed. Path ids must be canonical positive ASCII decimal integers at most 2147483647 without leading zeros. 007, 0, -1, 5abc and 1<x>2 are invalid and give 404 resource_not_found; they are never coerced into another id. A path outside the routes above, such as /customers/42/unknown, gets the general API 404, {"message":"Resource not found"}, without an error_code.

Customer fields

List items and detail use the same full object. Only name is required on POST; all writable fields are optional on PUT.

Field

JSON type

Rule/default

customer_id

integer

Read-only positive server id.

customer_number

string

1–32 Unicode characters; generated if omitted on POST; cannot clear.

name

string

Required on POST, 1–255; cannot clear.

org_number

string

Max 20; international identifiers allowed; may identify an individual.

vat_number

string

Max 20.

address_line_1

string

Max 255; customer’s main address, not an additional-address resource.

address_line_2

string

Max 255.

zip

string

Max 20.

city

string

Max 255.

country

string

Max 255; omitted POST defaults to Sverige.

phone

string

Max 20.

alt_phone

string

Max 20.

fax

string

Max 20.

web

string

Max 255; empty or a valid http/https URL.

email

string

Max 255; empty or a valid email.

plusgiro

string

Max 255; preserved after trimming.

bankgiro

string

Max 255; preserved after trimming.

iban

string

Max 255; preserved after trimming.

bic

string

Max 255; preserved after trimming.

contact_name

string

Max 255.

contact_email

string

Max 255; empty or a valid email.

default_contra_account

integer

POST default/clear 0. Otherwise an existing four-digit 3xxx account in the company’s current chart, with accounting enabled.

Omitted optional POST strings default to "" except country and generated customer number. No company ownership, user/timestamps, state/config/security or internal idempotency fields are exposed or accepted.

An explicit customer number, on POST or when changed by PUT, must be unique among other active customers in the company under table collation, and must not be reserved by an unfinished create (see Idempotency, recovery and audit). Comparison follows the collation, so abc and ABC are the same number. Automatic allocation uses the highest positive numeric active/reserved number plus one, starting at "1". Non-numeric legacy numbers are ignored; no global UNIQUE migration of legacy empty/duplicate numbers is required.

Additional-address fields

These are managed only through the address subresource. Every operation verifies the authorized company and active parent customer, including on later list pages.

Field

JSON type

Rule/default

address_id

integer

Read-only positive server id.

label

integer

0–5; default 0. Locale-independent code, no label name returned.

title

string

Max 50; default empty.

address_line_1

string

Required on POST, 1–255; cannot clear.

address_line_2

string

Max 255; default empty.

zip

string

Max 255; default empty (unlike the customer’s max 20).

city

string

Max 255; default empty.

country

string

Max 255; default Sverige.

Validation and partial PUT

POST/PUT require Content-Type: application/json (optionally ; charset=utf-8), a valid UTF-8 JSON object, and at most 64 KiB (65536 bytes). JSON nesting beyond depth 3 is invalid_json; nested arrays/objects of a supported depth are rejected as invalid field types. Unknown/read-only fields and an empty PUT object are validation_failed. No implicit conversion: integer/string types must match; null is not clear.

Strings are trimmed before validation and hashing. Lengths count Unicode characters, not bytes. Org/VAT/bank data is otherwise preserved without destructive formatting. All string fields reject, rather than remove:

  • < and >.

  • C0 U+0000–U+001F, including TAB/CR/LF; DEL U+007F; C1 U+0080–U+009F.

  • Line separators U+2028 and U+2029.

  • Direction controls U+200E/U+200F, U+202A–U+202E and U+2066–U+2069.

  • Invalid UTF-8.

ÅÄÖ & O'Connor "Ltd" is valid and preserved after trimming.

PUT is partial, not replacement. Only sent fields change. "" clears optional strings; 0 clears the contra account.

A sent value equal to the stored value (after trimming) counts as unchanged and is accepted as it is; only changed values must meet the rules above. You can therefore GET a customer or address, change one field and PUT the whole object back. Only changed field names appear in the audit event. Name/customer number and address line 1 cannot clear. No-op PUT returns stored values and adds no audit event. Zero changed UPDATE rows trigger a scoped post-check: absent or inactive is 404, supplied values already satisfied is 200, surviving different values is 409 resource_conflict. A SQL failure is 503, never 404.

Customer create, replay, conflict and update

All data below is fictional. Set EDAPI_PAT securely; do not put a real token in scripts checked into source control.

BASE=https://economydesk.econeum.com/edapi/v1
curl -i "$BASE/user-companies/7/customers" \
  -H "Authorization: Bearer $EDAPI_PAT" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: customer-create-000001' \
  --data '{"name":"ÅÄÖ & O'\''Connor","email":"billing@example.com","iban":"SE45 5000 0000 0583 9825 7466"}'

First response: 201 Created, Cache-Control: no-store, Location: /edapi/v1/user-companies/7/customers/42:

{
  "customer": {
    "customer_id": 42, "customer_number": "1", "name": "ÅÄÖ & O'Connor",
    "org_number": "", "vat_number": "", "address_line_1": "", "address_line_2": "",
    "zip": "", "city": "", "country": "Sverige", "phone": "", "alt_phone": "",
    "fax": "", "web": "", "email": "billing@example.com", "plusgiro": "",
    "bankgiro": "", "iban": "SE45 5000 0000 0583 9825 7466", "bic": "",
    "contact_name": "", "contact_email": "", "default_contra_account": 0
  },
  "idempotent_replay": false
}

Repeat the identical curl request after a lost response. 200 OK returns the same customer id, optional same Location, and the full stored object:

{
  "customer": {
    "customer_id": 42, "customer_number": "1", "name": "ÅÄÖ & O'Connor",
    "org_number": "", "vat_number": "", "address_line_1": "", "address_line_2": "",
    "zip": "", "city": "", "country": "Sverige", "phone": "", "alt_phone": "",
    "fax": "", "web": "", "email": "billing@example.com", "plusgiro": "",
    "bankgiro": "", "iban": "SE45 5000 0000 0583 9825 7466", "bic": "",
    "contact_name": "", "contact_email": "", "default_contra_account": 0
  },
  "idempotent_replay": true
}

Change the payload while reusing the key:

curl -i "$BASE/user-companies/7/customers" \
  -H "Authorization: Bearer $EDAPI_PAT" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: customer-create-000001' \
  --data '{"name":"Another customer"}'

409 Conflict; no extra row:

{"message":"Idempotency conflict","error_code":"idempotency_conflict"}

Partial update needs no idempotency key:

curl -i -X PUT "$BASE/user-companies/7/customers/42" \
  -H "Authorization: Bearer $EDAPI_PAT" -H 'Content-Type: application/json' \
  --data '{"phone":"+46 8 123456","contact_email":""}'

200 OK preserves every omitted value:

{
  "customer": {
    "customer_id": 42, "customer_number": "1", "name": "ÅÄÖ & O'Connor",
    "org_number": "", "vat_number": "", "address_line_1": "", "address_line_2": "",
    "zip": "", "city": "", "country": "Sverige", "phone": "+46 8 123456", "alt_phone": "",
    "fax": "", "web": "", "email": "billing@example.com", "plusgiro": "",
    "bankgiro": "", "iban": "SE45 5000 0000 0583 9825 7466", "bic": "",
    "contact_name": "", "contact_email": "", "default_contra_account": 0
  }
}

Read the detail:

curl -i "$BASE/user-companies/7/customers/42" -H "Authorization: Bearer $EDAPI_PAT"

200 OK has no customer envelope:

{
  "customer_id": 42, "customer_number": "1", "name": "ÅÄÖ & O'Connor",
  "org_number": "", "vat_number": "", "address_line_1": "", "address_line_2": "",
  "zip": "", "city": "", "country": "Sverige", "phone": "+46 8 123456", "alt_phone": "",
  "fax": "", "web": "", "email": "billing@example.com", "plusgiro": "",
  "bankgiro": "", "iban": "SE45 5000 0000 0583 9825 7466", "bic": "",
  "contact_name": "", "contact_email": "", "default_contra_account": 0
}

Address create, replay, conflict and CRUD

curl -i "$BASE/user-companies/7/customers/42/addresses" \
  -H "Authorization: Bearer $EDAPI_PAT" -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: address-create-000001' \
  --data '{"label":1,"title":"Warehouse","address_line_1":"Hamngatan 2","zip":"11147","city":"Stockholm"}'

201 Created, Cache-Control: no-store and Location: /edapi/v1/user-companies/7/customers/42/addresses/8:

{
  "address": {
    "address_id": 8, "label": 1, "title": "Warehouse", "address_line_1": "Hamngatan 2",
    "address_line_2": "", "zip": "11147", "city": "Stockholm", "country": "Sverige"
  },
  "idempotent_replay": false
}

Repeat exactly the POST; 200 OK, same address id:

{
  "address": {
    "address_id": 8, "label": 1, "title": "Warehouse", "address_line_1": "Hamngatan 2",
    "address_line_2": "", "zip": "11147", "city": "Stockholm", "country": "Sverige"
  },
  "idempotent_replay": true
}

Different payload with the same key:

curl -i "$BASE/user-companies/7/customers/42/addresses" \
  -H "Authorization: Bearer $EDAPI_PAT" -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: address-create-000001' --data '{"address_line_1":"Another street"}'

409 Conflict with no additional address:

{"message":"Idempotency conflict","error_code":"idempotency_conflict"}

The same key against another active parent also conflicts; parent participates in payload identity, not key namespace.

curl -i "$BASE/user-companies/7/customers/42/addresses/8" \
  -H "Authorization: Bearer $EDAPI_PAT"

200 OK detail is a direct object:

{
  "address_id": 8, "label": 1, "title": "Warehouse", "address_line_1": "Hamngatan 2",
  "address_line_2": "", "zip": "11147", "city": "Stockholm", "country": "Sverige"
}
curl -i -X PUT "$BASE/user-companies/7/customers/42/addresses/8" \
  -H "Authorization: Bearer $EDAPI_PAT" -H 'Content-Type: application/json' \
  --data '{"title":"Delivery entrance"}'

200 OK:

{
  "address": {
    "address_id": 8, "label": 1, "title": "Delivery entrance", "address_line_1": "Hamngatan 2",
    "address_line_2": "", "zip": "11147", "city": "Stockholm", "country": "Sverige"
  }
}
curl -i -X DELETE "$BASE/user-companies/7/customers/42/addresses/8" \
  -H "Authorization: Bearer $EDAPI_PAT"

204 No Content has zero body bytes, not null or {}. Repeat DELETE: 404 with {"message":"Resource not found","error_code":"resource_not_found"}. Retry the original address POST after deletion: 409 with error_code: idempotency_resource_gone; it never recreates the address.

Pagination: complete list traversal

Both collections use ascending resource ids, not name ordering:

  • limit: optional, default 100, range 1–500.

  • after_id: optional positive id, maximum 2147483647. Omit on the first request (internal default 0; an explicit 0 is invalid).

  • Both query values require canonical ASCII decimal without leading zeros. Arrays, signs, whitespace, non-ASCII digits and overflow give 400 validation_failed.

  • Each response contains items (full objects) and pagination. When more exist, next_after_id is the last returned id, not a lookahead id. Continue using it and the same limit until has_more=false.

  • There is no total-count cap or result_too_large/422 error.

The following traversal examples use a separate, static fictional register with customers 42, 43, 44 and addresses 8, 9, 10. All public fields are shown.

Customer first page:

curl "$BASE/user-companies/7/customers?limit=1" -H "Authorization: Bearer $EDAPI_PAT"

200 OK:

{
  "items": [{
    "customer_id": 42, "customer_number": "1", "name": "Customer A",
    "org_number": "", "vat_number": "", "address_line_1": "", "address_line_2": "",
    "zip": "", "city": "", "country": "Sverige", "phone": "", "alt_phone": "",
    "fax": "", "web": "", "email": "", "plusgiro": "", "bankgiro": "", "iban": "",
    "bic": "", "contact_name": "", "contact_email": "", "default_contra_account": 0
  }],
  "pagination": {"limit":1,"has_more":true,"next_after_id":42}
}

Next page, using exactly that continuation:

curl "$BASE/user-companies/7/customers?limit=1&after_id=42" -H "Authorization: Bearer $EDAPI_PAT"
{
  "items": [{
    "customer_id": 43, "customer_number": "2", "name": "Customer B",
    "org_number": "", "vat_number": "", "address_line_1": "", "address_line_2": "",
    "zip": "", "city": "", "country": "Sverige", "phone": "", "alt_phone": "",
    "fax": "", "web": "", "email": "", "plusgiro": "", "bankgiro": "", "iban": "",
    "bic": "", "contact_name": "", "contact_email": "", "default_contra_account": 0
  }],
  "pagination": {"limit":1,"has_more":true,"next_after_id":43}
}

Last page:

curl "$BASE/user-companies/7/customers?limit=1&after_id=43" -H "Authorization: Bearer $EDAPI_PAT"
{
  "items": [{
    "customer_id": 44, "customer_number": "3", "name": "Customer C",
    "org_number": "", "vat_number": "", "address_line_1": "", "address_line_2": "",
    "zip": "", "city": "", "country": "Sverige", "phone": "", "alt_phone": "",
    "fax": "", "web": "", "email": "", "plusgiro": "", "bankgiro": "", "iban": "",
    "bic": "", "contact_name": "", "contact_email": "", "default_contra_account": 0
  }],
  "pagination": {"limit":1,"has_more":false,"next_after_id":null}
}

Stop on false; do not send null as a query value. Address first page:

curl "$BASE/user-companies/7/customers/42/addresses?limit=1" -H "Authorization: Bearer $EDAPI_PAT"
{
  "items": [{
    "address_id":8,"label":0,"title":"A","address_line_1":"Street 1",
    "address_line_2":"","zip":"","city":"","country":"Sverige"
  }],
  "pagination":{"limit":1,"has_more":true,"next_after_id":8}
}

Next page:

curl "$BASE/user-companies/7/customers/42/addresses?limit=1&after_id=8" -H "Authorization: Bearer $EDAPI_PAT"
{
  "items": [{
    "address_id":9,"label":0,"title":"B","address_line_1":"Street 2",
    "address_line_2":"","zip":"","city":"","country":"Sverige"
  }],
  "pagination":{"limit":1,"has_more":true,"next_after_id":9}
}

Last page:

curl "$BASE/user-companies/7/customers/42/addresses?limit=1&after_id=9" -H "Authorization: Bearer $EDAPI_PAT"
{
  "items": [{
    "address_id":10,"label":0,"title":"C","address_line_1":"Street 3",
    "address_line_2":"","zip":"","city":"","country":"Sverige"
  }],
  "pagination":{"limit":1,"has_more":false,"next_after_id":null}
}

An empty register, or a request beyond the last id, is still 200. For example, with omitted limit:

curl "$BASE/user-companies/7/customers?after_id=44" -H "Authorization: Bearer $EDAPI_PAT"
curl "$BASE/user-companies/7/customers/42/addresses?after_id=10" -H "Authorization: Bearer $EDAPI_PAT"

Either response:

{"items":[],"pagination":{"limit":100,"has_more":false,"next_after_id":null}}

There is no snapshot guarantee or total count. Concurrent inserts, updates and deletions can change the data while traversing. A page or interrupted traversal is not a complete inventory; never use one alone to infer deletions during synchronization. Company and active-parent isolation apply on every page.

Idempotency, recovery and audit

POST requires Idempotency-Key; PUT/DELETE do not. Header names are case-insensitive. Trimmed values must be 16–128 ASCII characters from [A-Za-z0-9_.:-]; absent/malformed/conflicting header values give 400.

The namespace is authenticated uid + company + operation, not credential; rotating a credential for the same uid does not change it. JSON field order and omitted versus explicit POST defaults normalize to the same payload. For address creation, the parent id participates in payload identity.

After a lost response or a post-mutation failure, repeat POST with the same key and payload, not a fresh key. Recovery reuses the reservation/creation marker and returns the same resource id. Replay returns current stored values, not an immutable original response. A deleted address/inactive customer gives idempotency_resource_gone and is never resurrected. Different payload with the same key gives idempotency_conflict, with no extra row.

A customer number stays reserved for its create until the create completes: another POST or a PUT cannot take it and gets customer_number_conflict. If a generated number is no longer available when you retry, the retry completes with the next free number, so always read customer_number from the response. If an explicit number is no longer available, the retry gives customer_number_conflict.

A retry of a create that already completed returns the stored resource. It is not validated again, so a later change such as a contra account removed from the chart does not turn the replay into an error.

Each actual mutation reserves an audit event before changing business data. It records uid, public credential reference, operation/resource and sorted changed public field names, never values, bodies, tokens or raw keys. If reservation fails, the mutation does not run.

Important

A crash or audit-completion failure after mutation may leave the event attempted. That does not prove the mutation failed. POST replay/recovery completes the same linked create event as succeeded when storage works, without another business mutation or audit event, even if the operation was already marked completed. Persistent completion failures may leave it attempted. PUT/DELETE have the same post-mutation uncertainty but no equivalent POST recovery. Do not assume rollback from a failed response.

Status and error codes

Customer transport/domain errors use a stable machine code:

{"message":"Human-readable message","error_code":"stable_machine_code"}

Messages never include submitted values, SQL, DB names, stack traces or raw dependency messages, even in development mode. Validation messages name only the field and rule. Early authentication and rate limits retain their existing response vocabulary.

HTTP

Code

Meaning

200

—

List/detail, partial PUT, or POST replay/recovery.

201

—

First POST, full object, replay false and Location.

204

—

Address DELETE; no response body.

400

invalid_json

Unsupported Content-Type, malformed/non-object/non-UTF-8 body or excessive nesting.

400

validation_failed

Field/type/length/format/prohibited character/query error or empty PUT.

400

invalid_idempotency_key

Missing, malformed or conflicting POST key.

401

Existing auth vocabulary

Missing or invalid credential.

403

insufficient_scope

Missing route scope or read prerequisite.

403

forbidden

Current company access insufficient.

404

resource_not_found

Missing/inactive resource, wrong company/parent, invalid raw path id, or legacy credential on a new route. Paths outside the customer routes get the general 404 without error_code.

405

method_not_allowed

Known resource, unsupported method.

409

idempotency_conflict

Same key with another normalized payload.

409

idempotency_resource_gone

Replay points at a deleted/inactive resource.

409

customer_number_conflict

Explicit/changed customer number is already used, or reserved by an unfinished create.

409

write_in_progress

Company lock unavailable within its three-second timeout.

409

resource_conflict

UPDATE post-check finds a surviving resource with differing values.

413

payload_too_large

Body exceeds 64 KiB.

429

rate_limited

Customer write limiter; early credential limits retain their existing message: rate_limited format. Honour Retry-After seconds.

503

upgrade_required

Company database needs upgrade before the operation.

503

service_unavailable

DB/lock/operation/audit dependency failure, no provisioned company DB, or unexpected legacy output during authorization/data access.

500

internal_error

Unexpected failure, response encoding failure or early termination.

Responses are encoded before success headers are sent. Invalid legacy UTF-8 causes safe 500, not an empty success; stale Location is removed. Address 204 is never JSON-encoded.