DEV Community

Cizze R
Cizze R

Posted on

How to Build an Auditable Swedish Supplier Check with Python

How to Build an Auditable Swedish Supplier Check with Python

A supplier form that accepts a company name and moves on is easy to build, but it leaves someone doing registry checks by hand later. I wanted the onboarding service to verify the organization number, preserve what it found, and send ambiguous cases to a human instead of silently approving them.

The Swedish Company Registry handles the registry lookup, while Python handles the less glamorous parts that make the result useful: input cleanup, exact matching, review rules, and a small audit trail.

Treat the organization number as an identifier

Swedish organization numbers are commonly written as 556703-7482, but users also paste spaces, omit the hyphen, or include a Swedish prefix. Normalize the value before querying or comparing it, then retain the formatted version for display.

import re


def normalize_org_number(value: str) -> str:
    digits = re.sub(r"\D", "", value)

    # A Swedish VAT number can arrive as SE + 10 digits + 01.
    if digits.startswith("46") and len(digits) == 12:
        digits = digits[2:]
    if len(digits) == 12 and digits.endswith("01"):
        digits = digits[:-2]

    if len(digits) != 10:
        raise ValueError("Expected a 10-digit Swedish organization number")

    return digits


def display_org_number(value: str) -> str:
    digits = normalize_org_number(value)
    return f"{digits[:6]}-{digits[6:]}"
Enter fullscreen mode Exit fullscreen mode

Normalization is not verification. It only gives every later step one representation to compare. The registry response is still the authority for whether that number exists and which company it identifies.

Run the registry lookup synchronously

For an onboarding request, waiting for a short Actor run is simpler than creating a run and polling it yourself. Apify's synchronous dataset endpoint returns the resulting items in the same HTTP response.

import requests

APIFY_TOKEN = "YOUR_APIFY_TOKEN"
ACTOR_ID = "weeknds~swedish-company-registry"
ACTOR_URL = (
    f"https://api.apify.com/v2/acts/{ACTOR_ID}/"
    "run-sync-get-dataset-items"
)


class RegistryLookupError(RuntimeError):
    pass


def search_registry(query: str, max_results: int = 10) -> list[dict]:
    response = requests.post(
        ACTOR_URL,
        params={"token": APIFY_TOKEN},
        json={"searchQuery": query, "maxResults": max_results},
        timeout=90,
    )

    if response.status_code >= 500:
        raise RegistryLookupError("Registry lookup is temporarily unavailable")

    response.raise_for_status()
    items = response.json()
    if not isinstance(items, list):
        raise RegistryLookupError("Unexpected registry response")
    return items
Enter fullscreen mode Exit fullscreen mode

Keep the token in an environment variable in a real service. YOUR_APIFY_TOKEN is only there to make the request shape obvious.

A company-name search can return several similarly named businesses. An organization-number search should be much narrower, but I still require an exact normalized match rather than trusting the first result.

def exact_company_match(org_number: str, results: list[dict]) -> dict | None:
    expected = normalize_org_number(org_number)

    for company in results:
        candidate = company.get("org_number", "")
        try:
            if normalize_org_number(candidate) == expected:
                return company
        except ValueError:
            continue

    return None
Enter fullscreen mode Exit fullscreen mode

Return a review decision, not a fake risk score

A registry check can confirm identity and expose useful status fields. It cannot tell you whether a supplier is trustworthy. I use three outcomes: verified, review, and not_found.

from datetime import date, datetime, timedelta


def parse_registry_date(value: str | None) -> date | None:
    if not value:
        return None
    try:
        return datetime.strptime(value, "%Y-%m-%d").date()
    except ValueError:
        return None


