Skip to main content

addrly-api

The official Python client for the Addrly API: Belgian addresses (BOSA Best-Address) and Belgian companies (BCE/KBO, NBB annual accounts, Moniteur belge).

pip install addrly-api
from addrly_api import Addrly

client = Addrly()  # reads ADDRLY_API_KEY
# or Addrly("ak_...") / Addrly(api_key=key), from your own settings

for hit in client.autocomplete("rue de la loi", limit=5, type="address"):
    print(hit.score, hit.label)

address = client.normalize("16 rue de la loi 1000 brux")
if address and address.match_similarity >= 0.9:
    print(address.normalized.one_line())  # Rue de la Loi 16, 1000 Bruxelles

company = client.company("BE 0123.456.749")  # any format, checked locally
print(company.display_name, company.seat().one_line())

Requires Python 3.9+. The only dependency is httpx.

Get a key

Sign up at addrly.be, then create a key on the API keys page. Read it from the environment, never from source:

export ADDRLY_API_KEY=ak_...

Addrly() reads the environment variable. When your application already holds the key in its own settings, pass it to the constructor instead: Addrly("ak_...") or Addrly(api_key=key). Same for AsyncAddrly.

A key is server-to-server. Do not ship one to a browser or a mobile app.

What you can call

Method Endpoint Cost
account() GET /account free
autocomplete(q, limit=, type=) GET /autocomplete 1 address request
normalize(text) POST /normalize 1 address request
validate_address(**fields) POST /validate/address 1 address request
validate_components(**fields) POST /validate/components 1 address request
reverse_geocode(lat, lon, limit=) GET /geocode/reverse 1 address request
route(origin, dest) GET /routing/route 1 address request
company(number) GET /company/{n} 1 request
dossier(number) GET /company/{n}/enrich 1 dossier, once per company per period
match_companies(rows) POST /enrich/company 1 request per row
normalize_many / companies / dossiers POST /batch the unitary price per item

Know before you spend

account() is the only free call. It answers the plan, the feature slugs, the rate limit and what is left of both meters, and the server answers it even while billing is suspended, so it is also where you find out why everything else is refusing you:

account = client.account()

if account.frozen:
    raise SystemExit("billing suspended, resolve it in the app")
if not account.has_feature("company_api_bulk"):
    raise SystemExit("this plan does not include the bulk matcher")
if account.requests_remaining < len(rows):
    rows = rows[: int(account.requests_remaining)]

print(account.plan.name, account.credits_remaining, "dossiers left")
print("watching", account.watch.used, "of", account.watch.limit, "companies")
print("resets in", account.period.resets_in_seconds, "s")

A budget guard never refuses it either: a status call you cannot make when you are out of everything is a status call for nothing.

