Developers › Quickstart
Quickstart
Three calls: check that your key works, count for free, then fetch rows. Nothing here costs more than a few cents.
Doing this with a sandbox key(syyni_test_…)? Everything below works identically — the register is smaller and nothing is charged. See Sandbox.
1. Check the key
/usage is free and does not consume any quota. It is also the endpoint to call when something stops working — it tells you your limits and what you have spent this month.
export SYYNI_API_KEY="syyni_live_..."
curl -H "Authorization: Bearer $SYYNI_API_KEY" \
https://syynilabs.fi/api/v1/usage{
"key": { "prefix": "1fq2Jl8H", "customer": "Your Company Oy" },
"current_month": {
"spend": "12.40", "currency": "EUR", "vat": "excluded",
"requests": 143, "rows": 2180
},
"today": { "requests": 12 },
"limits": {
"requests_per_minute": 60, "requests_per_day": 5000,
"rows_per_month": 50000, "spend_cap": "500.00",
"customer_requests_per_month": null
},
"customer_current_month": { "requests": 143 }
}customer_requests_per_month is an account-level cap shared by all your keys (null = none), and customer_current_month.requests counts against it.
2. Count before you buy
Counting is free, so shape the filter here rather than by fetching rows and discarding them. The response gives both levels, because the same filter means a different number of each: a company row is a company, a decision-maker row is a person.
curl -H "Authorization: Bearer $SYYNI_API_KEY" \
"https://syynilabs.fi/api/v1/companies/count\
?region=01&industry_division=41&min_age_years=3&has_decision_maker=true"Unknown parameters are rejected rather than ignored. A typo like municipalty=091 would otherwise silently widen your query to the whole register — and bill you for it.
3. Fetch rows
Ask for the field groups you actually need. The base row is always included and always billed; phone, email and decision_makers cost extra, and only for rows where they contain something.
curl -H "Authorization: Bearer $SYYNI_API_KEY" \
"https://syynilabs.fi/api/v1/companies\
?region=01&industry_division=41&fields=phone&page_size=100"{
"data": [
{
"business_id": "0100130-4",
"name": "Example Oy",
"industry": { "code": "41200", "description": "Construction of buildings" },
"address": { "street": "Esimerkkitie 1", "postal_code": "00100",
"city": "Helsinki", "municipality_code": "091" },
"availability": { "has_phone": true, "phone_count": 3,
"has_email": true, "has_decision_maker": true },
"phone_numbers": ["+358401234567", "+358401234568"],
"phone_updated": "2026-08-16"
}
],
"pagination": { "page_size": 100, "has_more": true, "cursor": "eyJrIjpb..." },
"billing": {
"rows_returned": 100, "rows_billed": 100,
"cost": "5.92", "currency": "EUR", "vat": "excluded",
"field_groups": {
"core": { "billed": 100, "empty": 0, "free_repeat": 0 },
"phone": { "billed": 61, "empty": 26, "free_repeat": 13 }
}
}
}Read the billing block: 26 rows had no phone number and were not charged for it, and 13 had been fetched before within the last 30 days and were free.
4. Page through the results
Pagination is cursor-based. Pass the cursor back verbatim and stop when has_more is false. Do not construct cursors yourself — they are tied to the filters and fields you used, and a mismatched cursor returns invalid_cursor rather than silently wrong rows.
import os, requests
KEY = os.environ["SYYNI_API_KEY"]
BASE = "https://syynilabs.fi/api/v1"
params = {"region": "01", "industry_division": "41",
"fields": "phone", "page_size": 500}
rows, spent = [], 0.0
while True:
r = requests.get(f"{BASE}/companies", params=params,
headers={"Authorization": f"Bearer {KEY}"},
timeout=60)
r.raise_for_status()
body = r.json()
rows += body["data"]
spent += float(body["billing"]["cost"])
if not body["pagination"]["has_more"]:
break
params["cursor"] = body["pagination"]["cursor"]
print(f"{len(rows)} rows, {spent:.2f} EUR")const KEY = process.env.SYYNI_API_KEY;
const BASE = "https://syynilabs.fi/api/v1";
let cursor = null, rows = [], spent = 0;
do {
const url = new URL(`${BASE}/companies`);
url.searchParams.set("region", "01");
url.searchParams.set("fields", "phone");
url.searchParams.set("page_size", "500");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!res.ok) throw new Error((await res.json()).error.code);
const body = await res.json();
rows.push(...body.data);
spent += Number(body.billing.cost);
cursor = body.pagination.has_more ? body.pagination.cursor : null;
} while (cursor);
console.log(rows.length, "rows,", spent.toFixed(2), "EUR");Handle 429 properly
Two different limits return 429: rate_limited is the per-minute burst limit and clears in seconds; quota_exceeded is your daily or monthly quota and clears at midnight or month end. Both send Retry-After in seconds, and the same number appears in the body as retry_after_seconds — sleep that long rather than guessing. Nothing is billed for a rejected request.