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
| Plan | History (days) | Events (days) | Records / month | Webhooks | CHF / month |
|---|---|---|---|---|---|
| Free | 7 | 7 | 2’000 | 0 | 0 |
| Starter | 30 | 90 | 10’000 | 1 | 39 |
| Pro | 365 | 365 | 30’000 | 5 | 99 |
| Agency | 365 | 365 | 60’000 | 10 | 190 |
| Platform | whole archive | whole archive | 250’000 | 20 | 490 |
- The monthly quota counts delivered records (API, CSV and JSON downloads),
not requests. Every answer that delivers records carries
X-Quota-LimitandX-Quota-Remaining; when it is used up the API answers429 quota_exceededwithRetry-Afteruntil the first of next month. Webhooks and the CSV link do not count. - At most 60 requests per minute and key (
429 rate_limitedwithRetry-After). While another request of your account still holds the rest of the month's quota, you also get429 rate_limited, withRetry-After: 2. - On Free,
contact.website,website_verified_by,email,phoneandcontact_pagearenullandcontact.maskedistrue;has_website,has_emailandhas_phonestill tell what was found. - Asking for days before your plan's history answers
402 plan_history;GET /v1/accountnames the first day.
Companies
GET /v1/companies
New entries, newest first.
| Parameter | Meaning |
|---|---|
published_from, published_to | Publication days (inclusive). Default: your plan's whole history. |
canton | Canton codes, comma-separated: ZH,ZG |
zip | Postcode prefixes of 1–4 digits: 80,8400 |
legal_form | Legal 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_branch | true or false |
checks_min | Searched at least this often (1–3). has_website=false&checks_min=2:
still no website after two searches. |
q | A word in name or purpose (2–100 characters). |
limit | 1–1000, default 100. |
cursor | next_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:
id | SHAB publication id |
uid, name, name_translations | UID, company name as registered, its translations |
legal_form | code, English name, short in the language of the publication
(GmbH, Sàrl, Sagl, AG, SA …) |
is_branch, head_office | Branch; for Swiss branches name and UID of the head office |
seat, canton | Seat (municipality) and canton |
address | care_of, supplement, street, house_number, po_box, zip, town |
purpose, capital | Purpose; nominal capital {amount, currency} |
persons, persons_parsed | People 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, language | SHAB publication day, register journal day, language of the publication |
publication_text, source | Full official text; publication number and URL of the signed PDF |
contact | website, 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}/testsends awebhook.testevent (id 0) to a saved endpoint;POST /v1/webhooks/test{"url", "secret"}to any URL, also on Free (withoutsecretit signs withwhsec_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).
| 400 | invalid_request, invalid_json | A parameter or the body is wrong; the message names it. |
| 401 | unauthorized | No or an invalid API key. |
| 402 | plan_history, plan_limit, plan_required | Your plan does not include this. |
| 404 | not_found | |
| 405 | method_not_allowed | The path exists, not with this method; Allow names the right ones. |
| 409 | email_taken, already_subscribed, no_subscription | |
| 422 | terms_not_accepted | Sign-up needs "accept_terms": true for your business. |
| 429 | rate_limited, quota_exceeded | See Retry-After. |
| 500 | internal_error | Something failed on our side; retry later. |
| 503 | billing_unavailable | Payments 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.