# AccountCloud API > AccountCloud is one REST API for the integrations accounting software needs from outside: reading invoices and receipts (OCR) into JSON and UBL, company data from the Dutch and Belgian trade registers, EU VAT number checks at VIES, and address forms for 34 countries. Prepaid, priced per call, with a free test mode. - Base URL: `https://api.accountcloud.io/v1`. JSON in and out. - Authentication: `Authorization: Bearer `. Test keys (`ac_test_…`) are free and return fixed test values; live keys (`ac_live_…`) make real, charged calls. - Errors are RFC 9457 problem+json. Branch on the stable `code`, never on `detail`. - Every answer to an accepted key has `AccountCloud-Charged` (the cost in euros) and `X-Request-Id`. - Send an `Idempotency-Key` header with every POST, so a retry never does the work twice. - The API is in pre-release. Most operations are in preview; `GET /v1/pricing` (no key needed) lists the status and price of each. --- # Quickstart Source: https://docs.accountcloud.io/guides/quickstart/ Get a free test key and make your first two calls to the AccountCloud API, in five minutes. Five minutes, a terminal with `curl`, and no cost: test keys are free. The base URL of the API is `https://api.accountcloud.io/v1`. ## 1. Get a test key 1. Sign up at [app.accountcloud.io/signup](https://app.accountcloud.io/signup) with your work email address. 2. Open the activation link we send you, and fill in your login and your company. 3. At the end you see your test key, which starts with `ac_test_`. It is shown once, so copy it now. Lost it? Make a new one in the portal under **API keys**. Put the key in an environment variable, so the commands below work as they are: ```sh export ACCOUNTCLOUD_API_KEY="ac_test_…" ``` ## 2. Check your key: `GET /v1/me` ```sh curl https://api.accountcloud.io/v1/me \ -H "Authorization: Bearer $ACCOUNTCLOUD_API_KEY" ``` ```json { "account": { "id": "acc_01m6hdz01g91yghfzyarpr76h8", "name": "Example B.V.", "status": "pending" }, "environment": "test", "api_key": { "id": "key_01m6j19j146rd8ddxhf7cv0z0y", "name": "Default test key", "prefix": "ac_test_45QT", "expires_at": null }, "scopes": ["*"], "balance": { "amount": "0.00", "currency": "EUR" } } ``` - `environment` is `test`: this key never reaches a real source and is never charged. - `account.status` is `pending` until we have verified your company, and `active` after that. Test keys work either way. - `scopes` is `["*"]`: the key may call every operation. You can narrow that in the portal. - `balance` is your prepaid credit. Test calls never touch it. `GET /v1/me` is free and needs no scope. A missing or wrong key gets `401` [`unauthenticated`](https://docs.accountcloud.io/guides/errors/#unauthenticated) or [`invalid_api_key`](https://docs.accountcloud.io/guides/errors/#invalid_api_key). ## 3. Call an operation: `GET /v1/address/forms/NL` The address form of a country: which fields an address has there, in which order, which are required, and what the postcode looks like. ```sh curl https://api.accountcloud.io/v1/address/forms/NL \ -H "Authorization: Bearer $ACCOUNTCLOUD_API_KEY" ``` ```json { "country": "NL", "verified": true, "fields": [ { "id": "street", "label": "Street", "type": "text", "required": true, "max_length": 255, "options": null, "options_source": null, "example": null, "autocomplete": "address-line1", "layout": { "row": 1, "column": 1, "span": 8 } }, { "id": "house_number", "label": "House number", "type": "text", "required": false, "max_length": 20, "options": null, "options_source": null, "example": null, "autocomplete": null, "layout": { "row": 1, "column": 2, "span": 2 } } ], "postcode": { "pattern": "[1-9]\\d{3} ?(?:[A-RT-Z][A-Z]|S[BCE-RT-Z])", "example": "1234 AB" }, "subdivisions": [] } ``` The answer is shortened: the Netherlands has six fields. Try another country, such as `BE` or `DE`, or add `?locale=nl` for Dutch labels. A country we do not support, such as `US`, gets `404` [`country_not_supported`](https://docs.accountcloud.io/guides/errors/#country_not_supported). The [addresses guide](https://docs.accountcloud.io/guides/addresses/) explains every field. ## 4. See what it cost Add `-i` to see the response headers: ```sh curl -i https://api.accountcloud.io/v1/address/forms/NL \ -H "Authorization: Bearer $ACCOUNTCLOUD_API_KEY" ``` ```http HTTP/1.1 200 OK Content-Type: application/json RateLimit-Policy: "minute";q=60;w=60, "day";q=10000;w=86400 RateLimit: "minute";r=58;t=60, "day";r=9998;t=82610 AccountCloud-Charged: 0.0000 X-Request-Id: req_01m6j19mfvdxgsda7awkz3gzxk ``` - `AccountCloud-Charged` is what the call cost, in euros. With a test key it is always `0.0000`. - `RateLimit` says how many calls you have left this minute and today: 60 per minute for a test key. See [rate limits](https://docs.accountcloud.io/guides/authentication/#rate-limits). - `X-Request-Id` identifies the call. Both calls are now in the request log of the portal, marked as test. Quote the id when you contact support. ## Next - **Build with test values.** Every operation has fixed test values, including the error paths: see [test mode](https://docs.accountcloud.io/guides/test-mode/). - **Handle errors and retries.** Read [errors](https://docs.accountcloud.io/guides/errors/) and [idempotency](https://docs.accountcloud.io/guides/idempotency/): send an `Idempotency-Key` with every `POST`. - **Go live.** Once we have verified your company, you can make a live key (`ac_live_…`) and call the same URLs for real. See [authentication and keys](https://docs.accountcloud.io/guides/authentication/) and [pricing and credit](https://docs.accountcloud.io/guides/pricing/). - **Browse everything.** The [API reference](https://docs.accountcloud.io/reference/) lists every endpoint, and you can try each one there. To check whether the API is up, without a key: `curl https://api.accountcloud.io/v1/status`, or see [status.accountcloud.io](https://status.accountcloud.io). --- # Authentication and keys Source: https://docs.accountcloud.io/guides/authentication/ API keys, what a key may do, rotating and revoking keys, and the rate limits. Every call to the API, except `GET /v1/status` and `GET /v1/pricing`, needs an API key. ## API keys Make keys in the portal under **API keys**. You need the owner, admin or developer role, and two-factor authentication. - **Test keys** start with `ac_test_`. They are free and never reach a real source; see [test mode](https://docs.accountcloud.io/guides/test-mode/). You get one when you sign up. - **Live keys** start with `ac_live_`. They make real calls on your credit. You can make them once we have verified your company. A key is shown in full once, when you make it. After that the portal only shows its first 12 characters. Store it as a secret, for example in an environment variable, and never in code that runs in a browser or an app. An account can have 25 keys that are not revoked or expired. Send the key as a Bearer token: ```sh curl https://api.accountcloud.io/v1/me \ -H "Authorization: Bearer ac_test_…" ``` `GET /v1/me` is free and needs no scope, which makes it a good first call: ```json { "account": { "id": "acc_01jbxk3v6c9m2q8r4t5w7y0z1a", "name": "Example B.V.", "status": "active" }, "environment": "test", "api_key": { "id": "key_01jbxk3v6c9m2q8r4t5w7y0z1a", "name": "Default test key", "prefix": "ac_test_Xb3k", "expires_at": null }, "scopes": ["*"], "balance": { "amount": "12.50", "currency": "EUR" } } ``` A missing, unknown, revoked or expired key gets a `401` ([`unauthenticated`](https://docs.accountcloud.io/guides/errors/#unauthenticated), [`invalid_api_key`](https://docs.accountcloud.io/guides/errors/#invalid_api_key), [`api_key_expired`](https://docs.accountcloud.io/guides/errors/#api_key_expired)) with `WWW-Authenticate: Bearer`. ## What a key may do When you make or change a key, you set: - **Scopes.** `*` for everything, also what we add later. Or a list of products (`address`) and single operations (`address.forms`). A product scope covers every operation of that product. Without the scope, a call gets `403` [`insufficient_scope`](https://docs.accountcloud.io/guides/errors/#insufficient_scope), before its input is checked. `GET /v1/me` needs no scope. - **An IP allowlist.** IPv4 and IPv6 addresses or CIDR blocks, such as `203.0.113.10` or `198.51.100.0/24`. Empty means any address. A call from elsewhere gets `403` [`ip_not_allowed`](https://docs.accountcloud.io/guides/errors/#ip_not_allowed). - **A monthly spending cap**, in euros per calendar month (UTC). A live call that would pass it gets `429` [`monthly_cap_reached`](https://docs.accountcloud.io/guides/errors/#monthly_cap_reached). - **An end date.** After it, the key gets `401` [`api_key_expired`](https://docs.accountcloud.io/guides/errors/#api_key_expired). ## Rotating and revoking **Rotate** a key to get a new one with the same name, scopes and limits. The old key keeps working for the overlap you choose: up to 7 days, 24 hours by default, or not at all. During the overlap, every answer to the old key carries `Deprecation: true` and a `Sunset` header with the time it stops. After that it gets `api_key_expired`. **Revoke** a key when it may have leaked: it stops at once, with `invalid_api_key`. ## Rate limits | | Per key, per minute | Per account, per UTC day | |---|---:|---:| | Live | 600 | 250,000 | | Test | 60 | 10,000 | The minute is a fixed window per key. The day counts all keys of the account in that environment and resets at 00:00 UTC. We can raise the limits for your account; ask support. Every answer to a request that passed the key and scope checks says where you stand, in the `RateLimit-Policy` and `RateLimit` headers: ```http RateLimit-Policy: "minute";q=600;w=60, "day";q=250000;w=86400 RateLimit: "minute";r=599;t=60, "day";r=249999;t=43200 ``` `q` is the quota and `w` the window in seconds; `r` is what is left and `t` the seconds until that window resets. When a window is full you get `429` with `Retry-After`: [`rate_limit_exceeded`](https://docs.accountcloud.io/guides/errors/#rate_limit_exceeded) for the minute, [`daily_quota_exceeded`](https://docs.accountcloud.io/guides/errors/#daily_quota_exceeded) for the day. A refused request does not count, so waiting for `Retry-After` is enough. --- # Errors Source: https://docs.accountcloud.io/guides/errors/ The problem+json body of every error, and what each error code means. Every error of the API is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem, with `Content-Type: application/problem+json`: ```json { "type": "https://docs.accountcloud.io/errors/invalid_api_key", "title": "Invalid API key", "status": 401, "detail": "The API key is not valid. Check that you copied it completely, or create a new one in the portal.", "code": "invalid_api_key", "request_id": "req_01jbxk3v6c9m2q8r4t5w7y0z1a" } ``` | Field | What it is | |---|---| | `type` | A link to the explanation of this error, below. Ends in the `code`. | | `title` | A short summary of the error type. | | `status` | The HTTP status code, the same as the status of the response. | | `detail` | What went wrong this time, for a human. The text can change; do not parse it. | | `code` | The stable, machine-readable code. **Branch on this one.** A code never changes meaning; new errors get new codes. | | `request_id` | The id of the request, also in the `X-Request-Id` header. Quote it when you contact support. | Some errors add fields of their own, listed with the code. A `validation_failed` has the messages per field in `errors`: ```json { "type": "https://docs.accountcloud.io/errors/validation_failed", "title": "Validation failed", "status": 422, "detail": "The request body or parameters are not valid.", "code": "validation_failed", "request_id": "req_01jbxk3v6c9m2q8r4t5w7y0z1a", "errors": { "address.country": ["The selected address.country is invalid."] } } ``` An error is not charged: its `AccountCloud-Charged` header is `0.0000`. There is one exception: a [`company_not_found`](#company_not_found) for a Dutch company costs what the KvK charges us for the lookup, at cost price. ## What to do with an error - **`4xx`:** fix the request or the key; sending it again unchanged gives the same answer. - **`429` and `503`:** wait the number of seconds in `Retry-After`, then try again. - **`500`:** try again later, with a growing pause. For a `POST`, send an [`Idempotency-Key`](https://docs.accountcloud.io/guides/idempotency/), so a retry never does the work twice. ## Error codes ### Authentication and access | Code | Status | Meaning | |---|---|---| | unauthenticated | 401 | No API key. Send `Authorization: Bearer ac_live_…` or `ac_test_…`. | | invalid_api_key | 401 | The key does not exist or was revoked. After 20 of these per minute from one IP address, that address gets `rate_limit_exceeded` instead. | | api_key_expired | 401 | The key passed its end date, or it was rotated and its overlap is over. Use its replacement. | | account_inactive | 403 | The account is suspended or closed. Contact support. | | account_disabled | 403 | We switched off API access for this account for now. Contact support. | | ip_not_allowed | 403 | The key has an IP allowlist, and your address is not on it. | | insufficient_scope | 403 | The key may not call this operation. With `required_scope` and `granted_scopes`. | | subscription_ended | 403 | A live key of an account whose base subscription was stopped. Test keys keep working; a paid top-up starts a new subscription. | | forbidden | 403 | The key may not do this. | ### The request | Code | Status | Meaning | |---|---|---| | bad_request | 400 | The request could not be understood. | | invalid_idempotency_key | 400 | The `Idempotency-Key` header must be 1 to 255 visible ASCII characters. See [idempotency](https://docs.accountcloud.io/guides/idempotency/). | | invalid_entity_reference | 400 | The `AccountCloud-Entity` header must be 1 to 255 visible characters. | | request_failed | 4xx | Another client error; `status` has the code. | | not_found | 404 | The path or the resource does not exist. | | country_not_supported | 404 | The operation does not support this country. With `supported_countries`. | | method_not_allowed | 405 | This HTTP method does not exist on this path; the `Allow` header lists the ones that do. | | conflict | 409 | The request conflicts with the current state of the resource. | | idempotency_key_reused | 409 | This `Idempotency-Key` was used before for a different request. See [idempotency](https://docs.accountcloud.io/guides/idempotency/#the-same-request). | | idempotency_key_in_use | 409 | A request with this `Idempotency-Key` is still running. Try again after `Retry-After` (1 second). See [idempotency](https://docs.accountcloud.io/guides/idempotency/#a-request-that-is-still-running). | | validation_failed | 422 | The body or the parameters are not valid. The messages per field are in `errors`. | ### Limits and credit | Code | Status | Meaning | |---|---|---| | insufficient_credits | 402 | Your credit does not cover this call. With `price`, `available` and `currency`. Top up in the portal, or switch on automatic top-up. | | rate_limit_exceeded | 429 | Too many requests for this key this minute. Wait for `Retry-After`. See [rate limits](https://docs.accountcloud.io/guides/authentication/#rate-limits). | | daily_quota_exceeded | 429 | The daily quota of the account is used up. It resets at 00:00 UTC. | | monthly_cap_reached | 429 | This key reached its monthly spending cap. Raise the cap in the portal, or wait for the next calendar month (UTC); `Retry-After` says how long. | ### Availability | Code | Status | Meaning | |---|---|---| | server_error | 500 | Something went wrong on our side. Try again later; quote the `request_id` if it persists. | | test_mode_not_supported | 501 | This operation has no test mode yet. With `operation`. See [test mode](https://docs.accountcloud.io/guides/test-mode/). | | api_unavailable | 503 | The API is switched off for now. Try again after `Retry-After`. | | product_unavailable | 503 | This product is switched off for now. Try again after `Retry-After`. | | operation_unavailable | 503 | This operation is switched off for now, or it has no price yet, for every country or for the one you asked. Try again after `Retry-After`. | | provider_unavailable | 503 | The source behind this operation is down, such as a trade register or VIES. Nothing was charged. With `provider_status`. Try again after `Retry-After`. | | service_unavailable | 503 | The service is unavailable for now, for example storing a document or validating a UBL file. Nothing was charged. Try again later. | ### Documents (OCR and UBL) See [documents](https://docs.accountcloud.io/guides/documents/). | Code | Status | Meaning | |---|---|---| | file_missing | 422 | No file. Send `file` as a JSON object with `name` and `content_base64`, or as a multipart file field. | | invalid_base64 | 422 | `file.content_base64` is not valid base64. A prefix such as `data:application/pdf;base64,` is allowed. | | file_too_large | 413 | The file is larger than 10 MB after decoding. With `max_bytes`. | | unsupported_media_type | 415 | The file is not a PDF, JPEG or PNG. The type is decided from the content, not from the file name or a declared type. | | encrypted_pdf | 422 | The PDF asks for a password, or cannot be opened. | | too_many_pages | 422 | The PDF has more pages than a document may have. With `max_pages` (now 50) and `pages`. | | invalid_option | 422 | An option has a value that is not allowed, such as `processing: "fast"`. With the option in `field`, and the messages per field in `errors`, as with `validation_failed`. | | verified_not_enabled | 403 | `processing: verified` is switched off for this account. Contact support. | | verified_daily_limit | 429 | The account sent five times its daily maximum of verified documents on one working day. With `daily_max`. Try again on the next working day, or contact support. | | document_not_found | 404 | The document does not exist, belongs to another account or environment, or was deleted. | | document_not_ready | 409 | The document is not `completed` yet, so it has no UBL. Wait for `document.completed`. Also when no UBL could be made from the result: see `ubl.errors` on the document. | | document_rejected | 409 | The document was rejected, so it has no UBL. See `rejection` on the document. | | content_purged | 410 | The content of the document was deleted at the end of its retention period, so there is no UBL any more. The metadata is still there. | | feedback_window_closed | 409 | The original of the document is deleted. Send the file again with the report, as `file.content_base64`. | | file_hash_mismatch | 422 | The file you sent with the report is not the original of the document: its SHA-256 differs. | | unknown_field | 422 | A `field` in the report is not a field of the result. With `field`. | | feedback_limit_reached | 429 | The account sent the maximum number of reports for today, 100 unless agreed otherwise. It resets at 00:00 UTC; `Retry-After` says how long. | ### Company data and VAT numbers See [company data and VAT numbers](https://docs.accountcloud.io/guides/companies/). | Code | Status | Meaning | |---|---|---| | company_not_found | 404 | The register has no company with this number. With `country` and `number`. For NL this costs what the KvK charges us for the lookup, at cost price; any other "not found" is free. | | company_not_cached | 404 | You asked `freshness=cached`, and we hold no copy of this company yet. Ask with `freshness=auto` or `freshness=live` to fetch it from the register. With `country` and `number`. Free. | | register_restricted | 403 | The data of this register is not available to your account, also not with a test key. Dutch company data comes from the KvK, whose terms do not allow us to pass it on. With `country`. Contact support. | --- # Idempotency Source: https://docs.accountcloud.io/guides/idempotency/ Retry a POST safely with an Idempotency-Key. The same key gives the same answer for 24 hours, and is never charged twice. When a request times out or the connection drops, you cannot tell whether it arrived. Send an `Idempotency-Key` header with every `POST`, and you can simply send the same request again: if the first one got through, the retry gets its answer instead of doing the work a second time. ```sh curl https://api.accountcloud.io/v1/address/validate \ -H "Authorization: Bearer $ACCOUNTCLOUD_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1c2e0a-4b7d-4e59-9a51-0c3f8f2d7b14" \ -d '{"address": {"country": "NL", "street": "Hoofdstraat", "house_number": "12", "postal_code": "1234ab", "city": "Amsterdam"}}' ``` ## Where it applies Only to `POST`: - `POST /v1/documents` (send a document) - `POST /v1/documents/{id}/feedback` - `POST /v1/ubl/validate` - `POST /v1/address/validate` and `POST /v1/address/format` On `GET` and `DELETE` the header is ignored: repeating those is safe as it is. ## The key - **Any string of 1 to 255 visible ASCII characters**, without spaces. Anything else gets `400` [`invalid_idempotency_key`](https://docs.accountcloud.io/guides/errors/#invalid_idempotency_key). A UUID works well, and so does your own id for the piece of work, such as `inbox-48213`. - **Per account and environment.** All live keys of your account share one set of idempotency keys, and all test keys another. A test request never replays a live answer. - **Kept for 24 hours** from the first request with it. After that the key is forgotten, and a request with it runs as a new one. ## What happens | Situation | Answer | |---|---| | The first request with a key | Runs as usual. A `2xx` answer is stored with the key. | | The same key and the same request again, after a `2xx` | The stored status and body, with `Idempotency-Replayed: true`. Nothing runs again, and nothing is charged. | | The same key with a different request | `409` [`idempotency_key_reused`](https://docs.accountcloud.io/guides/errors/#idempotency_key_reused). Nothing runs. | | The same key while the first request is still running | `409` [`idempotency_key_in_use`](https://docs.accountcloud.io/guides/errors/#idempotency_key_in_use), with `Retry-After: 1`. | | The same key after an answer that was not a `2xx` | Runs again. Only successful answers are stored, so a retry after a refusal or an error gets a fresh try. | ### The same request A retry counts as the same request when it has the same method, the same path and the same body. - The order of the fields in a JSON object does not matter; the order of the items in a list does. - With multipart, the form fields count, and each file by its SHA-256: a different file under the same key is a different request. - Headers are not compared. If you change a header that matters, such as `AccountCloud-Entity`, use a new key. A different request under a used key gets: ```json { "type": "https://docs.accountcloud.io/errors/idempotency_key_reused", "title": "Idempotency key reused", "status": 409, "detail": "This Idempotency-Key was already used for a different request.", "code": "idempotency_key_reused", "request_id": "req_01m6j19ysmj9hd5hkknyrejfkn" } ``` ### The replayed answer ```http HTTP/1.1 200 OK Content-Type: application/json Idempotency-Replayed: true AccountCloud-Charged: 0.0000 ``` - **The status and the body** are those of the first answer. - **Other headers of the first answer are not repeated**, such as the `Location` of a `202` from `POST /v1/documents`. Take the id from the body. - **It is free:** `AccountCloud-Charged` is `0.0000`, whatever the first request cost. - **It still passes the gateway:** the key, its scopes and the rate limits are checked as for any request, and a replay counts towards your [rate limits](https://docs.accountcloud.io/guides/authentication/#rate-limits). ### A request that is still running While the first request with a key has not answered yet, a second one with the same key gets `409` [`idempotency_key_in_use`](https://docs.accountcloud.io/guides/errors/#idempotency_key_in_use) and `Retry-After: 1`. Wait a second and send it again: once the first one has finished, you get its answer. If the first request never finishes, for example because a server stopped halfway, the key is released after 60 seconds, and the next request with it runs again. ## Retrying well 1. **Make the key once per piece of work, before the first attempt**, and store it with the work. A retry after a crash on your side then still uses the same key. 2. **Retry** after a network error or a timeout, after a `5xx`, and after a `429` or `503` once the `Retry-After` has passed. Use a growing pause between attempts. 3. **Wait and retry** after `409` `idempotency_key_in_use`. 4. **Do not retry** any other `4xx` unchanged: it gets the same answer. A `409` `idempotency_key_reused` means the key was used for something else, which is a bug on your side. ## Documents and duplicates `POST /v1/documents` also recognises a file it has seen before, without any key: the same file again returns the earlier document, with `duplicate_of`, and is not charged again. That works beyond the 24 hours, until the content of the earlier document is deleted. See [duplicates and retries](https://docs.accountcloud.io/guides/documents/#duplicates-and-retries). --- # Test mode Source: https://docs.accountcloud.io/guides/test-mode/ What a test key does, and the fixed test values of every operation. Every account has test keys (`ac_test_…`) next to its live keys (`ac_live_…`). Call the same URLs with a test key to build and test your integration without cost and without touching real data. ## What a test key does - **It is never charged.** A test call costs nothing and needs no credit. The `AccountCloud-Charged` header is always `0.0000`. - **It never reaches a real source.** An operation answers a test key from fixed test values (below), so you can trigger every outcome, the errors included. - **It has its own data.** What you create with a test key is only visible to test keys, and test webhooks go only to your test endpoints. - **It shows in your request log**, marked as test. - **It has lower limits:** 60 requests per minute per key and 10,000 per day per account. - **The input is checked for real.** A request that a live key would get a `422` for gets the same `422` with a test key. ## Operations without a test mode An operation that has no test values yet answers a test key with `501`: ```json { "type": "https://docs.accountcloud.io/errors/test_mode_not_supported", "title": "Not available in test mode", "status": 501, "detail": "This operation has no test mode yet, so it cannot be called with a test key. Use a live key, or see the test mode guide for the operations that support it.", "code": "test_mode_not_supported", "operation": "vat.rates", "request_id": "req_01jbxk3v6c9m2q8r4t5w7y0z1a" } ``` ## Provider test environments Some operations can pass test calls on to the test environment of their source instead of the fixed values. We switch that per operation; the table below says where it is possible. ## Test values per operation ### `address.format` `POST /v1/address/format` Writes an address as the lines its country prints it on. A test key gets the same answers as a live key: this operation reads only our own reference data. | Input | Result | |---|---| | `address`: `{"country": "NL", "street": "Hoofdstraat", "house_number": "12", "postal_code": "1234ab", "city": "Amsterdam"}`, `from_country`: `NL` | `lines`: `["Hoofdstraat 12", "1234 AB Amsterdam"]` | | The same with `from_country`: `BE` | The same lines, plus the country line `Netherlands` | ### `address.forms` `GET /v1/address/forms`, `GET /v1/address/forms/{country}` The address form of a country: its fields, which are required, the postcode pattern and the subdivisions. A test key gets the same answers as a live key: this operation reads only our own reference data. | Input | Result | |---|---| | Any of the 34 supported countries, such as `NL`, `BE` or `ES` | The real form of that country | | A country outside them, such as `US` | `404` with `country_not_supported` | ### `address.validate` `POST /v1/address/validate` Checks an address against the form of its country and returns it normalised. A test key gets the same answers as a live key: this operation reads only our own reference data. | Input | Result | |---|---| | `address`: `{"country": "NL", "street": "Hoofdstraat", "house_number": "12", "postal_code": "1234ab", "city": "Amsterdam"}` | `valid`: `true`, with `postal_code` normalised to `1234 AB` | | The same with `"postal_code": "12345"` | `valid`: `false`, with the error `postcode_invalid` on `postal_code` | | An address in a country outside the 34 supported ones | `422` with `validation_failed` on `address.country` | ### `company.profile` `GET /v1/companies/{country}/{number}` Fixed test companies for NL and BE, with every outcome. The checks on the country, the number and `freshness` are the real ones, and so is the rule on who may see NL data: an account without access gets `403` with `register_restricted`, also with a test key. Can pass test calls on to the test environment of `kvk`. | Input | When | Result | |---|---|---| | `NL` `69599084` | `freshness` `auto` or `cached` | The test company *Test EMZ Dagobert* (Eenmanszaak), from our copy (`source.freshness`: `cached`, 3 days old) | | `NL` `69599084` | `freshness`: `live` | The same company, fetched now (`source.freshness`: `live`) | | `NL` `68750110` | `freshness` `auto` or `live` | The test company *Test BV Donald* (Besloten vennootschap), fetched now (`source.freshness`: `live`) | | `NL` `68750110` | `freshness`: `cached` | `404` with `company_not_cached` | | `NL` `00000000` | | `404` with `company_not_found`. With a live key this costs what the KvK charges us for the lookup | | `NL` `99999999` | | `503` with `provider_unavailable` | | `NL`, any other 8 digits | | `404` with `company_not_found`, or `company_not_cached` with `freshness: cached` | | `BE` `0123456749` | | The test company *Testbedrijf NV*, from the daily copy of the KBO (`source.freshness`: `cached`) | | `BE`, any other enterprise number | | `404` with `company_not_found` | | `BE` with `freshness=live` | | `422` with `validation_failed` on `freshness`: the KBO is never asked live | | `NL` `1234` | | `422` with `validation_failed` on `number` | | `DE` (or any country other than NL and BE) | | `404` with `country_not_supported` | ### `company.search` `GET /v1/companies` Finds the test companies of `company.profile`. The checks on `country`, `q` and `limit` are the real ones, and so is the rule on who may see NL data. Can pass test calls on to the test environment of `kvk`. | Input | Result | |---|---| | `country=NL`, a `q` that contains `test` | *Test EMZ Dagobert* (`69599084`) and *Test BV Donald* (`68750110`) | | `country=NL`, `q` is one of those numbers | That company | | `country=BE`, a `q` that contains `test`, or the start of `0123456749` | *Testbedrijf NV* (`0123456749`) | | `q=unavailable` | `503` with `provider_unavailable` | | Anything else | An empty list | ### `ocr.extract` `POST /v1/documents` A test document is checked like a live one (size, file type, password, pages), then gets a fixed result that depends on its file name, with the same statuses and webhooks as a live document. Nothing is read and nothing is charged: `charge.amount` is `0.0000`, and `charge.live_amount` shows what it would cost live. Sending the same test file twice gives `duplicate_of`. Test documents are deleted after 24 hours. The result changes over time, as it would live, with the same webhooks to your test endpoints. | Input | When | Result | |---|---|---| | any other file name | `processing`: `automatic` | After ~3 s `completed`, with the fixed invoice of *Testleverancier B.V.* (two lines, 21% and 9%) | | `receipt.*` (for example `receipt.jpg`) | `processing`: `automatic` | After ~3 s `completed`, with a fixed receipt (9% only) | | `unbalanced.pdf` | `processing`: `automatic` | After ~3 s `completed`, with the warning `totals_do_not_reconcile` | | `slow.pdf` | `processing`: `automatic` | 60 s `extracting`, then `completed` (to test polling) | | `fail.pdf` | `processing`: `automatic` | After ~3 s `failed`, with `failure.code` `extraction_failed` | ### `ocr.feedback` `POST /v1/documents/{document}/feedback` A report on a test document is checked and stored as live, but the document never goes to the training set: `training` is always `stats_only`. A test key gets the same answers as a live key: this operation reads only our own reference data. ### `ocr.read` `DELETE /v1/documents/{document}`, `GET /v1/documents`, `GET /v1/documents/{document}` Reading, listing and deleting work as live, on your test documents only. A test key never sees a live document, and the other way round. A test key gets the same answers as a live key: this operation reads only our own reference data. ### `ubl.generate` `GET /v1/documents/{document}/ubl` The UBL of a completed test document is made from its fixed result, with your `own_party` as the buyer, or *Testafnemer B.V.* when you sent none. A test key gets the same answers as a live key: this operation reads only our own reference data. ### `ubl.validate` `POST /v1/ubl/validate` The validation is the real one, with a test key too. A test key gets the same answers as a live key: this operation reads only our own reference data. ### `ubl.verified` `POST /v1/documents` A verified test document goes through the same statuses and webhooks as a live one, but no processor looks at it: after a minute it is approved, with a fixed UBL that passes the validation, or rejected, depending on its file name. Nothing is charged. The result changes over time, as it would live, with the same webhooks to your test endpoints. | Input | When | Result | |---|---|---| | any other file name | `processing`: `verified` | `extracting` → `awaiting_review` (with `document.extracted`) → after 1 minute `completed`, with a fixed UBL that passes the validation | | `reject.pdf` | `processing`: `verified` | After 1 minute `rejected`, with `rejection.reason` `not_an_invoice` | | `duplicate.pdf` | `processing`: `verified` | After 1 minute `rejected`, with `rejection.reason` `duplicate` | ### `vat.validate` `GET /v1/vat-numbers/{vat_number}` Fixed VAT numbers, without asking VIES. The checks on the country and the format are the real ones: a number in the wrong format is `valid: false` with `format_valid: false`, as it is live. | Input | Result | |---|---| | `NL999999990B01` | `valid: true`, *Testbedrijf B.V.*, with the consultation number `WAPITEST00000001` | | `BE0123456749` | `valid: true`, *Testbedrijf NV*, with the consultation number `WAPITEST00000001` | | `NL999999991B01` | `valid: false` | | `DE999999999` | `503` with `provider_unavailable`: the register of the member state does not answer | | Any other number in the right format | `valid: false` | | `NL123` | `valid: false`, `format_valid: false`, and nothing is charged | | `GB123456789` (outside the EU) | `404` with `country_not_supported` | | `123456789` (no country code) | `422` with `validation_failed` on `vat_number` | --- # Webhooks Source: https://docs.accountcloud.io/guides/webhooks/ Receive events from AccountCloud, verify their signature, and what happens when your endpoint fails. A webhook endpoint is an https URL of yours that AccountCloud sends events to, as a `POST` with a JSON body. Every request is signed the [Standard Webhooks](https://www.standardwebhooks.com) way, so any of its libraries can verify it. ## Endpoints Add endpoints in the portal under **Webhooks**. You need the owner, admin or developer role, and two-factor authentication to add, change or delete an endpoint or to see its secret. - **Live or test.** An endpoint belongs to one environment. Events of live activity only go to live endpoints, events of test mode only to test endpoints. A test event that you send from the portal goes to the endpoint you send it to. - **Event types.** An endpoint receives the types you pick, or all of them (`*`, which also includes types we add later). - **At most 20 endpoints** per account, live and test together. ### What the URL must be We check the URL when you save it, and again before every attempt: - `https`, on port 443 or a port from 1024 to 65535; - no user name or password in the URL, at most 2,048 characters; - the host must be reachable from the internet: every address its name resolves to must be public. Private, loopback, link-local and other reserved ranges are refused (IPv4 and IPv6), as are names such as `localhost` or `*.local`. Redirects are not followed. To test locally, use a tunnel that gives you a public https URL. ## Event types | Type | When | |---|---| | `credit.low_balance` | The available credit fell below your alert threshold. | | `credit.auto_top_up_failed` | An automatic top-up could not be collected, so automatic top-up was switched off. | | `subscription.paused` | The credit ran out for longer than the grace period, so the base subscription was paused. | | `document.extracted` | Only `processing: verified`: a document was read and waits for a processor. | | `document.completed` | A document is done; with `verified`, a processor approved it. | | `document.failed` | Only `processing: automatic`: a document could not be read. | | `document.rejected` | Only `processing: verified`: a processor rejected a document. | | `ping` | A test event from the portal. Only sent when you ask for it, to one endpoint. | The credit and subscription events are about live money, so they go to your **live** endpoints. A `document.*` event goes to the endpoints of the document's environment: the events of a test document go to your test endpoints. Their `data` is described in the [documents guide](https://docs.accountcloud.io/guides/documents/#webhooks). The body is always `type`, `timestamp` (when the event happened) and `data`: ```json { "type": "credit.low_balance", "timestamp": "2026-11-03T12:00:00Z", "data": { "account_id": "acc_01jbxk3v6c9m2q8r4t5w7y0z1a", "available": "1.50", "threshold": "2.00", "currency": "EUR" } } ``` The `data` of the other types: ```jsonc // credit.auto_top_up_failed { "account_id": "acc_01jbxk3v6c9m2q8r4t5w7y0z1a", "reason": "The payment failed." } // subscription.paused { "account_id": "acc_01jbxk3v6c9m2q8r4t5w7y0z1a", "status": "paused", "paused_at": "2026-11-03T00:30:00Z" } // document.completed (the other document.* types add a field or two; see the documents guide) { "id": "doc_01jbx6y4q9t2m8rz1v3k5n7p0a", "status": "completed", "processing": "automatic", "reference": "inbox-48213", "metadata": { "administration": "8842" }, "charge": { "operation": "ocr.extract", "amount": "0.0350", "currency": "EUR" } } // ping { "message": "This is a test event from AccountCloud.", "endpoint_id": "whe_01jbxk3v6c9m2q8r4t5w7y0z1a" } ``` Amounts are decimal strings in euros. Payloads are thin on purpose: ids, a status and amounts. ## The request ```http POST /webhooks/accountcloud HTTP/1.1 Content-Type: application/json User-Agent: AccountCloud-Webhooks/1.0 (+https://docs.accountcloud.io/guides/webhooks/) webhook-id: evt_01jbxm0r7f2c9q4w8z1t6k3n5d webhook-timestamp: 1793707205 webhook-signature: v1,4+dyfJJD2QQ0J+4w8OAc1m/gpYF80QynkYPk4/JkDIQ= ``` | Header | What it is | |---|---| | `webhook-id` | The id of the event (`evt_…`). The same on every retry and redelivery of that event: use it to process an event once. | | `webhook-timestamp` | When this attempt was sent, in Unix seconds. | | `webhook-signature` | `v1,` and the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`, keyed with the base64-decoded part of your secret after `whsec_`. Right after you rotate the secret it holds two signatures, separated by a space. | ## Verify the signature Find the signing secret (`whsec_…`) in the portal. Verify against the **raw body**, exactly as it arrived: a body that you parse and serialise again no longer matches the signature. The libraries also refuse a `webhook-timestamp` more than 5 minutes from now, against replays. ### Node.js ```sh npm install standardwebhooks ``` ```js title="server.js" const webhook = new Webhook(process.env.ACCOUNTCLOUD_WEBHOOK_SECRET); // whsec_… const app = express(); // express.raw keeps the body as a Buffer, exactly as it was sent. app.post("/webhooks/accountcloud", express.raw({ type: "application/json" }), (req, res) => { let event; try { event = webhook.verify(req.body, req.headers); } catch { return res.status(400).send("invalid signature"); } // event.type, event.timestamp, event.data res.status(204).end(); }); app.listen(3000); ``` ### PHP ```sh composer require standard-webhooks/standard-webhooks ``` ```php title="webhook.php" $_SERVER['HTTP_WEBHOOK_ID'] ?? '', 'webhook-timestamp' => $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '', 'webhook-signature' => $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '', ]; try { $event = (new Webhook(getenv('ACCOUNTCLOUD_WEBHOOK_SECRET')))->verify($payload, $headers); } catch (WebhookVerificationException) { http_response_code(400); exit; } // $event['type'], $event['timestamp'], $event['data'] http_response_code(204); ``` Libraries for other languages are in the [Standard Webhooks repository](https://github.com/standard-webhooks/standard-webhooks). ## Answering - **Any 2xx** counts as delivered. Everything else is a failure, including a 3xx. - **Answer within 10 seconds.** That is the time an attempt gets in all; connecting gets 5 of them. Do slow work after you have answered. - We keep the first kilobyte of your answer in the delivery log, and stop reading after 256 KB. ## Retries A failed attempt is tried again after **1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours**: seven attempts in all. Each delay gets up to 10% added at random. If you answer with `Retry-After`, we wait at least that long, up to 24 hours. Events can arrive more than once, for example when your answer was lost. Use `webhook-id` to process each event once. ## When we switch an endpoint off - after **3 days** in which every automatic delivery to it failed; - at once when it answers **`410 Gone`**. The owners, admins and developers of the account then get an e-mail. Attempts that were still planned are cancelled, and nothing is sent until you switch the endpoint on again in the portal. That also resets its failure count. One successful delivery ends a series of failures. ## Test events and sending again In the portal you can: - **send a test event** to an endpoint: a `ping`, or any other type with the example `data` above. It goes out at once, also to a disabled endpoint, so you can check a fix before you switch it on. A test event looks exactly like a real event of its type, so do not act on its example data; - **see the delivery log**: every attempt, with its status, the HTTP status, the duration and the start of your answer; - **send an event again**: once more, now, as a new attempt. This needs an active endpoint. Test events and sending again are never retried and do not count towards switching an endpoint off. You can do at most 10 of them per minute per endpoint. ## Rotating the secret Rotate the secret in the portal when it may have leaked, or on a schedule. Choose how long the old secret keeps signing as well: up to 7 days, 24 hours by default, or not at all. During that time every request carries both signatures, and the libraries accept a request when either one matches. Put the new secret in your receiver before the time is up. --- # Pricing and credit Source: https://docs.accountcloud.io/guides/pricing/ The base subscription, the price per call, and what each call cost. You pay from prepaid credit: a small base subscription, and a price per call on top. Prices are in euros, excluding VAT. Test keys never cost anything. ## The price list `GET /v1/pricing` needs no API key and lists what applies now: ```sh curl https://api.accountcloud.io/v1/pricing ``` ```json { "currency": "EUR", "vat_included": false, "base_subscription": { "operation": "account.subscription", "product": "platform", "name": "Base subscription", "description": null, "status": "live", "unit": "month", "asynchronous": false, "prices": [ { "variant": null, "model": "per_unit_month", "price": null, "monthly_price": "9.00", "unit": "account", "tiers": [], "valid_from": "2026-11-01T00:00:00Z" } ] }, "operations": [ { "operation": "address.forms", "product": "address", "name": "Address forms per country", "description": null, "status": "live", "unit": "call", "asynchronous": false, "prices": [ { "variant": null, "model": "per_call", "price": "0.0000", "monthly_price": null, "unit": null, "tiers": [], "valid_from": "2026-11-01T00:00:00Z" } ] } ], "generated_at": "2026-11-03T14:20:41Z" } ``` - `model` is `per_call`, a price per call, or `per_unit_month`, a price per unit per month. - A `per_call` price can have `tiers` on the monthly volume; `price` is then `null`. - An operation can have several priced `variant`s, such as data from our cache and live data from the source. - `status` is `live`, or `preview` for an operation that is announced but not generally available. An operation without a price is not in the list. - Prices change from a date (`valid_from`); the list shows the one that applies now. ## The base subscription - It **starts** with your first paid top-up. The start credit you get when we verify your company does not start it. - It is **charged per day**: every night we take that day's part of the monthly price from your credit. The days of a month add up to exactly the monthly price. - When your credit **does not cover** a day, the subscription has 3 days of grace, which are not charged. Then it is **paused**: you get an e-mail and a [`subscription.paused`](https://docs.accountcloud.io/guides/webhooks/#event-types) webhook. Your next paid top-up resumes it. Pausing does not block calls; a call that your credit cannot cover gets `402` [`insufficient_credits`](https://docs.accountcloud.io/guides/errors/#insufficient_credits). - You can **stop** it in the portal (owner or billing role, with two-factor authentication). Today is still charged, nothing after that. From tomorrow live calls get `403` [`subscription_ended`](https://docs.accountcloud.io/guides/errors/#subscription_ended), and automatic top-up stops. Test keys keep working, and a new paid top-up starts a new subscription. ## Per call Before a live call runs, its price is reserved on your credit. When the call succeeds (a `2xx`), the price is taken; any other answer releases the reservation, so an error, invalid input or a fault on our side costs nothing. Sending the same `POST` again with the same [`Idempotency-Key`](https://docs.accountcloud.io/guides/idempotency/) replays the first answer and is not charged twice. Two refusals before a call runs: - `402` [`insufficient_credits`](https://docs.accountcloud.io/guides/errors/#insufficient_credits) when the credit does not cover the price, with the `price` and your `available` credit; - `429` [`monthly_cap_reached`](https://docs.accountcloud.io/guides/errors/#monthly_cap_reached) when the call would pass the key's monthly spending cap. ## What a call cost Every answer to a request whose key was accepted says what it cost: | Header | What it is | |---|---| | `AccountCloud-Charged` | What this call cost, in euros, with at least four decimals: `0.0350`. `0.0000` for a free call, an error or a test key. | | `AccountCloud-Balance` | With a live key, after a charged call: the credit you have left, in euros. Not sent when nothing was charged. | ```http HTTP/1.1 200 OK AccountCloud-Charged: 0.0350 AccountCloud-Balance: 184.20 X-Request-Id: req_01jbxk3v6c9m2q8r4t5w7y0z1a ``` `GET /v1/me` gives your balance at any time. The portal shows the usage and costs per day, operation and key, and every movement of your credit. ## Topping up Top up in the portal, from €10 up to €10,000 at a time (your first top-up at most €500). You pay through Mollie; VAT is added where it applies, and each top-up gets an invoice. Automatic top-up tops up for you when the credit falls below an amount you choose. When a charge takes your credit below your alert threshold (€2 unless you set another), you get an e-mail and a [`credit.low_balance`](https://docs.accountcloud.io/guides/webhooks/#event-types) webhook. --- # Addresses Source: https://docs.accountcloud.io/guides/addresses/ Address forms, validation and formatting for 34 countries. Three operations for addresses in 34 countries: the EU, the EEA, Switzerland, the United Kingdom, Turkey and the United Arab Emirates. | Operation | Endpoint | What it does | |---|---|---| | `address.forms` | `GET /v1/address/forms`, `GET /v1/address/forms/{country}` | The fields of an address in a country, which are required, the postcode and the subdivisions. | | `address.validate` | `POST /v1/address/validate` | Checks an address against its country and returns it normalised. | | `address.format` | `POST /v1/address/format` | Writes an address as the lines its country prints it on. | A key needs the scope `address`, or the operation itself (see [scopes](https://docs.accountcloud.io/guides/authentication/#what-a-key-may-do)). **Availability.** `address.forms` is live and free. `address.validate` and `address.format` are in preview and have no price yet: a live key gets `503` [`operation_unavailable`](https://docs.accountcloud.io/guides/errors/#operation_unavailable) until they have one. Test keys already get real answers from all three, for free. Current prices are in [`GET /v1/pricing`](https://docs.accountcloud.io/guides/pricing/). The countries: `AE` `AT` `BE` `BG` `CH` `CY` `CZ` `DE` `DK` `EE` `ES` `FI` `FR` `GB` `GR` `HR` `HU` `IE` `IS` `IT` `LI` `LT` `LU` `LV` `MT` `NL` `NO` `PL` `PT` `RO` `SE` `SI` `SK` `TR`. ## Address forms ```sh curl https://api.accountcloud.io/v1/address/forms/NL \ -H "Authorization: Bearer ac_test_…" ``` ```json { "country": "NL", "verified": true, "fields": [ { "id": "street", "label": "Street", "type": "text", "required": true, "max_length": 255, "options": null, "options_source": null, "example": null, "autocomplete": "address-line1", "layout": { "row": 1, "column": 1, "span": 8 } }, { "id": "postal_code", "label": "Postal code", "type": "text", "required": true, "max_length": 20, "options": null, "options_source": null, "example": "1234 AB", "autocomplete": "postal-code", "layout": { "row": 3, "column": 5, "span": 4 } } ], "postcode": { "pattern": "[1-9]\\d{3} ?(?:[A-RT-Z][A-Z]|S[BCE-RT-Z])", "example": "1234 AB" }, "subdivisions": [] } ``` The example is shortened: NL has six fields (`street`, `house_number`, `house_number_addition`, `address_line_2`, `postal_code` and `city`). - `fields` are in the order to ask them. A field's `id` is its key in the `address` of the other two operations. - `type` is `text`, or `select` for a closed list: its `options`, or the country's `subdivisions` when `options_source` is `subdivisions`. - `autocomplete` is the HTML `autocomplete` token. `layout` places the field on a 12-column grid. - `postcode.pattern` is a regular expression for the normalised postcode; `null` where a country has no postcodes. - `subdivisions` are the provinces, states or regions, where the country uses them. Send the `code`. - `verified` says whether the form was checked against the rules of the country's postal operator. Add `?locale=nl` for Dutch labels; the default is `en`. `GET /v1/address/forms` returns the forms of all countries in `data`, or only those in `?countries=NL,BE,DE`. A country we do not support gets `404` [`country_not_supported`](https://docs.accountcloud.io/guides/errors/#country_not_supported), with the list in `supported_countries`. ## Validate an address Send `country` and the fields of that country's form, by `id`: ```sh curl https://api.accountcloud.io/v1/address/validate \ -H "Authorization: Bearer ac_test_…" \ -H "Content-Type: application/json" \ -d '{"address": {"country": "NL", "street": "Hoofdstraat", "house_number": "12", "postal_code": "1234ab", "city": "Amsterdam"}}' ``` ```json { "valid": true, "address": { "country": "NL", "street": "Hoofdstraat", "house_number": "12", "postal_code": "1234 AB", "city": "Amsterdam" }, "errors": [] } ``` `address` is the address normalised: trimmed, empty fields left out, the postcode in its national form and a subdivision as its code. Store that one. An incomplete or wrong address is still a `200`, with `valid: false` and what is wrong per field: ```json { "valid": false, "address": { "country": "NL", "street": "Hoofdstraat", "postal_code": "12345", "city": "Amsterdam" }, "errors": [ { "field": "postal_code", "code": "postcode_invalid", "message": "This is not a valid postcode in NL, for example 1234 AB." } ] } ``` The `code` of an error is `required`, `too_long`, `postcode_invalid`, `subdivision_invalid`, `invalid_option` or `unknown_field`. A missing or unsupported `country`, or a field that is not a string, is a `422` [`validation_failed`](https://docs.accountcloud.io/guides/errors/#validation_failed). ## Format an address ```sh curl https://api.accountcloud.io/v1/address/format \ -H "Authorization: Bearer ac_test_…" \ -H "Content-Type: application/json" \ -d '{"address": {"country": "NL", "street": "Hoofdstraat", "house_number": "12", "postal_code": "1234ab", "city": "Amsterdam"}, "from_country": "BE"}' ``` ```json { "country": "NL", "lines": ["Hoofdstraat 12", "1234 AB Amsterdam", "Netherlands"], "text": "Hoofdstraat 12\n1234 AB Amsterdam\nNetherlands" } ``` The lines follow the convention of the address's country, not the sender's, without the name of the recipient. A valid postcode is normalised first. - `from_country` is the country the letter is sent from. When it differs from the address's country, the country line is added. Leave it out for a domestic address. - `locale` is the language of the country line, such as `nl` for `Nederland`. The default is `en`. Unlike validation, a field that the country's form does not have is a `422` [`validation_failed`](https://docs.accountcloud.io/guides/errors/#validation_failed) here. ## Source The address forms are partly based on the address data of Google's [libaddressinput](https://github.com/google/libaddressinput), licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), with our own changes per country. --- # Documents (OCR and UBL) Source: https://docs.accountcloud.io/guides/documents/ Send invoices and receipts, read what is on them as JSON and UBL, and report a wrong result. Send a purchase invoice or a receipt as a PDF, JPEG or PNG. AccountCloud reads it and gives you what is on it as JSON, in the line of EN 16931, and as UBL. A document is processed in one of two ways: | `processing` | What happens | Ready | |---|---|---| | `automatic` (default) | Read by OCR. | Expected within 2 minutes. | | `verified` | Read by OCR, then checked and corrected by a processor, who approves it only when its UBL passes the validation. | Within 24 working hours. | OCR is not error-free. Check what you use, especially with `automatic`: the [warnings](#warnings) and the [confidence](#confidence) help you decide which documents a person should look at. | Operation | Endpoint | Scope | |---|---|---| | `ocr.extract` | `POST /v1/documents` with `processing: automatic` | `ocr` | | `ubl.verified` | `POST /v1/documents` with `processing: verified` | `ubl` | | `ocr.read` | `GET /v1/documents`, `GET /v1/documents/{id}`, `DELETE /v1/documents/{id}` | `ocr` | | `ubl.generate` | `GET /v1/documents/{id}/ubl` | `ubl` | | `ocr.feedback` | `POST /v1/documents/{id}/feedback` | `ocr` | | `ubl.validate` | `POST /v1/ubl/validate` | `ubl` | A key needs the scope of the product, or the operation itself (see [scopes](https://docs.accountcloud.io/guides/authentication/#what-a-key-may-do)). A key that sends verified documents and reads them back needs both, for example `ubl` and `ocr`. Only sending a document costs money. Reading, listing and deleting documents, downloading the UBL, validating a UBL file and reporting a wrong result are free. See [what it costs](#what-it-costs). All six operations are in preview: their `status` in [`GET /v1/pricing`](https://docs.accountcloud.io/guides/pricing/) is `preview`. ## Send a document As JSON, with the file in base64: ```sh curl https://api.accountcloud.io/v1/documents \ -H "Authorization: Bearer ac_test_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6f1c2e0a-4b7d-4e59-9a51-0c3f8f2d7b14" \ -d '{ "file": { "name": "invoice-2026-118.pdf", "content_base64": "JVBERi0xLjcK…" }, "processing": "automatic", "output": ["json", "ubl"], "own_party": { "name": "Example Bakery B.V.", "vat_number": "NL000000000B01", "country_code": "NL", "street": "Bakkerstraat", "house_number": "1", "postal_code": "1000 AA", "city": "Amsterdam" }, "reference": "inbox-48213", "metadata": { "administration": "8842" } }' ``` Or as multipart, with the same fields as form fields: ```sh curl https://api.accountcloud.io/v1/documents \ -H "Authorization: Bearer ac_test_…" \ -H "Idempotency-Key: 6f1c2e0a-4b7d-4e59-9a51-0c3f8f2d7b14" \ -F "file=@invoice-2026-118.pdf" \ -F "processing=automatic" \ -F "output[]=json" -F "output[]=ubl" \ -F "own_party[name]=Example Bakery B.V." \ -F "own_party[vat_number]=NL000000000B01" \ -F "reference=inbox-48213" \ -F "metadata[administration]=8842" ``` | Field | What it is | |---|---| | `file` | Required. In JSON an object with `name` (at most 255 characters) and `content_base64`, where a prefix such as `data:application/pdf;base64,` is allowed. In multipart a file field. At most 10 MB. The name only counts for test mode and display: the type is decided from the content. | | `processing` | `automatic` (default) or `verified`. | | `type` | A hint: `purchase_invoice`, `receipt` or `auto` (default). The OCR does not use it; with `verified` the processor sees it. | | `direction` | `purchase` (default) or `sales`. On a purchase document `own_party` is the buyer, on a sales document the seller. | | `output` | `["json"]` (default) or `["json", "ubl"]`. With `ubl` the UBL is made and validated as soon as the document is completed; otherwise on the first download. | | `country_hint` | A hint, ISO 3166-1 alpha-2. The OCR does not use it; with `verified` the processor sees it. | | `own_party` | The party you process the document for: `name`, `vat_number`, `registration_number`, `country_code`, `street`, `house_number`, `postal_code` and `city`, all optional. It helps the OCR tell the supplier and the customer apart, and becomes the buyer (with `sales`, the seller) in the UBL. | | `accepted_currencies` | Up to 20 ISO 4217 codes. With `verified`, a document in another currency is rejected with `currency_not_accepted`. | | `reference` | Your own id for the document, at most 255 characters. The list can be filtered on it. | | `metadata` | Up to 20 keys of 1 to 40 letters, digits, `_`, `.`, `:` or `-`, with text values of at most 500 characters. Comes back unchanged on the document and in every webhook. | The answer is `202 Accepted`, with the new document and its URL in `Location`: ```http HTTP/1.1 202 Accepted Location: /v1/documents/doc_01jbx6y4q9t2m8rz1v3k5n7p0a ``` ```json { "id": "doc_01jbx6y4q9t2m8rz1v3k5n7p0a", "object": "document", "status": "extracting", "processing": "automatic", "environment": "test", "reference": "inbox-48213", "metadata": { "administration": "8842" }, "file": { "name": "invoice-2026-118.pdf", "mime_type": "application/pdf", "size_bytes": 182311, "pages": 2, "sha256": "9f2c4e7a…" }, "duplicate_of": null, "expected_ready_at": "2026-11-04T09:16:12Z", "created_at": "2026-11-04T09:14:12Z", "extracted_at": null, "completed_at": null, "failure": null, "result": null, "confidence": null, "warnings": [], "verification": null, "rejection": null, "ubl": { "available": false, "href": "/v1/documents/doc_01jbx6y4q9t2m8rz1v3k5n7p0a/ubl", "valid": null, "profile": "peppol-bis-billing-3.0", "errors": [] }, "charge": { "operation": "ocr.extract", "status": "none", "amount": "0.0000", "currency": "EUR", "live_amount": "0.0350" }, "content_available": true, "content_purged_at": null, "retention_until": "2026-11-05T09:14:12Z" } ``` This is a test document. With a live key a document starts as `queued`, its price is reserved (`charge.status: reserved`), and `retention_until` is set once the document has its outcome. `expected_ready_at` is the time of sending plus two minutes for `automatic`, and the deadline for `verified`. ### Checks at intake The file is checked before anything is stored, with a test key too. A request that fails a check costs nothing. - No file: `422` [`file_missing`](https://docs.accountcloud.io/guides/errors/#file_missing). - Content that is not base64: `422` [`invalid_base64`](https://docs.accountcloud.io/guides/errors/#invalid_base64). - More than 10 MB after decoding: `413` [`file_too_large`](https://docs.accountcloud.io/guides/errors/#file_too_large), with `max_bytes`. - Not a PDF, JPEG or PNG, judged by the first bytes of the file: `415` [`unsupported_media_type`](https://docs.accountcloud.io/guides/errors/#unsupported_media_type). The file name and a declared type do not count. - A PDF that asks for a password or cannot be opened: `422` [`encrypted_pdf`](https://docs.accountcloud.io/guides/errors/#encrypted_pdf). More than 50 pages: `422` [`too_many_pages`](https://docs.accountcloud.io/guides/errors/#too_many_pages), with `max_pages` and `pages`. - An option with a value that is not allowed: `422` [`invalid_option`](https://docs.accountcloud.io/guides/errors/#invalid_option), with the option in `field`. Any other invalid field: `422` [`validation_failed`](https://docs.accountcloud.io/guides/errors/#validation_failed). - `verified` while it is switched off for your account: `403` [`verified_not_enabled`](https://docs.accountcloud.io/guides/errors/#verified_not_enabled). See also the [daily maximum](#verified-processing). ### Duplicates and retries **The same file again.** Within the same account, environment, entity and `processing`, the same file gives `200` with the earlier document instead of a new one. Its `duplicate_of` is then set, to that document's own id. The answer costs nothing; the `charge` in it is still that of the earlier document. It is not a duplicate when the earlier document `failed`, was deleted, or its content is gone after the [retention period](#retention-and-deleting), and neither is a file you sent as `automatic` before and now send as `verified`. To have the same file read again, delete the earlier document first. The SHA-256 of the file is in `file.sha256`. **Entities.** When you send documents for several administrations or customers of your own, send the `AccountCloud-Entity` header with your id for it (1 to 255 visible characters). Duplicates are then found per entity, the list can be narrowed to it, and charges are recorded per entity. **Retrying a request.** Send an `Idempotency-Key` with every `POST`. A retry with the same key and the same request gets the first answer again, for 24 hours, with `Idempotency-Replayed: true`, and is never charged twice. The same key with a different request (with multipart: a different file, too) gets `409` [`idempotency_key_reused`](https://docs.accountcloud.io/guides/errors/#idempotency_key_reused). Only a `2xx` answer is kept, so a retry after an error is processed anew. See [idempotency](https://docs.accountcloud.io/guides/idempotency/). ## Statuses | Status | What it means | |---|---| | `queued` | Accepted, and waiting to be read. | | `extracting` | Being read. When the OCR is busy or down, it is tried again after 2, 5, 15, 30 and 60 minutes, and the status stays `extracting`. | | `awaiting_review` | Only `verified`: read, and waiting for a processor. | | `in_review` | Only `verified`: a processor has it open. It goes back to `awaiting_review` when they let it go. | | `completed` | Done. With `verified`: approved by a processor. | | `failed` | Only `automatic`: the document could not be read. With `failure.code`. | | `rejected` | Only `verified`: a processor rejected it. With `rejection`. | `completed`, `failed` and `rejected` are final. The `failure.code` of a failed document: | Code | Why | |---|---| | `extraction_failed` | The OCR failed in a way that trying again does not fix. | | `document_unreadable` | The OCR could not read the file. | | `provider_unavailable` | The OCR was still unavailable after the last retry. | A failed document costs nothing, and you can send the file again. A `verified` document never fails: when the OCR fails, it still goes to a processor, who fills it in by hand, and it gets the warning `extraction_failed`. ## Getting the result ### Webhooks Add a webhook endpoint in the portal (see [webhooks](https://docs.accountcloud.io/guides/webhooks/)) and pick the `document.*` types: | Type | When | |---|---| | `document.extracted` | Only `verified`: the document was read and waits for a processor. With `expected_ready_at`. | | `document.completed` | The document is done. With `verified`: a processor approved it. | | `document.failed` | Only `automatic`: the document could not be read. With `failure`. | | `document.rejected` | Only `verified`: a processor rejected it. With `rejection`. | The payload is thin: ids, the status, your `reference` and `metadata`, and the charge. Never the contents of the document. Fetch the result with `GET /v1/documents/{id}`. ```json { "type": "document.completed", "timestamp": "2026-11-04T09:14:16Z", "data": { "id": "doc_01jbx6y4q9t2m8rz1v3k5n7p0a", "status": "completed", "processing": "automatic", "reference": "inbox-48213", "metadata": { "administration": "8842" }, "charge": { "operation": "ocr.extract", "amount": "0.0350", "currency": "EUR" } } } ``` `charge` is what the document costs at that moment; for a test document it also has `live_amount`. A document's events go to the endpoints of its environment: those of a test document to your test endpoints. ### Polling `GET /v1/documents/{id}` is free, but counts towards your [rate limits](https://docs.accountcloud.io/guides/authentication/#rate-limits). `expected_ready_at` says when to expect the outcome. ## The document ```sh curl https://api.accountcloud.io/v1/documents/doc_01jbx6y4q9t2m8rz1v3k5n7p0a \ -H "Authorization: Bearer ac_test_…" ``` ```json { "id": "doc_01jbx6y4q9t2m8rz1v3k5n7p0a", "object": "document", "status": "completed", "processing": "automatic", "environment": "test", "reference": "inbox-48213", "metadata": { "administration": "8842" }, "file": { "name": "invoice-2026-118.pdf", "mime_type": "application/pdf", "size_bytes": 182311, "pages": 2, "sha256": "9f2c4e7a…" }, "duplicate_of": null, "expected_ready_at": "2026-11-04T09:16:12Z", "created_at": "2026-11-04T09:14:12Z", "extracted_at": "2026-11-04T09:14:15Z", "completed_at": "2026-11-04T09:14:15Z", "failure": null, "result": { "document_type": "invoice", "direction": "purchase", "invoice_number": "TEST-2026-0001", "issue_date": "2026-11-02", "due_date": "2026-12-02", "currency": "EUR", "order_reference": "PO-TEST-1", "customer_reference": null, "supplier": { "name": "Testleverancier B.V.", "vat_number": "NL000000000B02", "registration_number": "00000002", "iban": "NL91ABNA0417164300", "bic": null, "email": "facturen@testleverancier.example", "phone": null, "website": null, "address": { "street": "Leveranciersweg", "house_number": "2", "postal_code": "1000 AB", "city": "Amsterdam", "country_code": "NL" } }, "customer": { "name": "Testafnemer B.V.", "vat_number": "NL000000000B01", "matches_own_party": false }, "lines": [ { "description": "Advieswerk oktober", "quantity": "1", "unit_price": "100.00", "vat_rate": "21.00", "amount_excl_vat": "100.00", "verified": false }, { "description": "Vakboeken", "quantity": "2", "unit_price": "25.00", "vat_rate": "9.00", "amount_excl_vat": "50.00", "verified": false } ], "vat_breakdown": [ { "rate": "21.00", "taxable_amount": "100.00", "vat_amount": "21.00" }, { "rate": "9.00", "taxable_amount": "50.00", "vat_amount": "4.50" } ], "totals": { "amount_excl_vat": "150.00", "vat_amount": "25.50", "amount_incl_vat": "175.50" } }, "confidence": { "document": 0.95, "fields": { "invoice_number": 0.99, "issue_date": 0.99, "supplier.name": 0.98, "supplier.vat_number": 0.99, "totals.amount_incl_vat": 0.99, "totals.vat_amount": null } }, "warnings": [], "verification": null, "rejection": null, "ubl": { "available": true, "href": "/v1/documents/doc_01jbx6y4q9t2m8rz1v3k5n7p0a/ubl", "valid": true, "profile": "peppol-bis-billing-3.0", "errors": [] }, "charge": { "operation": "ocr.extract", "status": "none", "amount": "0.0000", "currency": "EUR", "live_amount": "0.0350" }, "content_available": true, "content_purged_at": null, "retention_until": "2026-11-05T09:14:12Z" } ``` | Field | What it is | |---|---| | `status`, `processing`, `environment` | See [statuses](#statuses). `environment` is `live` or `test`. | | `file` | `name`, `mime_type` (from the content), `size_bytes`, `pages` and `sha256`. | | `duplicate_of` | Only set in the answer to a duplicate `POST`. | | `expected_ready_at` | `automatic`: the time of sending plus two minutes. `verified`: the deadline. | | `created_at`, `extracted_at`, `completed_at` | When the document was sent, read and completed. | | `failure` | Only with `failed`: the `code`. | | `result` | What was read; see below. `null` until the document is read, with `verified` until a processor approved it, and after the retention period. | | `confidence` | See [confidence](#confidence). | | `warnings` | See [warnings](#warnings). | | `verification` | Only `verified`: whether a processor checked the document (`verified`, `verified_at`), and which fields they did not check (`unverified_fields`). | | `rejection` | Only with `rejected`: the `reason` and a `message`. | | `ubl` | `available`: whether the document is completed and its content still there; `href`: the download; `valid` and `errors`: the outcome of the validation, once the UBL was made; `profile`. See [the UBL](#the-ubl). | | `charge` | See [what it costs](#what-it-costs). | | `content_available`, `content_purged_at`, `retention_until` | See [retention](#retention-and-deleting). | ### The result Amounts are decimal strings in the currency of the document, with two decimals (`"45.00"`); rates too (`"21.00"`). Any value can be `null` when it was not found. | Field | What it is | |---|---| | `document_type` | What the document was read as: `invoice`, `proforma`, `receipt`, `credit_note`, `quote`, `order`, `packing_slip`, `contract`, `bank_statement`, `reminder` or `other`. | | `direction` | `purchase` or `sales`, as you sent it. | | `invoice_number`, `issue_date`, `due_date` | Dates in ISO 8601 (`2026-11-02`). | | `currency` | ISO 4217. | | `order_reference`, `customer_reference` | The order number and the customer number on the document. | | `supplier` | `name`, `vat_number`, `registration_number`, `iban`, `bic`, `email`, `phone`, `website`, and the `address` (`street`, `house_number`, `postal_code`, `city`, `country_code`). | | `customer` | `name`, `vat_number`, and `matches_own_party`: whether the customer on the document is the `own_party` you sent. | | `lines` | `description`, `quantity`, `unit_price`, `vat_rate`, `amount_excl_vat`, and `verified`, which is always `false`: no processor checks the lines. | | `vat_breakdown` | Per VAT rate: `rate`, `taxable_amount` and `vat_amount`. | | `totals` | `amount_excl_vat`, `vat_amount` and `amount_incl_vat`. | The field names are also the paths you use when you [report a wrong result](#report-a-wrong-result). ### Confidence `confidence.fields` has, per path of the result, how sure the OCR was of the text that value came from, from `0` to `1`. A value that was calculated or normalised rather than read from one place has `null`. `confidence.document` is a rough score for the whole document: it mainly says whether the amounts add up and the main fields were found on the page. Neither number is calibrated: `0.95` does not mean that 95 out of 100 such values are right. Use them to sort, for example to pick the documents a person looks at first, not as a probability. With `verified`, `confidence` is that of the OCR, before the processor checked it. ### Warnings | Warning | What it means | |---|---| | `totals_do_not_reconcile` | The subtotal plus the VAT is not the total, the lines do not add up to the subtotal, or a VAT amount does not fit its rate and base. | | `vat_breakdown_derived` | A taxable amount per rate was not on the document, and was calculated from its VAT amount. | | `roles_swapped` | Supplier and customer look swapped: the document labels them the other way round, or on a `sales` document your `own_party` came out as the customer. | | `multiple_documents_suspected` | The file seems to hold more than one invoice. A suspicion only: this cannot be detected reliably. | | `text_truncated` | The document has more lines of text than were read. Check the lines and the totals. | | `extraction_failed` | Only `verified`: the OCR failed, so the processor filled the document in by hand. | ### Boxes `GET /v1/documents/{id}?include=boxes` adds `boxes`: where the values stand on the page, for example to draw them over the PDF. ```json { "boxes": { "header": [ { "key": "invoice_number", "value": "2026-000481", "confidence": 0.991, "page": 0, "vertices": [[0.61, 0.12], [0.74, 0.12], [0.74, 0.14], [0.61, 0.14]] } ], "lines": [ { "key": "title", "line_index": 0, "value": "Mobiele telefonie oktober", "confidence": 0.987, "page": 0, "vertices": [[0.08, 0.41], [0.36, 0.41], [0.36, 0.43], [0.08, 0.43]] } ] } } ``` - `page` starts at `0`. `vertices` are the four corners, as `[x, y]` from `0` to `1` of the width and height of the page, from the top left. - `key` names the value as the OCR found it: in `header` keys such as `invoice_number`, `document_date` and `total_amount`, and keys starting with `supplier_`, `customer_`, `tax_` and `payment_`; in `lines` keys such as `title`, `quantity`, `amount_each` and `vat_percentage`, with the position of the line in `line_index`. These keys are not the paths of `result`. A value can have more than one box, when it is on the page more than once. - `boxes` is empty for a test document and after the retention period. ## List documents ```sh curl "https://api.accountcloud.io/v1/documents?status=completed&limit=50" \ -H "Authorization: Bearer ac_test_…" ``` ```json { "object": "list", "data": [ { "id": "doc_01jbx6y4q9t2m8rz1v3k5n7p0a", "object": "document", "status": "completed", "processing": "automatic", "reference": "inbox-48213", "metadata": { "administration": "8842" }, "file": { "name": "invoice-2026-118.pdf", "mime_type": "application/pdf", "size_bytes": 182311, "pages": 2, "sha256": "9f2c4e7a…" }, "failure": null, "rejection": null, "charge": { "operation": "ocr.extract", "status": "none", "amount": "0.0000", "currency": "EUR", "live_amount": "0.0350" }, "expected_ready_at": "2026-11-04T09:16:12Z", "created_at": "2026-11-04T09:14:12Z", "completed_at": "2026-11-04T09:14:15Z", "content_available": true } ], "has_more": false, "next_cursor": null } ``` Newest first, without the result. Pass `next_cursor` as `cursor` for the next page. With an `AccountCloud-Entity` header, only that entity's documents. | Parameter | What it does | |---|---| | `status` | Only documents with this status. | | `processing` | `automatic` or `verified`. | | `reference` | Only documents with this `reference`. | | `created_after`, `created_before` | ISO 8601. From and including `created_after`, up to `created_before`. | | `limit` | 1 to 100; 25 by default. | | `cursor` | The `next_cursor` of the previous page. | ## The UBL ### Download the UBL ```sh curl https://api.accountcloud.io/v1/documents/doc_01jbx6y4q9t2m8rz1v3k5n7p0a/ubl \ -H "Authorization: Bearer ac_test_…" \ -o doc_01jbx6y4q9t2m8rz1v3k5n7p0a.xml ``` The answer is UBL 2.1 according to Peppol BIS Billing 3.0, as `application/xml`. Every UBL we make is validated; the outcome is on the document, in `ubl.valid` and `ubl.errors`. A UBL that does not pass can still be downloaded. - Before the document is `completed`: `409` [`document_not_ready`](https://docs.accountcloud.io/guides/errors/#document_not_ready). The same code when no UBL could be made from the result at all; `ubl.errors` on the document then says why. - A rejected document has no UBL: `409` [`document_rejected`](https://docs.accountcloud.io/guides/errors/#document_rejected). - After the retention period: `410` [`content_purged`](https://docs.accountcloud.io/guides/errors/#content_purged). **With `automatic`**, the UBL is made from the result: when the document completes if you asked for `ubl` in `output`, otherwise on the first download. - On a purchase document the supplier is the seller and your `own_party` the buyer; on a sales document your `own_party` is the seller and the customer the buyer. Without `own_party` the buyer only has the name and VAT number on the document, so the UBL usually does not pass the validation. Send `own_party` with its address when you need a valid UBL. - The lines of the result go into the UBL when they add up, per VAT rate and in total, to the VAT breakdown. Otherwise the UBL has one line per VAT rate. - A 0% rate becomes "zero rated", or "reverse charge" when the document says so. Intra-community supplies and exports are not assumed from the text of a document. - A credit note becomes a UBL `CreditNote`, with positive amounts. - The rates, amounts and totals are those of the result; nothing is recalculated, except where a breakdown is missing. **With `verified`**, the UBL is made from the values the processor checked, with one line per VAT rate, and the processor can only approve it when it passes the validation. ### Validate a UBL file `POST /v1/ubl/validate` checks a UBL 2.1 `Invoice` or `CreditNote` of yours against the UBL schema, EN 16931 and Peppol BIS Billing 3.0. Send it as JSON with base64, or as multipart, at most 10 MB: ```sh curl https://api.accountcloud.io/v1/ubl/validate \ -H "Authorization: Bearer ac_test_…" \ -F "file=@invoice.xml" ``` ```json { "valid": false, "profile": "peppol-bis-billing-3.0", "complete": true, "engine": "saxon", "engine_error": null, "artifacts": { "ubl": "2.1", "peppol_bis": "3.0.20", "cen_schematron": "1.3.15" }, "errors": [ { "rule": "BR-CO-15", "level": "fatal", "location": "/Invoice", "message": "Invoice total amount with VAT (BT-112) = Invoice total amount without VAT (BT-109) + Invoice total VAT amount (BT-110)." } ] } ``` - An invalid file is still a `200`, with `valid: false`. `valid` is `true` when no finding is `fatal`. - `errors` lists every finding, `fatal` and `warning`: the `rule` (such as `BR-CO-15` or `PEPPOL-EN16931-R001`; `UBL-XSD` for the schema; `UBL-XML` for a file that is not well-formed XML, contains a `DOCTYPE`, or is not an `Invoice` or `CreditNote`), its `level`, the `location` as an XPath, and the text of the rule. - `complete` is `true` when the full official rule sets ran (`engine: saxon`). When it is `false`, only the schema and our own subset of the business rules ran (`engine: php-subset`), and `valid` means "no rule we check is broken", not "passes the official validator". - `artifacts` has the versions of UBL, Peppol BIS Billing and the EN 16931 schematron we validate against. - When the validator itself is down, the answer is `503` [`service_unavailable`](https://docs.accountcloud.io/guides/errors/#service_unavailable). As JSON, send `{"file": {"content_base64": "…"}}`; a `name` is optional. ## Verified processing With `processing: verified`, a processor of our team checks and corrects what the OCR read, next to the document. They can only approve it when all of this holds: - the VAT breakdown adds up to the total, to the cent, and there is at least one VAT line; - the supplier has a name, an IBAN passes its check digits, and a VAT number has the format of its country (it is not checked at VIES); - there is an invoice number and a valid invoice date, and the due date is not before it; - the currency is an ISO 4217 code, and one of your `accepted_currencies` if you sent them; - the UBL made from it passes the UBL schema, EN 16931 and Peppol BIS Billing 3.0. The `result` of an approved document has the values the processor checked: the supplier, the header, the VAT breakdown and the totals. Not checked: - **the lines.** `verification.unverified_fields` is `["lines"]`, every line keeps `verified: false`, and the UBL has one line per VAT rate instead of the lines of the invoice; - **the customer**, which stays as the OCR read it. In the UBL of a purchase document, the buyer is your `own_party`, completed with what the OCR read. ### The deadline `expected_ready_at` is the deadline: 24 working hours after you sent the document. Working hours run on Monday to Friday, Amsterdam time, and not on holidays: a document sent on Friday at 15:00 is due on Monday at 15:00, one sent on Saturday on Tuesday at 00:00. An account has a daily maximum of verified documents, 100 unless agreed otherwise. Above it, a document gets the deadline of the next working day, and so on per block of that size, so `expected_ready_at` shows the real expectation. At five times the maximum on one working day, a new verified document gets `429` [`verified_daily_limit`](https://docs.accountcloud.io/guides/errors/#verified_daily_limit), with `daily_max`. ### Rejection A processor can reject a document, with one of these reasons in `rejection.reason`, and their note or a standard text in `rejection.message`: | Reason | What it means | |---|---| | `not_readable` | The document could not be read. | | `not_an_invoice` | The document is not an invoice or a receipt. | | `multiple_documents` | The file holds more than one document. | | `invoice_requirements` | The document does not meet the requirements of an invoice. | | `duplicate` | This invoice was processed before: the same number from the same supplier. | | `currency_not_accepted` | The currency is not one of your `accepted_currencies`. Only when you sent them. | A rejected document has no UBL and no `result`. What a rejection costs is under [what it costs](#what-it-costs). ## Retention and deleting We keep the content of a document (the file, the result and the UBL) for a limited time. `retention_until` says until when. - **Live documents:** until the retention period of your account after the outcome (`completed`, `failed` or `rejected`). The period is 7 days unless an owner or admin sets 0 to 30 days in the portal. A change applies to documents that get their outcome after it. At 0 days the content is deleted right after the outcome, so there is hardly time to fetch the result. - **Test documents:** 24 hours after you sent them. After that the document is still there, with `content_available: false`, `content_purged_at`, and no `result`, `confidence` or file name. The UBL then answers `410` [`content_purged`](https://docs.accountcloud.io/guides/errors/#content_purged). The status, the times, the charge, your `reference` and `metadata`, and the file's type, size, pages and SHA-256 stay. **Delete a document** to remove its content at once: ```sh curl -X DELETE https://api.accountcloud.io/v1/documents/doc_01jbx6y4q9t2m8rz1v3k5n7p0a \ -H "Authorization: Bearer ac_test_…" ``` ```json { "id": "doc_01jbx6y4q9t2m8rz1v3k5n7p0a", "object": "document", "deleted": true } ``` After that, every call on the document answers `404` [`document_not_found`](https://docs.accountcloud.io/guides/errors/#document_not_found). We keep its metadata (hash, pages, cost, times and outcome) for our statistics. A verified document that is not done yet is taken off the processors' queue; it costs the OCR price when it was read already, otherwise nothing. ## What it costs A live document is charged once, at its outcome: | Outcome | `automatic` | `verified` | |---|---|---| | Refused at intake (`4xx`), or a duplicate | Free | Free | | `completed` | The OCR price | The verified price | | `failed` | Free | — | | `rejected` as a `duplicate` | — | Free | | `rejected` for another reason, after the OCR read it | — | The OCR price | | `rejected`, and the OCR could not read it | — | Free | | Deleted before the outcome | Free | The OCR price when the OCR read it, otherwise free | The start prices are €0.0350 for the OCR (`ocr.extract`) and €0.3500 for verified (`ubl.verified`), excluding VAT. The current ones are in [`GET /v1/pricing`](https://docs.accountcloud.io/guides/pricing/). A document pays the price that applied when you sent it. - When you send a live document, its full price is reserved on your credit. When the credit does not cover it, the answer is `402` [`insufficient_credits`](https://docs.accountcloud.io/guides/errors/#insufficient_credits). - The `202` itself has `AccountCloud-Charged: 0.0000`: the price is taken at the outcome, and you find it in `charge` on the document and in its webhook. - `charge.status` is `reserved` while the document is processed, `captured` once it is charged, and `released` when its outcome costs nothing (`operation` is then `null`). A test document has `none`, an `amount` of `0.0000`, and in `live_amount` what it would cost live. ## Report a wrong result When the OCR read a document wrong, tell us which fields: ```sh curl https://api.accountcloud.io/v1/documents/doc_01jbx6y4q9t2m8rz1v3k5n7p0a/feedback \ -H "Authorization: Bearer ac_test_…" \ -H "Content-Type: application/json" \ -d '{ "result": "incorrect", "fields": [ { "field": "totals.vat_amount", "expected": "21.00" }, { "field": "supplier.vat_number", "expected": "NL000000000B02" } ], "comment": "The VAT amount was taken from the wrong line.", "allow_training": true }' ``` ```json { "id": "fbk_01jbx6y4q9t2m8rz1v3k5n7p0b", "object": "feedback", "document_id": "doc_01jbx6y4q9t2m8rz1v3k5n7p0a", "training": "accepted" } ``` | Field | What it is | |---|---| | `allow_training` | Required. `true` lets us add the document to the set we test and improve the OCR with. `false` keeps only the report, without the document. | | `fields` | Up to 50 fields that were wrong. `field` is a path of the [result](#the-result), such as `totals.vat_amount`, `supplier.address.city`, or a line or VAT rate by its index: `lines.1.amount_excl_vat`, `vat_breakdown.0.rate`. `expected` is the value you expected, at most 500 characters. Another path gives `422` [`unknown_field`](https://docs.accountcloud.io/guides/errors/#unknown_field). | | `comment` | At most 2,000 characters. | | `result` | Optional. Always `incorrect`. | | `file` | Only needed after the retention period: the same file again, as `content_base64`. | The answer is `202`, and `training` says what we did: - `accepted`: the document joins the training set. We keep a copy of the file, what the OCR read and your report, for at most 12 months. - `stats_only`: we keep only the report. That is the answer with `allow_training: false`, for every test document, and when your account has switched off sharing for training. In the portal, an owner or admin can switch off sharing documents for training for the whole account: a report with `allow_training: true` is then kept as a report only, and the answer says `stats_only`. There they can also ask to delete every training case of the account. **After the retention period** the original is gone, so a report needs the file again, in `file.content_base64`. Its SHA-256 must be the `file.sha256` of the document, or the answer is `422` [`file_hash_mismatch`](https://docs.accountcloud.io/guides/errors/#file_hash_mismatch). Without the file it is `409` [`feedback_window_closed`](https://docs.accountcloud.io/guides/errors/#feedback_window_closed). A deleted document cannot be reported on. Reporting is free. An account can send 100 reports per UTC day unless agreed otherwise; after that it gets `429` [`feedback_limit_reached`](https://docs.accountcloud.io/guides/errors/#feedback_limit_reached) until 00:00 UTC. A report does not give back the price of the document by itself. ## Test mode With a test key, nothing is read and nothing is charged. A test document goes through the same checks at intake, statuses and webhooks as a live one, and gets a fixed result that depends on its file name: `receipt.*`, `unbalanced.pdf`, `slow.pdf` and `fail.pdf` for `automatic`, `reject.pdf` and `duplicate.pdf` for `verified`. A verified test document is approved or rejected after a minute, without a processor. The full list is in [test mode](https://docs.accountcloud.io/guides/test-mode/). --- # Company data and VAT numbers Source: https://docs.accountcloud.io/guides/companies/ Company profiles from the trade registers of the Netherlands and Belgium, and EU VAT numbers checked at VIES. Three operations to look up a business partner: | Operation | Endpoint | What it does | |---|---|---| | `company.profile` | `GET /v1/companies/{country}/{number}` | The profile of a company from its trade register, by its registration number. | | `company.search` | `GET /v1/companies?country=&q=` | Finds companies in a trade register by name, trade name or number. | | `vat.validate` | `GET /v1/vat-numbers/{vat_number}` | Checks an EU VAT number at VIES, the system of the European Commission. | A key needs the scope `company` or `vat`, or the operation itself (see [scopes](https://docs.accountcloud.io/guides/authentication/#what-a-key-may-do)). **Availability.** All three are in preview. `company.profile` has a price for Dutch companies. `company.search`, `vat.validate` and `company.profile` for Belgian companies have no price yet: a live key gets `503` [`operation_unavailable`](https://docs.accountcloud.io/guides/errors/#operation_unavailable) until they have one. Test keys already get answers from all three, for free; see [test mode](https://docs.accountcloud.io/guides/test-mode/) for the test companies and VAT numbers. Current prices are in [`GET /v1/pricing`](https://docs.accountcloud.io/guides/pricing/). ## The registers | Country | Register | Number | Where the data comes from | |---|---|---|---| | `NL` | KvK Handelsregister | The KvK number: 8 digits. | Asked at the KvK, with our own copy as a cache. | | `BE` | KBO Open Data (Crossroads Bank for Enterprises) | The enterprise number: 10 digits, starting with `0` or `1`. `0403.170.701` and `BE0403170701` are accepted too. | Our copy of the KBO Open Data, updated every day. The KBO is never asked live. | Spaces, dots and dashes in a number are ignored. Another country gets `404` [`country_not_supported`](https://docs.accountcloud.io/guides/errors/#country_not_supported), with `supported_countries`. **Who may see Dutch data.** Dutch company data comes from the KvK, whose terms do not allow passing it on to third parties. It is only available to accounts we have given access. Other accounts get `403` [`register_restricted`](https://docs.accountcloud.io/guides/errors/#register_restricted), with test keys too, so a test behaves as live will. Belgian data is available to every account. ## Get a company ```sh curl https://api.accountcloud.io/v1/companies/BE/0123456749 \ -H "Authorization: Bearer ac_test_…" ``` ```json { "object": "company", "country": "BE", "number": "0123456749", "name": "Testbedrijf NV", "legal_form": "Naamloze vennootschap", "legal_form_code": "014", "trade_names": ["Testbedrijf NV", "Entreprise Test SA"], "vat_number": "BE0123456749", "rsin": null, "establishment_number": null, "address": { "street": "Wetstraat", "house_number": "16", "house_number_suffix": null, "postal_code": "1000", "city": "Brussel", "country": "BE" }, "postal_address": null, "registration_date": "2001-03-15", "end_date": null, "website": "www.testbedrijf.example", "activities": [], "source": { "register": "kbo", "name": "KBO Open Data (Crossroads Bank for Enterprises)", "freshness": "cached", "retrieved_at": "2026-11-03T00:00:00Z", "data_age_days": 1, "notice": "Source: KBO Open Data, FPS Economy (Belgium), data of 2026-11-03." } } ``` | Field | What it is | |---|---| | `number` | The registration number, digits only. | | `name`, `trade_names` | The name, and the names the company trades under, the name itself included. For BE every name in the KBO, in every language. | | `legal_form` | The legal form as the register writes it, in Dutch. `legal_form_code`: for BE the code of the legal form in the KBO; `null` for NL. | | `vat_number` | For BE: `BE` and the enterprise number. `null` for NL, because the KvK does not hold the VAT number. Check a VAT number with [`GET /v1/vat-numbers/{vat_number}`](#check-a-vat-number) before you rely on it. | | `rsin`, `establishment_number` | NL only: the RSIN of the legal entity when the KvK gives it, and the number of the main establishment. | | `address` | The visiting address (NL) or the registered office (BE), with `street`, `house_number`, `house_number_suffix`, `postal_code`, `city` and `country`. The postcode is as the register writes it. | | `postal_address` | NL only: the correspondence address, when it differs. A PO box has the `street` `Postbus`. | | `registration_date`, `end_date` | When the company was registered (NL) or started (BE), and for NL when the registration ended. Ended BE companies are not in the KBO data. | | `website` | When the register has one. | | `activities` | NL only: the SBI activities, with `code`, `description` and `main: true` for the main one. Empty for BE. | | `source` | Where the data comes from and how old it is; see below. | ### `source` | Field | What it is | |---|---| | `register` | `kvk` or `kbo`, and `name` its full name. | | `freshness` | `cached`: from our copy. `live`: from the register, just now. | | `retrieved_at` | When the register gave us this data. For BE the date of the KBO export it is current with. | | `data_age_days` | Whole days since `retrieved_at`. | | `notice` | The attribution the register requires with its data, verbatim. For the KvK: *"Voor actuele informatie met juridische derdenwerking dient u altijd het Handelsregister te raadplegen."* | The registers require it: show `notice` and `retrieved_at` wherever you show their data. ### Fresh or from our copy For NL you choose where the profile may come from, with `freshness`: | `freshness` | Where the profile comes from | |---|---| | `auto` (default) | Our copy when it is younger than `max_age_days`; otherwise the KvK now, which also refreshes our copy. | | `cached` | Only our copy, whatever its age. When we hold no copy: `404` [`company_not_cached`](https://docs.accountcloud.io/guides/errors/#company_not_cached). | | `live` | Always the KvK now. | `max_age_days` (0 to 3650) says how old our copy may be with `auto`. `0` always asks the KvK. Without it, our own refresh period applies, now 30 days. ```sh curl "https://api.accountcloud.io/v1/companies/NL/68750110?freshness=auto&max_age_days=7" \ -H "Authorization: Bearer ac_test_…" ``` BE always comes from our daily copy: `freshness=live` gets `422` [`validation_failed`](https://docs.accountcloud.io/guides/errors/#validation_failed), and `max_age_days` is ignored. When the KvK does not answer, the answer is `503` [`provider_unavailable`](https://docs.accountcloud.io/guides/errors/#provider_unavailable), also with `auto` when we hold an older copy. Nothing is charged. ### Not found A number the register does not know gets `404` [`company_not_found`](https://docs.accountcloud.io/guides/errors/#company_not_found), with `country` and `number`. A number that cannot be one, such as a KvK number without 8 digits, gets `422` [`validation_failed`](https://docs.accountcloud.io/guides/errors/#validation_failed) on `number`. ## Search companies ```sh curl "https://api.accountcloud.io/v1/companies?country=BE&q=testbedrijf" \ -H "Authorization: Bearer ac_test_…" ``` ```json { "object": "list", "data": [ { "object": "company_summary", "country": "BE", "number": "0123456749", "name": "Testbedrijf NV", "city": "Brussel", "href": "/v1/companies/BE/0123456749" } ], "source": { "register": "kbo", "name": "KBO Open Data (Crossroads Bank for Enterprises)", "freshness": "cached", "retrieved_at": "2026-11-03T00:00:00Z", "notice": "Source: KBO Open Data, FPS Economy (Belgium), data of 2026-11-03." } } ``` | Parameter | What it is | |---|---| | `country` | Required: `NL` or `BE`. | | `q` | Required, 2 to 100 characters: (part of) a name or trade name, or a number. For NL an 8-digit KvK number, for BE the enterprise number or its start. | | `limit` | 1 to 100; 10 by default. | - **NL** asks the KvK now (`source.freshness: live`). The KvK answers per establishment; you get one result per KvK number, with the name of the legal entity or main establishment and the place of the main establishment. - **BE** searches our copy of the KBO (`source.freshness: cached`), on name, trade name and the start of the enterprise number, sorted by name. Each result has the `city` when it is known, and in `href` the path of its profile. Ask that for the full profile. Nothing found is an empty `data`. ## Check a VAT number ```sh curl https://api.accountcloud.io/v1/vat-numbers/NL999999990B01 \ -H "Authorization: Bearer ac_test_…" ``` ```json { "object": "vat_number", "vat_number": "NL999999990B01", "country": "NL", "valid": true, "format_valid": true, "name": "TESTBEDRIJF B.V.", "address": "TESTSTRAAT 00001\n1234AB AMSTERDAM", "consultation_number": "WAPITEST00000001", "source": { "register": "vies", "name": "VIES (European Commission)", "retrieved_at": "2026-11-04T09:12:41Z" } } ``` Send the number with its country code, such as `NL123456789B01`. Spaces, dots and dashes are allowed. Every call asks VIES again, at that moment: we never reuse an earlier answer. | Field | What it is | |---|---| | `vat_number` | The number as VIES knows it: upper case, without separators, with its prefix (`EL` for Greece). | | `country` | ISO 3166-1 alpha-2, and `XI` for Northern Ireland. | | `valid` | Whether VIES confirms the number. | | `format_valid` | Whether the number has the format of its country. When it has not, VIES was not asked. | | `name`, `address` | What the member state registered, when it shares it; otherwise `null`. The lines of the address are separated by `\n`. | | `consultation_number` | The number VIES gave for this check: proof that the number was checked at `source.retrieved_at`. Keep it with the invoice, for example for a reverse-charge supply. `null` when VIES gave none. | | `source` | `register` `vies`, its `name`, and `retrieved_at`, the moment of the check. `null` when VIES was not asked. | - **The wrong format** is a `200` with `valid: false` and `format_valid: false`, without asking VIES, and costs nothing. - **A number VIES does not confirm** is a `200` with `valid: false` and `format_valid: true`. - **When VIES or the register of the member state does not answer**, the answer is `503` [`provider_unavailable`](https://docs.accountcloud.io/guides/errors/#provider_unavailable), also free. Try again after `Retry-After`. - **No country code** gets `422` [`validation_failed`](https://docs.accountcloud.io/guides/errors/#validation_failed) on `vat_number`. A country outside the EU, such as `GB`, gets `404` [`country_not_supported`](https://docs.accountcloud.io/guides/errors/#country_not_supported), with `supported_countries`: the EU member states and `XI`. ## What it costs - **`company.profile` for NL** has two prices: from our copy (`source.freshness: cached`), and from the KvK now (`live`). You pay for what you got, so with `auto` that depends on the age of our copy. The start prices are €0.0050 and €0.0250, excluding VAT. Before the call the higher one is reserved on your credit. - **A Dutch number the KvK does not know** costs what the KvK charges us for asking, at cost price, although the answer is a `404` [`company_not_found`](https://docs.accountcloud.io/guides/errors/#company_not_found). `company_not_cached` and any other "not found" are free. - **`company.search`, `vat.validate` and `company.profile` for BE** have no price yet; see *Availability* above. - Errors and refusals are free, and test keys never cost anything. The current prices are in [`GET /v1/pricing`](https://docs.accountcloud.io/guides/pricing/), and every answer says what it cost in `AccountCloud-Charged`. --- # Changelog Source: https://docs.accountcloud.io/changelog/ What changed in the AccountCloud API, newest first. Every change to the API, newest first. The current status and price of each operation is always in [`GET /v1/pricing`](https://docs.accountcloud.io/guides/pricing/). ## 2026-11-04 (Pre-release) The first entry: what exists today. The API is not generally available yet, and until it is, endpoints and fields can still change. Every change will be listed here. ### The platform - **One base URL**, `https://api.accountcloud.io/v1`, with JSON in and out, described by an [OpenAPI 3.1 spec](https://docs.accountcloud.io/openapi/v1.json) and the [API reference](https://docs.accountcloud.io/reference/). - **API keys** for live (`ac_live_…`) and test (`ac_test_…`), with scopes per product or operation, an IP allowlist, a monthly spending cap, an end date, rotation with an overlap, and revoking. See [authentication and keys](https://docs.accountcloud.io/guides/authentication/). - **Rate limits** per key per minute and per account per day, in the `RateLimit-Policy` and `RateLimit` headers. - **Errors** as RFC 9457 problems with a stable `code`; each code has its own link, `https://docs.accountcloud.io/errors/{code}`. See [errors](https://docs.accountcloud.io/guides/errors/). - **`Idempotency-Key`** on every `POST`: a retry within 24 hours gets the first answer and is never charged twice. See [idempotency](https://docs.accountcloud.io/guides/idempotency/). - **Test mode:** fixed test values for every operation, the error paths included, always free. See [test mode](https://docs.accountcloud.io/guides/test-mode/). - **The cost in every answer**, in `AccountCloud-Charged`, and the credit left in `AccountCloud-Balance`. - **The `AccountCloud-Entity` header**, to keep documents and charges apart per customer of yours. - **Webhooks**, signed the Standard Webhooks way: `credit.low_balance`, `credit.auto_top_up_failed`, `subscription.paused`, `document.extracted`, `document.completed`, `document.failed`, `document.rejected` and the test event `ping`, with retries for 24 hours. See [webhooks](https://docs.accountcloud.io/guides/webhooks/). - **Prepaid credit and a base subscription**, topped up in the portal. See [pricing and credit](https://docs.accountcloud.io/guides/pricing/). - **`GET /v1/me`** (the account, the key and the balance), and without a key **`GET /v1/status`** and **`GET /v1/pricing`** (in English, or Dutch with `?locale=nl`). ### The operations | Operation | Endpoint | Status on 2026-11-04 | |---|---|---| | `address.forms` | `GET /v1/address/forms`, `GET /v1/address/forms/{country}` | Live, free | | `address.validate` | `POST /v1/address/validate` | Preview, no price yet | | `address.format` | `POST /v1/address/format` | Preview, no price yet | | `ocr.extract` | `POST /v1/documents` with `processing: automatic` | Preview | | `ubl.verified` | `POST /v1/documents` with `processing: verified` | Preview | | `ocr.read` | `GET /v1/documents`, `GET /v1/documents/{id}`, `DELETE /v1/documents/{id}` | Preview, free | | `ubl.generate` | `GET /v1/documents/{id}/ubl` | Preview, free | | `ubl.validate` | `POST /v1/ubl/validate` | Preview, free | | `ocr.feedback` | `POST /v1/documents/{id}/feedback` | Preview, free | | `company.profile` | `GET /v1/companies/{country}/{number}` | Preview. NL priced, for accounts with access to KvK data; BE no price yet | | `company.search` | `GET /v1/companies` | Preview, no price yet | | `vat.validate` | `GET /v1/vat-numbers/{vat_number}` | Preview, no price yet | An operation in preview without a price answers a live key with `503` [`operation_unavailable`](https://docs.accountcloud.io/guides/errors/#operation_unavailable) until it has one; test keys already get answers. The guides: [addresses](https://docs.accountcloud.io/guides/addresses/), [documents](https://docs.accountcloud.io/guides/documents/), and [company data and VAT numbers](https://docs.accountcloud.io/guides/companies/).