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:readand the key owner’s current company read access.POST/PUT/DELETE require both
customers:writeandcustomers: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 |
|---|---|---|
|
integer |
Read-only positive server id. |
|
string |
1–32 Unicode characters; generated if omitted on POST; cannot clear. |
|
string |
Required on POST, 1–255; cannot clear. |
|
string |
Max 20; international identifiers allowed; may identify an individual. |
|
string |
Max 20. |
|
string |
Max 255; customer’s main address, not an additional-address resource. |
|
string |
Max 255. |
|
string |
Max 20. |
|
string |
Max 255. |
|
string |
Max 255; omitted POST defaults to |
|
string |
Max 20. |
|
string |
Max 20. |
|
string |
Max 20. |
|
string |
Max 255; empty or a valid http/https URL. |
|
string |
Max 255; empty or a valid email. |
|
string |
Max 255; preserved after trimming. |
|
string |
Max 255; preserved after trimming. |
|
string |
Max 255; preserved after trimming. |
|
string |
Max 255; preserved after trimming. |
|
string |
Max 255. |
|
string |
Max 255; empty or a valid email. |
|
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 |
|---|---|---|
|
integer |
Read-only positive server id. |
|
integer |
0–5; default 0. Locale-independent code, no label name returned. |
|
string |
Max 50; default empty. |
|
string |
Required on POST, 1–255; cannot clear. |
|
string |
Max 255; default empty. |
|
string |
Max 255; default empty (unlike the customer’s max 20). |
|
string |
Max 255; default empty. |
|
string |
Max 255; default |
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) andpagination. When more exist,next_after_idis the last returned id, not a lookahead id. Continue using it and the same limit untilhas_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 |
|
Unsupported Content-Type, malformed/non-object/non-UTF-8 body or excessive nesting. |
400 |
|
Field/type/length/format/prohibited character/query error or empty PUT. |
400 |
|
Missing, malformed or conflicting POST key. |
401 |
Existing auth vocabulary |
Missing or invalid credential. |
403 |
|
Missing route scope or read prerequisite. |
403 |
|
Current company access insufficient. |
404 |
|
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 |
405 |
|
Known resource, unsupported method. |
409 |
|
Same key with another normalized payload. |
409 |
|
Replay points at a deleted/inactive resource. |
409 |
|
Explicit/changed customer number is already used, or reserved by an unfinished create. |
409 |
|
Company lock unavailable within its three-second timeout. |
409 |
|
UPDATE post-check finds a surviving resource with differing values. |
413 |
|
Body exceeds 64 KiB. |
429 |
|
Customer write limiter; early credential limits retain their existing
|
503 |
|
Company database needs upgrade before the operation. |
503 |
|
DB/lock/operation/audit dependency failure, no provisioned company DB, or unexpected legacy output during authorization/data access. |
500 |
|
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.