def assess_supplier(org_number: str) -> dict:
    formatted = display_org_number(org_number)
    results = search_registry(formatted, max_results=5)
    company = exact_company_match(formatted, results)

    if company is None:
        return {
            "decision": "not_found",
            "org_number": formatted,
            "reasons": ["No exact organization-number match"],
        }

    reasons = []
    status = company.get("status")
    registered = parse_registry_date(company.get("registration_date"))

    if status != "Registrerat":
        reasons.append(f"Registry status requires review: {status or 'missing'}")

    if registered and registered > date.today() - timedelta(days=90):
        reasons.append("Company was registered within the last 90 days")

    required = ("name", "org_number", "address")
    missing = [field for field in required if not company.get(field)]
    if missing:
        reasons.append("Missing registry fields: " + ", ".join(missing))

    return {
        "decision": "review" if reasons else "verified",
        "org_number": formatted,
        "company": {
            "name": company.get("name"),
            "legal_form": company.get("legal_form"),
            "status": status,
            "address": company.get("address"),
            "county": company.get("county"),
            "registration_date": company.get("registration_date"),
        },
        "reasons": reasons,
    }
Enter fullscreen mode Exit fullscreen mode

The 90-day rule is an example review policy, not a claim that a new company is suspicious. Make thresholds explicit so compliance or procurement can change them without rewriting the integration.

Save the evidence you actually used

Registry data changes. If someone asks why a supplier passed six months ago, the current record alone does not answer the question. Store the decision, the selected fields, and the lookup timestamp.

SQLite works well for a small internal service and keeps the example deployable without another database.

import json
import sqlite3
from datetime import datetime, timezone


def open_audit_db(path: str = "supplier-checks.sqlite3") -> sqlite3.Connection:
    connection = sqlite3.connect(path)
    connection.execute("""
        CREATE TABLE IF NOT EXISTS supplier_checks (
            id INTEGER PRIMARY KEY,
            org_number TEXT NOT NULL,
            checked_at TEXT NOT NULL,
            decision TEXT NOT NULL,
            result_json TEXT NOT NULL
        )
    """)
    return connection


def check_and_record(org_number: str, db_path: str = "supplier-checks.sqlite3") -> dict:
    result = assess_supplier(org_number)
    checked_at = datetime.now(timezone.utc).isoformat()

    with open_audit_db(db_path) as connection:
        connection.execute(
            """
            INSERT INTO supplier_checks
                (org_number, checked_at, decision, result_json)
            VALUES (?, ?, ?, ?)
            """,
            (
                result["org_number"],
                checked_at,
                result["decision"],
                json.dumps(result, ensure_ascii=False, sort_keys=True),
            ),
        )

    return {**result, "checked_at": checked_at}
Enter fullscreen mode Exit fullscreen mode

Avoid storing more personal data than the workflow needs. Company records can include officers, but a basic supplier identity check may only need the registered name, organization number, status, and address. Data minimization makes the audit record easier to defend and maintain.

Put the check behind a narrow API

Your purchasing app should not know Actor input fields or registry response details. A small service boundary lets the UI submit an organization number and receive a stable decision format.

from flask import Flask, jsonify, request

app = Flask(__name__)


@app.post("/supplier-checks")
def create_supplier_check():
    payload = request.get_json(silent=True) or {}

    try:
        result = check_and_record(payload.get("org_number", ""))
    except ValueError as exc:
        return jsonify({"error": str(exc)}), 400
    except (requests.RequestException, RegistryLookupError):
        return jsonify({"error": "Registry check unavailable"}), 503

    status_code = 200 if result["decision"] == "verified" else 202
    return jsonify(result), status_code
Enter fullscreen mode Exit fullscreen mode

A 202 for review cases prevents the caller from confusing a valid API response with automatic approval. Network failures return 503, so they can be retried instead of becoming false rejections.

Cost, caching, and rechecks

The Swedish Company Registry is priced at $0.003 per search, so 1,000 onboarding checks cost $3 in Actor charges. Apify platform usage can also apply, and the Actor page is the right place to confirm current pricing before a bulk run.

Do not cache forever just to save a fraction of a cent. Cache repeated requests during one onboarding session, then run a fresh check before approval if the saved evidence is older than your policy allows. A 30-day recheck window may work for routine procurement, while a payment change or compliance event should trigger an immediate lookup.

The audit row should remain immutable even after a recheck. Insert a new row, compare the new status with the previous one, and route any change in registered name, address, or status back to review.

Top comments (0)