API docs

Base URL https://swissnewco.tollwyn.com. JSON everywhere, UTF-8, dates as YYYY-MM-DD. Machine-readable: OpenAPI 3.1 and llms.txt. For Claude, Cursor and other agents: the MCP server.

Quick start

# 1. An account and an API key (or sign up in the browser)
curl -X POST https://swissnewco.tollwyn.com/v1/signup -H 'content-type: application/json' \
  -d '{"email":"you@example.com","password":"a long password","via":"docs","accept_terms":true}'

# 2. New companies in Zurich and Zug, newest first (as far back as your plan reaches)
curl 'https://swissnewco.tollwyn.com/v1/companies?canton=ZH,ZG' -H 'authorization: Bearer snf_…'

Authentication

Send your key as Authorization: Bearer snf_…. Keys are shown once; create and revoke them in the dashboard (two can be active at a time, so you can switch without downtime).

Plans and limits

PlanHistory (days)Events (days)Records / monthWebhooksCHF / month
Free772’00000
Starter309010’000139
Pro36536530’000599
Agency36536560’00010190
Platformwhole archivewhole archive250’00020490
  • The monthly quota counts delivered records (API, CSV and JSON downloads), not requests. Every answer that delivers records carries X-Quota-Limit and X-Quota-Remaining; when it is used up the API answers 429 quota_exceeded with Retry-After until the first of next month. Webhooks and the CSV link do not count.
  • At most 60 requests per minute and key (429 rate_limited with Retry-After). While another request of your account still holds the rest of the month's quota, you also get 429 rate_limited, with Retry-After: 2.
  • On Free, contact.website, website_verified_by, email, phone and contact_page are null and contact.masked is true; has_website, has_email and has_phone still tell what was found.
  • Asking for days before your plan's history answers 402 plan_history; GET /v1/account names the first day.

Companies

GET /v1/companies

New entries, newest first.

ParameterMeaning
published_from, published_toPublication days (inclusive). Default: your plan's whole history.
cantonCanton codes, comma-separated: ZH,ZG
zipPostcode prefixes of 1–4 digits: 80,8400
legal_formLegal form codes: 0107 GmbH/Sàrl, 0106 AG/SA, 0101 sole proprietorship, 0103 general partnership, 0151 Swiss branch …
has_website, has_email, has_care_of, is_branchtrue or false
checks_minSearched at least this often (1–3). has_website=false&checks_min=2: still no website after two searches.
qA word in name or purpose (2–100 characters).
limit1–1000, default 100.
cursornext_cursor of the previous page.
{ "data": [ …records… ], "next_cursor": "WyIyMDI2LTEwLTAxIiwi…" }

GET /v1/companies/{uid}

The newest entry of a UID such as CHE-413.480.290.

GET /v1/account

Plan, its limits, the first day of your history, this month's usage and the subscription.

The record

Each record is a snapshot of the new entry; later changes in the register (rubric HR02) do not change it. Fields:

idSHAB publication id
uid, name, name_translationsUID, company name as registered, its translations
legal_formcode, English name, short in the language of the publication (GmbH, Sàrl, Sagl, AG, SA …)
is_branch, head_officeBranch; for Swiss branches name and UID of the head office
seat, cantonSeat (municipality) and canton
addresscare_of, supplement, street, house_number, po_box, zip, town
purpose, capitalPurpose; nominal capital {amount, currency}
persons, persons_parsedPeople of the entry: name, family_name, given_names, residence, role, signature. Where the register wrote them in free form (often in French-speaking cantons) the list is empty and persons_parsed is false; publication_text has them. Three years after publication both are emptied (persons_parsed false, the text empty). A person who objected is left out, and the text of such an entry reads [removed].
published_on, registered_on, languageSHAB publication day, register journal day, language of the publication
publication_text, sourceFull official text; publication number and URL of the signed PDF
contactwebsite, website_verified_by (uid, name, name_and_address), email, phone (E.164), contact_page, has_website, has_email, has_phone, masked, status (pending, found, not_found, suppressed), checks, checked_at

CSV

GET /v1/companies.csv takes the same parameters plus delimiter=comma|semicolon (semicolon for Excel with Swiss settings) and include=publication_text. UTF-8 with byte order mark; up to 10,000 rows per file, continue with the cursor in X-Next-Cursor. Columns: uid, name, legal_form_short, legal_form, legal_form_code, is_branch, care_of, address_supplement, street, house_number, po_box, zip, town, seat, canton, website, email, phone, contact_page, persons, purpose, capital_amount, capital_currency, published_on, registered_on, language, contact_status, contact_checks, website_verified_by, source_url, id, has_website, has_email, has_phone [, publication_text]. Cells that a spreadsheet would run as a formula start with an apostrophe.