Two meters, and they are separate: requests (every call that does not open a dossier, company identity reads and matched rows included) and dossiers (one company's full dossier, charged once per company per billing period). An answer carries only the meter it charged (X-RateLimit-* for a request, X-Credit-* for a dossier), so client.last_usage keeps the best known state of both, while response.usage stays strictly what that one response said:

client.normalize("rue de la loi 16")
print(client.last_usage.address.remaining)  # 993

client.dossier("0123456749")
print(client.last_usage.credits.remaining)  # 249: dossiers left
print(client.last_usage.address.remaining)  # 993, still known

answer = client.autocomplete("exampleco")
print(answer.usage.credits.is_empty)  # True: that call charged a request

Bulk: one bad row never loses the good ones

normalize_many, companies and dossiers chunk your list to the API's ceilings (100 rows, 25 for full dossiers) and give back one Outcome per input, in input order. A malformed row is a 422 on that item, not on the call:

for outcome in client.normalize_many(rows):
    if outcome:
        save(outcome.value.normalized)
    else:
        log(outcome.index, outcome.error.code)

match_companies is the CRM/ERP import endpoint: messy names in, the best Belgian company match out, with the scored runners-up.

for row in client.match_companies(
    ["acme sofware", {"name": "globex", "address": "1000 Bruxelles"}]
):
    if row and row.score >= 0.9:
        apply(row.enterprise_number)

Errors: branch on the code, never on the status

Every refusal is a typed exception carrying the API's own error code, the message, the request_id and whatever fields that code documents.

from addrly_api import CreditsExhausted, FeatureNotInPlan, RateLimited, APIError

try:
    dossier = client.dossier("0123456749")
except CreditsExhausted as exc:
    print("out of credits, resets in", exc.reset_in_seconds, "s")
except FeatureNotInPlan as exc:
    print("the plan does not include", exc.feature)
except APIError as exc:
    print(exc.code, exc.request_id)
Exception Status / code
AuthenticationError 401 missing_credentials, invalid_api_key, invalid_token, session_expired
AccountFrozen 402 account_frozen
PermissionDenied, FeatureNotInPlan 403 account_inactive, api_key_disabled, feature_not_in_plan
NotFound, CompanyNotFound 404
InvalidRequest, InvalidEnterpriseNumber 422
RateLimited 429 rate_limited (the per-second cap)
QuotaExceeded, CreditsExhausted 429 quota_exceeded, credits_exhausted (the month)
ServerError, ServiceUnavailable, Maintenance 5xx
TransportError the request never came back

Retries: what is repeated, and what must not be

Two 429s mean opposite things, so the client treats them differently.

  • Retried, with exponential backoff plus jitter, honouring Retry-After: rate_limited, 5xx, and connection failures.
  • Never retried: quota_exceeded and credits_exhausted. A monthly allowance does not come back in 500 ms, and a loop on it only buys overage. Also never retried: 401, 402, 403, 404, 422. The request is wrong, or the plan is, and sending it again changes neither.
  • A priced POST (/normalize, /batch, /enrich/company) is metered upfront, so it is only retried when the connection provably never opened. A read timeout there might already have been billed, and the API has no idempotency key yet.
client = Addrly(max_retries=3, retry_backoff=0.5, timeout=10.0)

max_retries=0 disables it.

Never spend more than you meant to

A migration script is the one place a paid API gets expensive by accident. budget() counts the declared price of each call and refuses the one that would cross the cap, before sending it:

from addrly_api import BudgetExceeded

try:
    with client.budget(credits=50) as spent:
        for number in numbers:
            enrich(client.dossier(number))
except BudgetExceeded:
    print("stopped at", spent.credits_spent, "credits")

Async

Same methods, same models, same retry policy:

import asyncio
from addrly_api import AsyncAddrly


async def main():
    async with AsyncAddrly() as client:
        answer = await client.autocomplete("rue de la loi")
        print(len(answer))


asyncio.run(main())

Command line

The package installs an addrly command (and addrly-api, same thing, for a machine where the short name is taken), so the API can be tried in thirty seconds:

addrly account
addrly autocomplete "rue de la loi"
addrly normalize "rue de la loi 16 brux"
addrly company 0123456749
addrly dossier 0123456749
addrly reverse 50.8467 4.3499
addrly match "acme sofware" "globex grup"

# The job most integrations write first: an address column, cleaned.
addrly clean clients.csv --column address -o clean.csv

clean adds addrly_street, addrly_house_number, addrly_box, addrly_postcode, addrly_municipality, addrly_lat, addrly_lon, addrly_similarity and addrly_status (ok, review, not_found, empty). It sends 100 rows per round trip and never spends a request on an empty cell.

Enterprise numbers, checked without a round trip

from addrly_api import is_valid_enterprise_number, normalize_enterprise_number

normalize_enterprise_number("BE 0123.456.749")  # "0123.456.749"
is_valid_enterprise_number("0123456748")  # False (mod-97)

Every company method accepts any format and validates locally first, so a typo raises InvalidEnterpriseNumber instead of costing a call.

Forward compatible on purpose

Models are plain dataclasses and every one keeps the payload it was built from in .raw. An unknown field never raises, so a field the API adds tomorrow is readable today:

company.raw["a_field_shipped_after_this_release"]

Only the stable shapes are typed. The deep dossier tree (annual-account snapshots, gazette events, participations) stays as plain dicts under typed entry points: those grow with the pipelines behind them, and a class per leaf would be wrong one release later.

Configuration

Argument Default Environment
api_key - ADDRLY_API_KEY
base_url https://addrly.be/api/v1 ADDRLY_BASE_URL
timeout 10.0 -
max_retries 2 -
retry_backoff 0.5 -

One client per process, shared between threads: it holds a pooled TLS connection, which is most of the latency of a first call.

MIT licensed. Belgium only, on purpose.

Metadata

Release files for addrly-api 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for addrly-api 0.2.0
File Size Uploaded Provenance
addrly_api-0.2.0.tar.gz 45.5 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for addrly-api 0.2.0
File Interpreter ABI Platform
addrly_api-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 85.8 kB

Release files / addrly_api-0.2.0.tar.gz

Download URL addrly_api-0.2.0.tar.gz
Size 45.5 kB
Tags Source
SHA-256 checksum
How to use checksums
67732c6e6c7b8712b1d5b5926602839de8ef6eb7b31db8dd5e4aaeb67099dc82
BLAKE2b-256 checksum
How to use checksums
087519f235d9dd3db04dfd438640aef827f4d051d114149d4adc37612eef446a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release files / addrly_api-0.2.0-py3-none-any.whl

Download URL addrly_api-0.2.0-py3-none-any.whl
Size 40.3 kB
Tags Python 3
SHA-256 checksum
How to use checksums
29561313d0245dadce64f558954c3ed8e4515d3c00721f3f1bc15409afb85943
BLAKE2b-256 checksum
How to use checksums
91cbf46a2595416e6b93e709a45fe767c0dafce6cd8b0eb1d66c71bea58d168f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Security for addrly-api 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page