Events

GET /v1/events?after=<id>&limit=100 lists changes in a fixed order: company.registered (a new entry, after its first website search), company.enriched (website found later), company.suppressed (the company objected: delete website, e-mail and phone from your copy) and company.redacted (a person named in the entry objected: replace your copy with data; of a sole proprietorship whose owner objected only the UID, legal form, seat, canton, purpose, capital, dates and source stay). Apply both removals within 30 days, wherever you keep copies; the change list keeps them for 90 days on every plan, also on Free after a paid plan ended. Store the last id you processed and pass it as after; next_after is set while more follow.

{ "data": [ { "id": 4711, "type": "company.registered", "created_at": "2026-10-02T04:12:09Z", "data": { …record… } } ],
  "next_after": null }

Webhooks

Paid plans: add endpoints in the dashboard or with POST /v1/webhooks {"url": "https://…", "filters": {"canton": ["ZH"], "zip": ["80"], "legal_form": ["0107"]}}; change them with PATCH /v1/webhooks/{id} (url, filters, enabled, rotate_secret), delete with DELETE, list with GET /v1/webhooks. Each event is one POST with the event as body (the same shape as in /v1/events) and the headers X-SwissNewCo-Event-Id and X-SwissNewCo-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body" with the endpoint secret>. Filters select the companies an endpoint hears about; company.suppressed and company.redacted go to every endpoint, because a filter may have matched the entry as it was.

  • Answer with any 2xx within 10 seconds. Otherwise we retry after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 24 h; after that the endpoint is switched off and you get an e-mail. Fetch what you missed from /v1/events.
  • Delivery is at least once and not ordered: deduplicate by event id.
  • POST /v1/webhooks/{id}/test sends a webhook.test event (id 0) to a saved endpoint; POST /v1/webhooks/test {"url", "secret"} to any URL, also on Free (without secret it signs with whsec_test).

Check the signature with the raw body:

import crypto from 'node:crypto';

// body: the raw request body as a string; header: X-SwissNewCo-Signature
function verify(body, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    String(header).split(',').map((part) => part.split('=', 2))
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp) || typeof parts.v1 !== 'string') return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${body}`).digest();
  const given = Buffer.from(parts.v1, 'hex');
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
import hashlib, hmac, time

def verify(body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - timestamp) > tolerance:
        return False
    signed = parts["t"].encode() + b"." + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Excel and Google Sheets

Save a filter in the dashboard (Feed → Save as my filter) and create the CSV link. Excel: Data → From Web → paste the link. Google Sheets: =IMPORTDATA("https://…/feeds/…csv"). The link shows the last 7 days (?date=YYYY-MM-DD for one of them, ?delimiter=semicolon for Excel); it can be fetched 60 times an hour and replaced in the dashboard when it leaked.

Zapier and Make

Create a “Catch Hook” (Zapier: Webhooks by Zapier; Make: Custom webhook), add its URL as endpoint, send a test event, then map the fields (for example data.name, data.address.zip, data.contact.email) to a Google Sheets row, a HubSpot or Pipedrive company or a Bexio contact.

Billing for scripts and agents

POST /v1/billing/checkout {"plan": "starter|pro|agency|platform", "interval": "month|year", "renew": true} returns a Stripe Checkout url to open and pay. With "renew": false the subscription ends after the paid period. POST /v1/billing/portal returns the Stripe customer portal (change plan, payment method, invoices, cancel).

Errors

Every error is {"error": {"code", "message"}}. Send request bodies with Content-Type: application/json: a form-encoded or plain-text POST is refused with a plain 403 before it reaches the API (protection against cross-site forms).

400invalid_request, invalid_jsonA parameter or the body is wrong; the message names it.
401unauthorizedNo or an invalid API key.
402plan_history, plan_limit, plan_requiredYour plan does not include this.
404not_found
405method_not_allowedThe path exists, not with this method; Allow names the right ones.
409email_taken, already_subscribed, no_subscription
422terms_not_acceptedSign-up needs "accept_terms": true for your business.
429rate_limited, quota_exceededSee Retry-After.
500internal_errorSomething failed on our side; retry later.
503billing_unavailablePayments are not reachable; try again later.

Versioning and changes

Within /v1 we only add: new fields, new parameters, new event types. Ignore what you do not know. Breaking changes come as /v2, announced 90 days ahead by e-mail; /v1 keeps running meanwhile.

Changelog

  • 2026-10: MCP server for AI agents at /mcp, set-up on /docs/mcp.
  • 2026-10: first release of the API.

Data source and licence

Register data: Swiss Official Gazette of Commerce (SHAB / FOSC / FUSC), freely usable with source attribution. The legally binding text is the signed PDF linked in source.url. What you may do with the data depends on your plan; see the terms.