FolovUp Partner API — Integration Guide
Overview
FolovUp is a B2B market-research service. You describe the customers you are looking for in plain language ("transformer manufacturers in Germany that buy copper winding wire"); FolovUp runs a discovery: it searches the web, reads candidate companies' own public websites, keeps only the companies that fit the target, extracts contact details from those companies' contact/about/imprint pages, and verifies email addresses with its own SMTP-based verification system. The result is a list of companies and contacts that belongs to the FolovUp account that ran the discovery.
FolovUp is research-only. It does not send email, does not run campaigns and does not automate outreach. Whatever you do with the data happens in your own systems, under your own legal basis (see Data usage and compliance).
The Partner API is the machine-to-machine surface of that product. With an API key bound to one FolovUp account you can:
- Mirror that account's companies and contacts into your own database and keep the mirror fresh with cursor-based incremental sync (Pagination and incremental sync, Building a mirror (sync recipe)).
- Trigger new discoveries and read their progress and outcome (Discoveries). Triggering spends the owning account's credits.
- Receive webhooks when a discovery reaches a terminal state, signed with HMAC-SHA256 (Webhooks).
It is designed for partner platforms that embed FolovUp data in their own product, and for autonomous AI agents that act on behalf of a FolovUp customer (Integrating from an AI agent).
Base URL, always HTTPS:
https://folovup.com/api/partner/v1
The complete surface is 13 endpoints:
| Endpoint | Scope | Section |
|---|---|---|
GET /me |
none | GET /me |
GET /openapi.json |
none | GET /openapi.json |
GET /companies |
companies:read |
GET /companies |
GET /companies/{id} |
companies:read |
GET /companies/{id} |
GET /contacts |
contacts:read |
GET /contacts |
GET /discoveries |
discovery:read |
GET /discoveries |
GET /discoveries/{id} |
discovery:read |
GET /discoveries/{id} |
POST /discoveries |
discovery:write |
POST /discoveries |
POST /discoveries/{id}/cancel |
discovery:write |
POST /discoveries/{id}/cancel |
GET /webhook |
none | GET /webhook |
PUT /webhook |
none | PUT /webhook |
DELETE /webhook |
none | DELETE /webhook |
POST /webhook/test |
none | POST /webhook/test |
"Scope: none" means any active key can call the endpoint. Every GET endpoint also answers HEAD.
What the Partner API is not
- It is not the mobile API under
/api/v1. That surface uses personal user tokens obtained by logging in; the Partner API uses long-lived API keys issued by FolovUp. The two do not share credentials or response shapes. - There is no user login, registration, password or session on this surface. A key identifies one account; nothing else is negotiated.
- There is no endpoint to create, edit or delete companies or contacts, no email sending, no credit purchase and no key management. Keys are issued and revoked by the FolovUp team (Getting access).
- It exposes only what the owning account can see. Another account's data is never reachable: a foreign company id answers
404, and list endpoints simply do not contain it.
Machine-readable resources
| Resource | URL | Authentication | Notes |
|---|---|---|---|
| This guide (HTML) | https://folovup.com/en/developers |
none | Linked from every API response (Link header) and from 401 bodies (docs field). |
| This guide (Markdown twin, same content) | https://folovup.com/en/developers/partner-api.md |
none | Fetch this into an agent's context instead of scraping the HTML page. |
| OpenAPI 3.1 document, public copy | https://folovup.com/en/developers/openapi.json |
none | openapi: "3.1.0", info.title: "FolovUp Partner API", info.version: "1.0.0", servers[0].url: "https://folovup.com/api/partner/v1"; includes a top-level webhooks section describing the outbound deliveries. |
| OpenAPI 3.1 document, served by the API | https://folovup.com/api/partner/v1/openapi.json |
API key (no scope) | Byte-for-byte the same document; counts against your rate limit like any other call. See GET /openapi.json. |
| FolovUp summary for language models | https://folovup.com/llms.txt |
none | Product-level context (what FolovUp is and is not). Not an API reference. |
| FolovUp full knowledge base for language models | https://folovup.com/llms-full.txt |
none | Full text of the public site. |
Every response from the API, success or error, carries a Link header that points to both documents:
Link: <https://folovup.com/en/developers/openapi.json>; rel="service-desc", <https://folovup.com/en/developers>; rel="service-doc"
The OpenAPI document declares the ApiKeyBearer security scheme (type: http, scheme: bearer), all request and response schemas, the error-code enum, and a top-level webhooks section describing the three outbound deliveries. The scope each operation needs is written into the operation description as Gerekli scope: <scope>; it is not expressed as an OpenAPI security scope, so read it as text.
GET /openapi.json
Returns the OpenAPI 3.1 document for this API, generated from the running code (enums such as scopes and webhook events are read from the implementation, not hand-written).
Scope: none (any active key).
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
| — | — | — | — | — | No parameters. |
Request:
curl -s https://folovup.com/api/partner/v1/openapi.json \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-o folovup-openapi.json
Response (200, Content-Type: application/json; pretty-printed with unescaped Unicode and slashes, unlike every other endpoint). Skeleton only; "…" stands for the generated content:
{
"openapi": "3.1.0",
"info": {
"title": "FolovUp Partner API",
"version": "1.0.0",
"description": "…"
},
"servers": [
{ "url": "https://folovup.com/api/partner/v1" }
],
"security": [ { "ApiKeyBearer": [] } ],
"tags": ["…"],
"paths": {"…": {}},
"webhooks": {"…": {}},
"components": { "securitySchemes": { "ApiKeyBearer": { "type": "http", "scheme": "bearer", "description": "…" } }, "schemas": {"…": {}} },
"externalDocs": { "url": "https://folovup.com/en/developers" }
}
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired key. |
| 403 | account_suspended |
The owning account is suspended. |
| 429 | rate_limited |
Per-key quota exhausted; this call counts against it like any other. |
If you only need the document, the public copy at https://folovup.com/en/developers/openapi.json is identical and needs no key.
Quickstart (5 minutes)
Everything below is copy-paste-runnable once you replace the key. jq is used only to pretty-print.
Step 0 — put the key in the environment. You received it once, in full, from the FolovUp team (Getting access).
export FOLOVUP_KEY="pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
export FOLOVUP="https://folovup.com/api/partner/v1"
Step 1 — confirm the key works and see which account it is bound to.
curl -s "$FOLOVUP/me" -H "Authorization: Bearer $FOLOVUP_KEY"
{
"api_key": {
"name": "partner-prod",
"prefix": "pk_live_abc123def456",
"scopes": ["companies:read", "contacts:read", "discovery:read", "discovery:write"],
"rate_limit_per_minute": 60,
"expires_at": null
},
"account": { "id": 42, "name": "Örnek Dış Ticaret A.Ş.", "credits": 1250 },
"data": { "companies": 3211, "contacts": 4163 },
"server_time": "2026-09-08T09:49:46+00:00"
}
data.companies and data.contacts are the number of rows the list endpoints return by default (deleted rows excluded), so you know how many pages to expect. account.credits is the balance a discovery will draw from.
Step 2 — fetch the first page of companies. Ask for the maximum page size and include tombstones from the start so your mirror learns about deletions later without a schema change.
curl -s "$FOLOVUP/companies?per_page=200&include_deleted=1" \
-H "Authorization: Bearer $FOLOVUP_KEY" | jq .
{
"data": [
{
"id": "01J9Z0000000000000000000A1",
"folovup_company_id": 4821,
"discovery_id": 732,
"name": "Örnek Trafo Sanayi A.Ş.",
"legal_name": "Örnek Trafo Sanayi A.Ş.",
"short_name": "Örnek Trafo",
"searched_name": "örnek trafo",
"description": "Örnek Trafo designs and manufactures oil-immersed and dry-type distribution transformers up to 36 kV for utilities, industrial plants and renewable-energy projects. The company operates a single factory in Gebze and exports to Europe and the Middle East.",
"sector": "Electrical Equipment Manufacturing",
"entity_type": "company-organization",
"naics_code": "335311",
"company_code": "ORNK",
"founded_year": "1998",
"established_date": "1998-03-01",
"registration_number": "123456",
"number_of_employees": "50-100",
"annual_revenue": null,
"business_hours": null,
"is_exporter_or_importer": true,
"headquarter_country_iso": "TR",
"website": "https://www.example-trafo.com.tr",
"website_url": "https://example-trafo.com.tr",
"products": ["Oil-immersed distribution transformers", "Dry-type cast-resin transformers", "Special transformers for solar plants", "…"],
"services": ["Transformer maintenance", "On-site commissioning"],
"products_services": null,
"addresses": [
{
"country": "Turkey",
"street_and_city_and_state": "Örnek OSB 3. Cadde No: 12, 41400 Gebze / Kocaeli",
"two_digit_iso_country_code": "TR"
}
],
"phone_numbers": ["+90 262 000 00 00"],
"certifications": ["ISO 9001", "ISO 14001"],
"awards": [],
"key_personnel": [
{ "name": "Ayşe Yılmaz", "title": "Export Manager" }
],
"social_media_links": { "linkedin": "https://www.linkedin.com/company/example-trafo" },
"languages_supported": ["Turkish", "English"],
"payment_methods": [],
"shipping_countries": ["Germany", "Netherlands"],
"important_links": [
{
"url": "https://www.example-trafo.com.tr/contact",
"title": "Contact",
"importance_reason": "Contact details and factory address"
}
],
"contact_count": 2,
"is_catch_all_domain": false,
"is_manually_created": false,
"created_at": "2026-09-07T07:02:39+00:00",
"updated_at": "2026-09-08T08:23:39+00:00",
"is_deleted": false,
"deleted_at": null
},
…
],
"links": {
"first": null,
"last": null,
"prev": null,
"next": "https://folovup.com/api/partner/v1/companies?per_page=200&include_deleted=1&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wOCAwODoyMzozOSIsImlkIjo0ODIxLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9"
},
"meta": {
"path": "https://folovup.com/api/partner/v1/companies",
"per_page": 200,
"next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wOCAwODoyMzozOSIsImlkIjo0ODIxLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9",
"prev_cursor": null
}
}
The field-by-field reference is in Companies. links.first and links.last are always null.
Step 3 — follow the cursor until it is null. The simplest correct client requests links.next verbatim; it already contains your filters and the cursor.
NEXT="$FOLOVUP/companies?per_page=200&include_deleted=1"
while [ -n "$NEXT" ] && [ "$NEXT" != "null" ]; do
PAGE=$(curl -s "$NEXT" -H "Authorization: Bearer $FOLOVUP_KEY")
echo "$PAGE" | jq -c '.data[] | {id, name, is_deleted}'
NEXT=$(echo "$PAGE" | jq -r '.links.next')
done
Do the same with $FOLOVUP/contacts?per_page=500&include_deleted=1 for contacts (Contacts). Contacts changing does not change their company's updated_at, so the two streams are walked independently.
Step 4 (optional, spends credits) — start a discovery. Requires the discovery:write scope and at least 30 credits on the account. Always send an Idempotency-Key so a retried request cannot start a second discovery; the same key within 24 hours returns the first discovery with status 200 instead of 202.
curl -s -X POST "$FOLOVUP/discoveries" \
-H "Authorization: Bearer $FOLOVUP_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-7f3a9c-transformers-de-2026-09-08" \
-d '{"query": "Almanya'"'"'da dağıtım transformatörü üreten fabrikalar", "mode": "single"}'
{
"data": {
"id": 734,
"query": "Almanya'da dağıtım transformatörü üreten fabrikalar",
"mode": "single",
"status": "processing",
"is_terminal": false,
"progress": 0,
"target_company_count": null,
"counts": {
"links_found": 0,
"analyzed": 0,
"relevant": 0,
"companies_created": 0,
"companies_linked": null
},
"stop_reason": null,
"stop_explanation": null,
"error_message": null,
"started_at": "2026-09-08T09:50:02+00:00",
"completed_at": null,
"last_activity_at": "2026-09-08T09:50:02+00:00",
"created_at": "2026-09-08T09:50:02+00:00",
"updated_at": "2026-09-08T09:50:02+00:00"
},
"meta": { "accepted": true, "poll_after": 10 }
}
The response is 202 Accepted: the discovery runs asynchronously. Validation rules, the 402 credit refusal and the full idempotency semantics are in POST /discoveries.
Step 5 — poll until is_terminal is true. Wait meta.poll_after seconds (10) before the first poll, then poll every 10–30 seconds.
until curl -s "$FOLOVUP/discoveries/734" -H "Authorization: Bearer $FOLOVUP_KEY" \
| jq -e '.data.is_terminal' >/dev/null; do
sleep 15
done
curl -s "$FOLOVUP/discoveries/734" -H "Authorization: Bearer $FOLOVUP_KEY" | jq '.data | {status, stop_reason, stop_explanation, counts}'
When it is terminal, fetch the companies it produced: GET /companies?discovery_id=734&per_page=200 (expect counts.companies_linked rows). Polling strategy and the meaning of each stop_reason are in Discoveries.
Step 6 (optional) — register a webhook endpoint so you are told when a discovery finishes instead of polling. The URL must be https://. The secret is shown only in this response (and after a rotation); store it.
curl -s -X PUT "$FOLOVUP/webhook" \
-H "Authorization: Bearer $FOLOVUP_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://partner.example.com/webhooks/folovup"}'
{
"configured": true,
"webhook": {
"url": "https://partner.example.com/webhooks/folovup",
"events": ["discovery.completed", "discovery.failed", "discovery.cancelled"],
"is_active": true,
"last_success_at": null,
"last_failure_at": null,
"last_failure_reason": null,
"consecutive_failures": 0
},
"secret": "whsec_mnkrJtHKU5TFWCPywsLU0R7bNhIKtdjglMwCkUCQ",
"signature_contract": {
"header": "X-Folovup-Signature",
"format": "t=<unix>,v1=<hex>",
"signed_payload": "{t} + \".\" + RAW request body",
"algorithm": "HMAC-SHA256",
"tolerance_seconds": 300,
"idempotency_header": "X-Folovup-Delivery",
"note": "Gövdeyi yeniden serileştirmeyin; HAM gövde üzerinden doğrulayın. Aynı X-Folovup-Delivery tekrar gelebilir (en-az-bir-kez teslimat). Webhook teslimatı garanti DEĞİLDİR — polling yedeğini koruyun."
}
}
Send yourself a signed test delivery with POST /webhook/test, verify the signature over the raw body, and keep polling as the source of truth — delivery is at-least-once but not guaranteed (Webhooks).
Getting access
API keys are not self-service. They are issued by the FolovUp team on request and bound to one FolovUp account.
How to ask. Contact destek@folovup.com (or your FolovUp contact) from the address of the FolovUp account the key should belong to (or name that account explicitly). Include:
- The account the key must be bound to. Everything the key reads belongs to that account, and every discovery the key starts is charged to that account's credits. If you act for several FolovUp customers, ask for one key per customer account.
- The scopes you need, from
companies:read,contacts:read,discovery:read,discovery:write. Say explicitly if you needdiscovery:write: it spends credits and is not included unless requested. - The request rate you expect (requests per minute). Every key carries its own per-minute limit; keys are typically issued at 300 requests per minute (some at 60) unless you ask for a different value, which can be set between 1 and 6000.
- Whether the key should expire (a date, UTC) or stay valid until revoked.
- A name for the key (for example
partner-prod,partner-staging). Names are labels only; they need not be unique.
If the customer does not yet have a FolovUp account, say so: registration requires an invitation code that the FolovUp team provides, followed by email verification.
What you receive
- The full key, exactly once. Format:
pk_live_+ 12 lowercase characters[a-z0-9]+_+ 32 case-sensitive characters[A-Za-z0-9]; 53 characters in total, matching^pk_live_[a-z0-9]{12}_[A-Za-z0-9]{32}$. Example:pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX. FolovUp stores only a SHA-256 hash of it; a lost key cannot be recovered, only replaced. - The key prefix,
pk_live_+ the first 12 characters (20 characters, for examplepk_live_abc123def456). It is not secret;GET /mereports it asapi_key.prefix, and it is how you and FolovUp refer to a key in support conversations. Never quote the full key. - The scopes the key carries, reported by
GET /measapi_key.scopes. A key issued with no scope restriction reports all four. - The rate limit, reported as
api_key.rate_limit_per_minute. - The expiry, reported as
api_key.expires_at(null= never).
Key lifecycle
- One account per key. A key can never see or act on any other account.
- Several keys per account are allowed. Each has its own scopes, its own rate-limit window (two keys never share quota), its own optional expiry and its own webhook endpoint. Webhook deliveries, however, go to every active endpoint of every non-revoked key of the account — an expired key's endpoint keeps receiving them until the key is revoked (Who receives webhook deliveries).
- Revocation is requested through the same channel, takes effect on the very next request and is irreversible. A revoked key answers
401 unauthenticated. Ask for immediate revocation if a key leaks. - Expiry: once
expires_atis in the past the key answers401 unauthenticated. Plan a replacement before that date; there is no grace period. - Rotation (recommended at least yearly, and after any personnel change): ask for a new key for the same account and scopes, deploy it, confirm
GET /mesucceeds with it, then ask for the old key to be revoked. Because both keys are valid during the switch, rotation needs no downtime. - Suspension of the owning account makes every key of that account answer
403 account_suspendeduntil the account is reinstated.
Storing and handling the key
- Treat it like a password: keep it in a secret manager or environment variable, never in source control, never in a URL query string, never in client-side or mobile code, never in logs. The only place it belongs is the
Authorizationheader of a server-side request. - Do not call the API from a browser page. CORS is open, so a browser call would technically succeed — and expose the key to every visitor.
- Log the prefix (
pk_live_abc123def456) when you need to identify which key a request used.
Authentication
Authorization header
Every request carries the key as a bearer token:
Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Rules:
- The key is accepted only in this header. Query parameters, cookies,
X-Api-Keyand similar are ignored. - Send exactly one
Authorizationheader with exactly one value. The server takes the text after the lastBearer(the scheme word is matched case-insensitively) and cuts it at the first comma, so a combined or duplicated header value yields401. - No
Acceptheader is needed; see Request and response format.
curl -s https://folovup.com/api/partner/v1/me \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
import requests
BASE = "https://folovup.com/api/partner/v1"
KEY = "pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
HEADERS = {"Authorization": f"Bearer {KEY}"}
me = requests.get(f"{BASE}/me", headers=HEADERS, timeout=30)
me.raise_for_status()
print(me.json()["account"])
const BASE = "https://folovup.com/api/partner/v1";
const KEY = process.env.FOLOVUP_KEY; // pk_live_...
const res = await fetch(`${BASE}/me`, { headers: { Authorization: `Bearer ${KEY}` } });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log((await res.json()).account);
GET /me
Returns what the key is, which account it belongs to, and how much data that account has. Use it as a health check, to read your own rate limit, and to size an initial import.
Scope: none (any active key).
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
| — | — | — | — | — | No parameters. |
Request:
curl -s https://folovup.com/api/partner/v1/me \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
Response (200):
{
"api_key": {
"name": "partner-prod",
"prefix": "pk_live_abc123def456",
"scopes": ["companies:read", "contacts:read", "discovery:read", "discovery:write"],
"rate_limit_per_minute": 60,
"expires_at": null
},
"account": {
"id": 42,
"name": "Örnek Dış Ticaret A.Ş.",
"credits": 1250
},
"data": {
"companies": 3211,
"contacts": 4163
},
"server_time": "2026-09-08T09:49:46+00:00"
}
| Field | Type | Meaning | Notes |
|---|---|---|---|
api_key.name |
string | Label given to the key when it was issued. | Not unique; informational. |
api_key.prefix |
string | pk_live_ + first 12 characters of the key (20 characters). |
Safe to log and to quote in support requests. |
api_key.scopes |
array of string | Scopes this key may use. | Values from companies:read, contacts:read, discovery:read, discovery:write, always in that order. A key issued without restriction lists all four; you cannot tell the two cases apart and do not need to. |
api_key.rate_limit_per_minute |
integer | Requests allowed per fixed 60-second window for this key. | 1–6000. Same value as the X-RateLimit-Limit header. |
api_key.expires_at |
string|null | Instant after which the key stops working, ISO-8601 UTC. | null = no expiry. |
account.id |
integer | FolovUp account id the key is bound to. | Internal numeric id; not used as a parameter anywhere. |
account.name |
string | Display name of the account. | |
account.credits |
integer | Current credit balance of the account. | POST /discoveries is refused with 402 below 30; a discovery costs 30 credits to start plus 2 per company created (Discoveries). |
data.companies |
integer | Number of non-deleted companies in the account. | Equals the row count of GET /companies without include_deleted. |
data.contacts |
integer | Number of non-deleted contacts in the account. | Equals the row count of GET /contacts without include_deleted. |
server_time |
string | Current server time, ISO-8601 UTC. | Use it instead of your own clock when you need a "now" that is comparable with updated_at values. |
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Key missing, malformed, unknown, revoked or expired. |
| 403 | account_suspended |
Owning account suspended. |
| 429 | rate_limited |
Quota exhausted. |
401 unauthenticated
Every authentication failure produces the same status, the same body and the same headers, so you cannot (and need not) distinguish the cause from the response:
- no
Authorizationheader; - a value that does not start with
pk_live_or is otherwise malformed; - an unknown prefix or a wrong secret;
- a revoked key;
- an expired key;
- a key whose owning account no longer exists.
{
"error": "unauthenticated",
"message": "Geçerli bir API anahtarı gerekli.",
"docs": "https://folovup.com/en/developers"
}
Headers on a 401: Content-Type: application/json, Cache-Control: no-cache, private, the Link header; no X-RateLimit-* headers (the request did not reach the quota gate and did not consume anything); no WWW-Authenticate header.
What to do: do not retry automatically. Check that the header is present and the full 53-character key is intact, then call GET /me. If /me also answers 401, the key has been revoked or has expired — obtain a new one (Getting access).
403 insufficient_scope and account_suspended
Scope. A valid key calling an endpoint outside its scopes gets 403 with the missing scope in required:
{
"error": "insufficient_scope",
"message": "Bu anahtar bu işlem için yetkili değil.",
"required": "contacts:read"
}
required is exactly one scope string (companies:read, contacts:read, discovery:read or discovery:write). A 403 does not consume rate-limit quota and carries no X-RateLimit-* headers. What to do: do not retry; the key must be reissued with that scope, or the operation must be dropped from your integration.
Suspended account. If the FolovUp account that owns the key is suspended, every endpoint answers:
{
"error": "account_suspended",
"message": "Bu anahtarın bağlı olduğu hesap askıya alınmış."
}
The suspension check runs after authentication and before the scope check and the rate limiter, so a 403 account_suspended carries no X-RateLimit-* headers and does not consume quota; like every partner response it carries the Link header. What to do: stop calling the API for that key and contact destek@folovup.com. This is an account state, not a key state; a new key would be suspended too.
Scopes
Scopes are fixed per key at issue time. Read scopes let you mirror data; the single write scope lets you spend credits and stop running discoveries.
| Scope | Endpoints it unlocks | Side effects |
|---|---|---|
companies:read |
GET /companies, GET /companies/{id} |
none |
contacts:read |
GET /contacts |
none (note that GET /companies?include=contacts and GET /companies/{id} embed contacts under companies:read alone) |
discovery:read |
GET /discoveries, GET /discoveries/{id} |
GET /discoveries/{id} may finalise a single-mode discovery whose work is complete (Discoveries) |
discovery:write |
POST /discoveries, POST /discoveries/{id}/cancel |
spends credits (30 at start + 2 per company created); cancel does not refund |
| (no scope needed) | GET /me, GET /openapi.json, GET /webhook, PUT /webhook, DELETE /webhook, POST /webhook/test |
the webhook endpoints change where this key's deliveries go — any active key can do this, including read-only ones |
A recommended split for a partner platform: one read-only key (companies:read, contacts:read, discovery:read) for the sync worker and a separate key with discovery:write for the component that a human authorises to start discoveries.
Conventions
Transport
- HTTPS only,
https://folovup.com. TLS 1.2 and 1.3; HTTP/2 and HTTP/1.1. http://folovup.com/…answers301 Location: https://folovup.com/…andhttps://www.folovup.com/…answers301to the apex host. Do not rely on either redirect: many HTTP clients drop the body or downgrade a redirectedPOSTtoGET. Always callhttps://folovup.comdirectly.- No IP allow-listing is required; the API sees your real client IP and records the last one used per key.
- Every response — including errors — carries
Link: <https://folovup.com/en/developers/openapi.json>; rel="service-desc", <https://folovup.com/en/developers>; rel="service-doc",Cache-Control: no-cache, privateandVary: Origin. Nothing is cacheable. - CORS. The API answers cross-origin requests from any origin: a preflight
OPTIONSreturns204without authentication, the request'sOriginis echoed back inAccess-Control-Allow-Origintogether withAccess-Control-Allow-Credentials: true, andAccess-Control-Expose-HeaderslistsX-RateLimit-Limit,X-RateLimit-Remaining,Retry-AfterandLinkso browser code can read them. This exists for tooling such as API explorers; a production integration must still call the API from a server, because any page that holds the key hands it to its visitors.
Request and response format
- Responses are always JSON with
Content-Type: application/json(nocharsetparameter; the body is UTF-8). TheAcceptheader is ignored:Accept: text/htmlstill gets JSON. You may sendAccept: application/json; it changes nothing. - Request bodies must be JSON and must be declared as such. For
POST /discoveriesandPUT /webhooksendContent-Type: application/json(any media type containing/jsonor+jsonis accepted). A JSON body sent with a form or missing content type is not parsed: every field looks absent and you get422 validation_failedwith messages such asThe query field is required.. Query-string parameters are never read from the body and body fields are never read from the query string. - JSON booleans in bodies,
1/0in query strings. See Boolean query parameters. - Escaping on the wire. Regular responses are compact JSON in which non-ASCII characters are written as
\uXXXXescapes and/as\/— for example"message":"Geçerli bir API anahtarı gerekli."and"https:\/\/www.example-trafo.com.tr\/". Any JSON parser decodes both forms identically; the examples in this guide show the decoded text. Two things are emitted unescaped: theGET /openapi.jsondocument, and webhook delivery bodies — which matters for signature verification, because the HMAC is computed over the raw bytes we send (Webhooks). - Field order in this guide matches the order the API emits. Do not depend on it. Example blocks tagged
jsoncare trimmed with…inside long arrays and are not valid JSON as shown; every block taggedjsonparses as-is. - Unknown fields may appear at any time (new fields, new enum values). Ignore what you do not know; never fail on an unexpected key (Versioning and changelog).
- Free-text fields stay strings.
founded_year("1998"),established_date(may be partial,"1976-XX-XX"),number_of_employees("50-100"),annual_revenue,business_hours,entity_type,sector,sourceare strings with no enforced format. Store them as text; parse defensively if you must (Companies, Contacts). - List-ish fields vary in shape.
addresses,key_personnel,important_linksare arrays whose item shape is not fixed;social_media_linksis an empty array[]or an object keyed by network name. Treat them as opaque JSON.
Timestamps
All timestamps are ISO-8601 in UTC with an explicit +00:00 offset and one-second resolution:
2026-09-08T09:49:46+00:00
This applies to every *_at field (created_at, updated_at, deleted_at, validated_at, started_at, completed_at, last_activity_at, last_success_at, last_failure_at, expires_at) and to server_time. Unset timestamps are null, never an empty string. Because every value shares the same format and zone, updated_at strings sort correctly as plain strings.
Timestamps you send (updated_since) are parsed as described in updated_since.
Identifiers
Companies and contacts carry two identifiers each; discoveries carry one.
| Field | Type | Example | Properties |
|---|---|---|---|
id (company, contact) |
string, 26-character ULID, Crockford base32, uppercase | 01J9Z0000000000000000000A1 |
Public identifier. Never changes, never reused, globally unique across accounts and across companies and contacts. |
folovup_company_id, folovup_contact_id |
integer | 4821 |
Internal numeric identifier. Stable and never reused, but only meaningful inside FolovUp. |
company_id (on a contact) |
string ULID|null | 01J9Z0000000000000000000A1 |
The parent company's id. null if the parent is unavailable. |
folovup_company_id (on a contact) |
integer|null | 4821 |
The parent company's numeric id. |
discovery_id (on a company) |
integer|null | 128 |
The discovery that created the company; null for companies created before June 2026 or whose discovery was deleted in the web app. |
id (discovery) |
integer | 128 |
Discoveries have no ULID. |
Which to store:
- Key your mirror on the ULID
id. It is the value you will receive in every flow (list, single, embedded) and the valueGET /companies/{id}andGET /contacts?company_id=accept. - Also store
folovup_company_id/folovup_contact_idas a secondary column. It is what you will be asked for in support conversations, it joins todiscovery_id-based questions, andGET /companies/{id}accepts it too (a path segment made only of digits is treated as the numeric id; anything else as a ULID). - Never key a contact on (company, email). A contact's
company_idcan change when a later analysis attributes the same address to a different company; the contact's ownidstays the same. - Where an endpoint accepts either form (
/companies/{id},contacts?company_id=), prefer the ULID; the numeric form exists for convenience and for the one edge case described in Contacts.
Null, empty and absent values
nullmeans "unknown / not set". It is never an empty string.- An empty array
[]means "known to be empty" (for exampleproducts: []). Array-typed fields are in practice nevernullfor companies, but the schema allows it — handle both. - An absent key is not
null.contactson a company object is present only when you asked for it (include=contactson the list; always onGET /companies/{id});companieson a discovery only withinclude=companies. Check for key presence, not fornull, before deciding whether the embed was requested. verification.is_validon a contact is three-state:true,falseornull(unknown or catch-all). Store it in a nullable boolean column; never collapsenulltofalse(Contacts).is_deletedis the only deletion signal for both companies and contacts.deleted_atisnullon the common contact-deletion path even whenis_deletedistrue.
Boolean query parameters
include_deleted, verified_only and exclude_role are validated strictly: send 1 or 0.
| Sent | Result |
|---|---|
include_deleted=1 |
true |
include_deleted=0 |
false |
| omitted | false |
include_deleted=true, =false, =on, =yes, =2 |
422 validation_failed, errors.include_deleted[0] = The include deleted field must be true or false. |
In JSON request bodies (is_active, rotate_secret on PUT /webhook) use JSON true/false.
Rate limiting
The limit is per API key, not per account or IP. Two keys on the same account never affect each other.
Window. A fixed 60-second window opens with the first request that finds no window open, and closes 60 seconds later. Requests are counted within that window; when it closes the count is discarded entirely and the next request opens a fresh window. The window does not slide, so after a 429 the whole quota is available again the moment the window ends.
Headers. Every response that passed authentication and scope checks carries:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Limit is the key's rate_limit_per_minute. X-RateLimit-Remaining is limit − requests counted in the current window, evaluated after the current request. There is no reset header; only a 429 tells you how long the current window still has.
What consumes quota. One unit is charged per request before the endpoint runs, so the outcome does not matter: 200, 202, 402, 404 not_found, 409, 422 (all variants) and 500 all count. HEAD counts like GET. The following are answered before the quota gate and carry no X-RateLimit-* headers: 401 unauthenticated, 403 account_suspended, 403 insufficient_scope, 429 rate_limited, 404 not_found for unknown paths, 405 method_not_allowed, and CORS preflight OPTIONS (204).
When the quota is exhausted the request is rejected with 429, a Retry-After header (whole seconds until the window closes, 0–60) and the same number in the body:
HTTP/2 429
Content-Type: application/json
Retry-After: 60
{
"error": "rate_limited",
"message": "İstek sınırı aşıldı.",
"retry_after": 60
}
Concurrency. The check and the count are not atomic. If you fire many requests in parallel at the boundary, a few more than limit may be accepted. X-RateLimit-Remaining is clamped at 0 and never goes negative, so it cannot show that overshoot; use it as a hint, not for exact accounting.
Backoff recipe. This is what a well-behaved client does; the Python and Node.js loops in Pagination and incremental sync implement it.
- Keep your own token bucket at the key's
rate_limit_per_minute(read it fromGET /meat startup) and never exceed it from your side. Leave headroom (for example 80 %) if more than one process shares the key. - On
429: sleepmax(1, Retry-After) + random(0, 1)seconds, then retry the same request. Do not count the429as an attempt against a retry budget — it is not an error, it is scheduling. - On
500,502,503,504or a network timeout: retry the same request with exponential backoff1 s, 2 s, 4 s, 8 s, 16 s(cap 30 s), at most 5 times. Cursors are stateless, so retrying a page is always safe;POST /discoveriesis safe to retry only with the sameIdempotency-Key. - On
401,403and any other4xx: stop; these do not fix themselves. - If
X-RateLimit-Remainingreaches0and you have noRetry-After, pausing 60 seconds is always sufficient.
Error envelope
Every error response is JSON and carries a stable machine-readable error code. Match on the error code and the HTTP status — never on message, which is human-readable text that may change without notice (see Error reference for the language note and the complete table).
Shape 1 — general errors: error and message, plus at most one context field.
{ "error": "not_found", "message": "Firma bulunamadı." }
| Extra field | Present on | Type | Meaning |
|---|---|---|---|
required |
403 insufficient_scope |
string | The scope the endpoint needs. |
retry_after |
429 rate_limited |
integer | Seconds until the window closes (same as the Retry-After header). |
docs |
401 unauthenticated |
string | URL of this guide. |
Shape 2 — validation errors (422, error = validation_failed): adds errors, an object keyed by parameter name whose values are arrays of English messages. message repeats the first of them and, when there are several, appends (and N more errors).
{
"error": "validation_failed",
"message": "The query field must be at least 10 characters. (and 2 more errors)",
"errors": {
"query": ["The query field must be at least 10 characters."],
"mode": ["The selected mode is invalid."],
"target_company_count": ["The target company count field must not be greater than 500."]
}
}
Keys in errors are the parameter names as you sent them (per_page, updated_since, include_deleted, url); array items use dot notation (events.0). The messages name the field with spaces instead of underscores (The per page field …).
Cross-cutting codes that any endpoint can return (the full table, per endpoint, is in Error reference):
| Status | error |
Shape | Consumes quota | Meaning |
|---|---|---|---|---|
| 401 | unauthenticated |
1 (+ docs) |
no | Key missing/invalid/revoked/expired. |
| 403 | insufficient_scope |
1 (+ required) |
no | Key lacks the scope. |
| 403 | account_suspended |
1 | no | Owning account suspended; checked before the scope check and the rate limiter. |
| 404 | not_found |
1 | yes for known routes, no for unknown paths | Resource not owned by this account or does not exist; or the path does not exist under /api/partner/. |
| 405 | method_not_allowed |
1 | no | Wrong HTTP method; the Allow header lists the supported ones (Allow: GET, HEAD on /me). |
| 422 | validation_failed |
2 | yes | A query parameter or body field is invalid. |
| 422 | invalid_cursor |
1 | yes | cursor present, non-empty and undecodable. |
| 429 | rate_limited |
1 (+ retry_after) |
no | Quota exhausted. |
| 500 | server_error |
1 | yes | Unexpected server failure; no internals are leaked. Retry with backoff. |
Unknown paths (404) and wrong methods (405) are answered before authentication, so a typo in the URL produces one of these even with a missing key — a useful diagnostic: if you expected 401 and got 404, the path is wrong.
Pagination and incremental sync
The three list endpoints — GET /companies, GET /contacts, GET /discoveries — share one pagination model: keyset (cursor) pagination over a fixed ascending (updated_at, id) order, plus an updated_since lower bound. Together they let you copy an account once and then fetch only what changed, without ever skipping or duplicating a row.
List envelope
{
"data": [ … ],
"links": {
"first": null,
"last": null,
"prev": null,
"next": "https://folovup.com/api/partner/v1/companies?per_page=200&include_deleted=1&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wOCAwODoyMzozOSIsImlkIjo0ODIxLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9"
},
"meta": {
"path": "https://folovup.com/api/partner/v1/companies",
"per_page": 200,
"next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wOCAwODoyMzozOSIsImlkIjo0ODIxLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9",
"prev_cursor": null
}
}
| Field | Type | Meaning |
|---|---|---|
data |
array | The page. May be empty ([]). |
links.first, links.last |
null | Always null: keyset pagination has no page numbers. Do not wait for them. |
links.next |
string|null | Absolute URL of the next page: meta.path + every query parameter you sent (except cursor) + the new cursor. null on the last page. |
links.prev |
string|null | Absolute URL of the previous page; null on the first page. |
meta.path |
string | The endpoint URL without query string. |
meta.per_page |
integer | Effective page size (your value or the default). |
meta.next_cursor |
string|null | The cursor to send as ?cursor= for the next page. null means the stream is exhausted. Same information as links.next. |
meta.prev_cursor |
string|null | Cursor of the previous page; null on the first page. |
There is no total count and no page number. GET /me gives data.companies / data.contacts if you want a progress bar.
Cursor mechanics
-
Order is always
updated_atascending, thenidascending. It cannot be changed. Because ties onupdated_atare common (rows written by the same job in the same second), theidtiebreak is what makes the walk exact — never re-sort or de-duplicate onupdated_atalone. -
A cursor is opaque. It is a URL-safe string (
A–Z a–z 0–9 - _, no padding) that encodes the position of the last row you received. Do not build, inspect or edit one; only the server decodes it. It needs no URL-encoding, but encoding it is harmless. -
A cursor is a position, not a session. It carries no filters, no
per_pageand no expiry. It stays valid indefinitely, so you may persistlinks.nextand resume a walk hours later. It is your responsibility to send the sameupdated_since,include_deletedand other filters with every page of one walk — the simplest way is to requestlinks.nextverbatim. -
Changing filters or
per_pagemid-walk does not error; the server continues from the same position with the new filters. Only do this deliberately. -
Backward paging works with
prev_cursor; a sync client never needs it. -
Rows that change during a walk move to the end of the order (their
updated_atbecomes "now"), so you will see them again on a later page of the same walk. This is expected; apply them again. -
Invalid cursor (
?cursor=present, non-empty, undecodable — for example truncated in a log or database column):{ "error": "invalid_cursor", "message": "cursor değeri çözümlenemedi. Yalnız bir önceki yanıttaki meta.next_cursor / meta.prev_cursor değerini gönderin." }HTTP
422. What to do: discard the stored cursor and restart the walk from the beginning with the sameupdated_sinceyou used for that walk; you lose nothing because the checkpoint has not been advanced. An emptycursor=is treated as absent (first page).
Follow the cursor in Node.js:
async function* pages(url, key) {
while (url) {
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 429) {
const wait = Math.max(1, Number(res.headers.get("retry-after") || 60));
await new Promise(r => setTimeout(r, (wait + Math.random()) * 1000));
continue; // same url
}
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const page = await res.json();
yield page.data;
url = page.links.next; // null on the last page
}
}
for await (const rows of pages("https://folovup.com/api/partner/v1/companies?per_page=200&include_deleted=1", process.env.FOLOVUP_KEY)) {
for (const row of rows) console.log(row.id, row.is_deleted);
}
Page size
| Endpoint | per_page default |
per_page maximum |
|---|---|---|
GET /companies |
100 | 200 |
GET /contacts |
200 | 500 |
GET /discoveries |
50 | 100 |
per_page must be an integer ≥ 1. A value above the maximum is rejected with 422 validation_failed (The per page field must not be greater than 200.), not clamped. For a full import use the maximum: fewer requests per rate-limit window. include=contacts on /companies embeds every visible contact of every company on the page with no cap, so a 200-company page can be large; that is fine for a sync worker.
updated_since
updated_since restricts a list to rows whose updated_at is greater than or equal to the given instant (inclusive, one-second resolution). It applies to /companies, /contacts and /discoveries, and it composes with every other filter.
Accepted formats — all are converted to UTC by the server before comparison:
| You send | Interpreted as |
|---|---|
2026-09-08T09:00:00Z |
09:00:00 UTC (recommended form) |
2026-09-08T09:00:00+00:00 |
09:00:00 UTC — the API's own updated_at values can be sent back verbatim; URL-encode the + as %2B (an unencoded + arrives as a space and is still rescued, but do not rely on it) |
2026-09-08T12:00:00+03:00 |
09:00:00 UTC (offset honoured) |
2026-09-08T09:00:00 |
09:00:00 UTC (naive = UTC) |
2026-09-08 09:00:00 |
09:00:00 UTC |
2026-09-08 |
2026-09-08T00:00:00 UTC |
1757322000, yesterday, 2026-13-01, 08/09/2026 |
422 validation_failed, errors.updated_since[0] = The updated since field must be a valid date. |
Rules:
- Inclusive. If you store the largest
updated_atyou have seen and send it back, the row(s) with exactly that timestamp are delivered again. That is intended: it guarantees nothing is lost at the boundary, and your upsert is idempotent anyway. - UTC, one-second resolution. Fractional seconds are accepted but rows are compared at whole seconds.
- Advance it only after a complete walk. While
links.nextis non-null you are inside one consistent walk; changeupdated_sinceonly whennext_cursorcame backnull. Advancing it mid-walk permanently loses the pages you had not fetched yet. - Never a silent empty page. A value the server cannot parse is a
422, not an empty result. - Combine with
include_deleted=1in incremental walks. Deletions are ordinary updates (updated_atmoves,is_deletedbecomestrue); without the flag those rows are filtered out and your mirror keeps deleted records forever.
What a walk with updated_since does not see: rows that were hard-deleted (a company deleted by the account owner in the web app disappears together with its contacts, no tombstone), and a company whose discovery_id became null because its discovery was deleted. A periodic full walk reconciles both (Building a mirror (sync recipe)). Also, changing a contact does not touch its company's updated_at; walk /companies and /contacts as two independent streams with two checkpoints.
Incremental sync algorithm
State you persist per stream (companies, contacts; optionally discoveries):
| Item | Meaning |
|---|---|
checkpoint |
The updated_since value for the next walk. null before the first successful full walk. |
resume_url (optional) |
The links.next of the page you are about to fetch, saved while a walk is in progress so a crash resumes instead of restarting. Cleared when the walk completes. |
procedure SYNC(stream): # stream ∈ {companies, contacts}
checkpoint ← load(stream.checkpoint) # e.g. "2026-09-08T09:12:03Z" or null
url ← load(stream.resume_url)
if url is null:
url ← BASE + "/" + stream
+ "?per_page=" + MAX_PER_PAGE[stream]
+ "&include_deleted=1"
+ ("&updated_since=" + urlencode(checkpoint) if checkpoint else "")
max_seen ← checkpoint
loop:
page ← GET url # with the backoff recipe: 429 → wait Retry-After and repeat,
# 5xx/timeout → exponential backoff and repeat,
# 422 invalid_cursor → url ← first-page URL (same checkpoint), repeat,
# 401/403/other 4xx → abort, alert a human
for row in page.data:
if row.is_deleted:
MARK_DELETED(stream, row.id) # keep the row, flag it; or delete it — your choice
else:
UPSERT(stream, row.id, row) # idempotent: insert or replace by ULID id
if max_seen is null or row.updated_at > max_seen:
max_seen ← row.updated_at # string comparison is safe: same format, same zone
if page.links.next is null:
break
url ← page.links.next
save(stream.resume_url, url) # optional crash-resume
save(stream.checkpoint, max_seen) # ONLY here — after the whole walk
clear(stream.resume_url)
# Schedule: run SYNC(companies) and SYNC(contacts) every N minutes (5–15 is typical).
# A run that finds no rows leaves the checkpoint unchanged.
# Once a day (or week) run a FULL walk (checkpoint ← null) and delete every mirrored row whose id
# was not seen, to catch hard deletions that never produce a tombstone.
Why this is loss-free: the server orders by (updated_at, id); the cursor pins an exact position in that order; a row modified after you passed its position re-appears later in the same walk with its new updated_at; the next walk starts at max_seen inclusively, so the boundary second is covered twice rather than not at all. If you want extra protection against a write that commits a few seconds late with an updated_at slightly older than max_seen, subtract an overlap (for example 300 seconds) from the checkpoint before saving it — the only cost is re-delivering a few rows your upsert already knows.
Two checkpoint choices are equally safe: the largest updated_at seen during the walk (used here) or the server_time read from GET /me immediately before the walk started (used in Building a mirror (sync recipe)). Either way, save it only after the walk completes, and subtract a small overlap.
Rows that should be upserted with special care: a contact's company_id can change between walks (attribute it to the new company); a company's is_deleted can become true and never false again except by an administrator (treat it as final for the purposes of your product).
Python reference loop
A complete, runnable implementation of the algorithm above with the backoff recipe. Replace the Store class with your database.
import json
import random
import time
from pathlib import Path
import requests
BASE = "https://folovup.com/api/partner/v1"
KEY = "pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
HEADERS = {"Authorization": f"Bearer {KEY}", "Accept": "application/json"}
MAX_PER_PAGE = {"companies": 200, "contacts": 500, "discoveries": 100}
class FatalApiError(Exception):
"""401/403/4xx other than 429 and invalid_cursor: a human must look."""
class InvalidCursor(Exception):
"""422 invalid_cursor: restart the current walk from page 1."""
def get(url):
"""GET with the documented backoff. Returns the decoded JSON body."""
delay = 1
for attempt in range(6):
try:
r = requests.get(url, headers=HEADERS, timeout=60)
except requests.RequestException:
time.sleep(min(delay, 30)); delay *= 2
continue
if r.status_code == 429:
wait = max(1, int(r.headers.get("Retry-After", "60")))
time.sleep(wait + random.random())
continue # does not count as an attempt
if r.status_code >= 500:
time.sleep(min(delay, 30)); delay *= 2
continue
body = r.json()
if r.status_code == 422 and body.get("error") == "invalid_cursor":
raise InvalidCursor(body)
if r.status_code >= 400:
raise FatalApiError(r.status_code, body) # 401, 403, 404, other 422 ...
return body
raise RuntimeError(f"gave up on {url}")
class Store:
"""Minimal file-backed store: replace with your database."""
def __init__(self, root="folovup-mirror"):
self.root = Path(root); self.root.mkdir(exist_ok=True)
self.state_file = self.root / "state.json"
self.state = json.loads(self.state_file.read_text()) if self.state_file.exists() else {}
def upsert(self, stream, row):
(self.root / stream).mkdir(exist_ok=True)
(self.root / stream / f"{row['id']}.json").write_text(json.dumps(row, ensure_ascii=False))
def load(self, k): return self.state.get(k)
def save(self, k, v): self.state[k] = v; self.state_file.write_text(json.dumps(self.state))
def clear(self, k): self.state.pop(k, None); self.state_file.write_text(json.dumps(self.state))
def first_page_url(stream, checkpoint):
url = f"{BASE}/{stream}?per_page={MAX_PER_PAGE[stream]}&include_deleted=1"
if checkpoint:
url += "&updated_since=" + requests.utils.quote(checkpoint, safe="")
return url
def sync(stream, store, overlap_seconds=0):
checkpoint = store.load(f"{stream}.checkpoint")
url = store.load(f"{stream}.resume_url") or first_page_url(stream, checkpoint)
max_seen = checkpoint
while url:
try:
page = get(url)
except InvalidCursor:
url = first_page_url(stream, checkpoint) # same checkpoint → nothing lost
continue
for row in page["data"]:
store.upsert(stream, row) # row["is_deleted"] tells you what it is
if max_seen is None or row["updated_at"] > max_seen:
max_seen = row["updated_at"] # same format + zone → string compare is exact
url = page["links"]["next"]
if url:
store.save(f"{stream}.resume_url", url)
if max_seen:
if overlap_seconds:
from datetime import datetime, timedelta
t = datetime.fromisoformat(max_seen) - timedelta(seconds=overlap_seconds)
max_seen = t.strftime("%Y-%m-%dT%H:%M:%SZ")
store.save(f"{stream}.checkpoint", max_seen) # only after a complete walk
store.clear(f"{stream}.resume_url")
if __name__ == "__main__":
store = Store()
me = get(f"{BASE}/me")
print("account", me["account"]["name"], "expect", me["data"])
for stream in ("companies", "contacts"):
sync(stream, store, overlap_seconds=300)
print(stream, "checkpoint now", store.load(f"{stream}.checkpoint"))
Notes on the code:
- The API's
updated_atvalues (…+00:00) are sent back asupdated_sinceunchanged;requests.utils.quoteturns the+into%2B. When an overlap is applied the value is re-emitted in the…Zform. Both forms are accepted. row["is_deleted"]is stored with the row; a consumer that wants a hard delete does it at read time. Keeping tombstones lets you answer "why did this contact disappear" later.- A
FatalApiErroris not caught on purpose: a401or403means configuration, not weather. - Rate limiting is handled reactively (
429→ wait). With a typical 300 requests per minute and 200 companies per page, a 3 000-company account loads in 15 requests; the loop rarely hits the limit at all.
Companies
A company is one organisation that a discovery found and analysed for the account that owns your API key (see Discoveries). Every company belongs to exactly one account; you only ever see the companies of the account your key is bound to (see Authentication). The data comes from the company's own public website plus AI analysis of that content; it is research data, not a verified registry (see Data usage and compliance).
Two endpoints expose companies, both read-only and both requiring the companies:read scope:
| Endpoint | Purpose |
|---|---|
GET /companies |
cursor-paginated list, ordered by (updated_at, id) ascending, with filters and incremental sync |
GET /companies/{id} |
one company by ULID or numeric id, contacts always embedded |
Identifiers: id is a 26-character ULID that never changes and is the key you should store. folovup_company_id is FolovUp's internal integer id; it is stable and never reused, and it is accepted wherever an id is expected. Store both, key on id.
JSON encoding note: like every other endpoint, list and single-resource responses encode non-ASCII characters as \uXXXX escapes and slashes as \/ on the wire (for example "Sanayi A.\u015e.", "https:\/\/example-trafo.com.tr"). Any JSON parser produces the same values as the readable form used in the examples below; do not compare raw bytes. See Request and response format.
Company field reference
All fields are present on every company object, in this order. "Type" is the JSON type; string|null means the value can be JSON null.
| Field | Type | Meaning | Notes / caveats |
|---|---|---|---|
id |
string | Public ULID (26 chars, Crockford base32). Mirror key. | Never null, never changes. |
folovup_company_id |
integer | Internal numeric id. | Stable, never reused. Also accepted by GET /companies/{id} and by GET /contacts?company_id=. |
discovery_id |
integer | null | Id of the discovery that created the company. Filter with GET /companies?discovery_id=. |
name |
string | Display name. | Resolved as legal_name, else short_name, else searched_name. Not null in practice. |
legal_name |
string | null | Registered legal name as found on the site. |
short_name |
string | null | Brand / short name. |
searched_name |
string | The name the company was searched and analysed under. | Never null. Not normalised (case as entered). |
description |
string | null | AI-written summary of what the company does. |
sector |
string | null | Free-text sector label. |
entity_type |
string | null | Kind of organisation. |
naics_code |
string | null | NAICS code as text, e.g. "335311". |
company_code |
string | null | Short abbreviation derived from the name, at most 4 characters, e.g. "ORNK". |
founded_year |
string | null | Four-digit founding year as a string, e.g. "1998". |
established_date |
string | null | Free-text establishment date. |
registration_number |
string | null | Trade-registry / tax number as found on the site. |
number_of_employees |
string | null | Free text, often a range such as "50-100". |
annual_revenue |
string | null | Free text. |
business_hours |
string | null | Free text. |
is_exporter_or_importer |
boolean | null | AI assessment that the company trades internationally. |
headquarter_country_iso |
string | null | ISO-3166-1 alpha-2, upper-case, e.g. "TR". |
website |
string | null | The corporate website confirmed by the analysis, falling back to website_url when no confirmed value exists. |
website_url |
string | null | The URL recorded when the company was created. |
products |
array | null | Product names (strings). |
services |
array | null | Service names (strings). |
products_services |
array | null | Reserved. |
addresses |
array | null | Postal addresses. Usual item shape: {"country": "...", "street_and_city_and_state": "...", "two_digit_iso_country_code": "TR"}. |
phone_numbers |
array | null | Phone numbers as strings, format as found on the site. |
certifications |
array | null | Certification names (strings), e.g. "ISO 9001". |
awards |
array | null | Strings. |
key_personnel |
array | null | Usual item shape {"name": "...", "title": "..."}. |
social_media_links |
array | object | null |
languages_supported |
array | null | Strings. |
payment_methods |
array | null | Strings. |
shipping_countries |
array | null | Strings; country names as written on the site, occasionally free text. |
important_links |
array | null | Usual item shape {"url": "...", "title": "...", "importance_reason": "..."}. |
contact_count |
integer | Number of contacts GET /contacts?company_id={id} returns for this company by default (deleted contacts excluded). |
Computed at request time from the contacts that are visible by default; it is not a stored counter. With include_deleted=1 the embedded contacts array can contain more entries than contact_count. |
is_catch_all_domain |
boolean | Reserved. | false in every current row. Catch-all information is delivered per contact in verification.is_catch_all; use that. |
is_manually_created |
boolean | true if the company was entered by hand in the FolovUp app rather than produced by a discovery. |
false in every current row. |
contacts |
array of Contact | Embedded contacts. | Key present only when you send include=contacts on GET /companies; always present on GET /companies/{id}. When absent, the key is missing entirely (not null). Same visibility rules as GET /contacts; no ordering guarantee; no cap. |
created_at |
string | null | ISO-8601 UTC with +00:00 offset. |
updated_at |
string | null | ISO-8601 UTC with +00:00. Sort key and updated_since key. |
is_deleted |
boolean | Tombstone. true means "remove this company from your mirror". |
The only authoritative deletion signal. Only returned when include_deleted=1 (list) or on GET /companies/{id}. |
deleted_at |
string | null | Deletion time. |
Company field caveats
- Free-text everywhere.
established_date,number_of_employees,annual_revenue,business_hours,registration_numberandfounded_yearare strings extracted from web pages. Store them as text; derive typed values in your own layer if you need them, and expect failures. - Variable-shape arrays.
addresses,key_personnel,important_linksandsocial_media_linksare produced by AI analysis and are not schema-validated. Handle: object vs string items, missing keys, alternative key names, and (forsocial_media_links) an object instead of an array. websitevswebsite_url. Usewebsitefor display and matching; keepwebsite_urlonly if you need the original URL.is_deletedis the authority. For companiesdeleted_athappens to be filled too, but a mirror must apply one rule for both resources:is_deleted === true→ drop.- Hard deletes exist and leave no tombstone. When the account owner deletes a company in the FolovUp app, the company and all its contacts are removed permanently and never appear again, not even with
include_deleted=1. See Deletions and tombstones.
GET /companies
Lists the account's companies, ordered by (updated_at, id) ascending, with cursor pagination and optional filters. This is the endpoint you use for the initial load and for incremental sync (see Pagination and incremental sync).
Scope: companies:read
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
updated_since |
query | string (datetime) | no | — | Returns companies with updated_at >= value (inclusive, 1-second resolution, compared in UTC). Accepted forms: ISO-8601 with Z or a numeric offset (2026-09-08T09:00:00Z, 2026-09-08T09:00:00+00:00, 2026-09-08T12:00:00+03:00 = same instant), naive ISO-8601 (2026-09-08T09:00:00, treated as UTC), YYYY-MM-DD HH:MM:SS (UTC), YYYY-MM-DD (midnight UTC). An unencoded + that arrives as a space is repaired. Anything else (yesterday, epoch seconds, 2026-02-30) → 422 validation_failed. |
cursor |
query | string | no | — | Opaque string from meta.next_cursor (or the links.next URL). Do not build or decode it. A non-empty value that cannot be decoded → 422 invalid_cursor. The cursor carries no filter state: always send the same filters you sent on page 1. |
per_page |
query | integer | no | 100 |
1–200. Values above 200 → 422 validation_failed. |
include_deleted |
query | 1 or 0 |
no | 0 |
1 also returns soft-deleted companies as tombstones (is_deleted: true). Also switches embedded contacts (with include=contacts) to include deleted contacts. Send literally 1/0; other spellings (true, yes, on) → 422 validation_failed. |
include |
query | string | no | — | Comma-separated list of relations to embed. Only contacts is recognised (case-sensitive); unknown tokens are ignored. When present, every company on the page carries a contacts array (possibly empty). |
sector |
query | string (max 255) | no | — | Exact match on sector, case- and accent-insensitive (İnşaat matches insaat, ü matches u; dotless ı does not match i). No partial matching. |
country |
query | string (exactly 2 chars) | no | — | Upper-cased and compared with headquarter_country_iso. Not validated against an ISO list. Any other length → 422 validation_failed. Companies with headquarter_country_iso: null never match. |
discovery_id |
query | integer | no | — | Matches discovery_id. An unknown id returns an empty page (200), not 404. Combine with include_deleted=1 if you also want companies that were soft-deleted after the discovery. |
search |
query | string (max 255) | no | — | Substring match (LIKE %term%) on legal_name, short_name, searched_name and website — not on website_url or description. Case- and accent-insensitive as for sector. % and _ inside the term act as SQL wildcards and are not escaped. |
All filters are AND-combined. links.next / links.prev carry every current query parameter except cursor, so following links.next verbatim is safe.
Request:
curl -sS "https://folovup.com/api/partner/v1/companies?per_page=1&country=TR" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
With a sector filter that needs URL-encoding, and an incremental window:
curl -sS -G "https://folovup.com/api/partner/v1/companies" \
--data-urlencode "sector=Electrical Equipment Manufacturing" \
--data-urlencode "updated_since=2026-09-08T09:00:00Z" \
--data-urlencode "include_deleted=1" \
--data-urlencode "per_page=200" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Response 200 (one company per page for illustration; the contacts key is absent because include=contacts was not sent):
{
"data": [
{
"id": "01J9Z0000000000000000000A1",
"folovup_company_id": 4821,
"discovery_id": 732,
"name": "Örnek Trafo Sanayi A.Ş.",
"legal_name": "Örnek Trafo Sanayi A.Ş.",
"short_name": "Örnek Trafo",
"searched_name": "örnek trafo",
"description": "Örnek Trafo designs and manufactures oil-immersed and dry-type distribution transformers up to 36 kV for utilities, industrial plants and renewable-energy projects. The company operates a single factory in Gebze and exports to Europe and the Middle East.",
"sector": "Electrical Equipment Manufacturing",
"entity_type": "company-organization",
"naics_code": "335311",
"company_code": "ORNK",
"founded_year": "1998",
"established_date": "1998-03-01",
"registration_number": "123456",
"number_of_employees": "50-100",
"annual_revenue": null,
"business_hours": null,
"is_exporter_or_importer": true,
"headquarter_country_iso": "TR",
"website": "https://www.example-trafo.com.tr",
"website_url": "https://example-trafo.com.tr",
"products": ["Oil-immersed distribution transformers", "Dry-type cast-resin transformers", "Special transformers for solar plants", "…"],
"services": ["Transformer maintenance", "On-site commissioning"],
"products_services": null,
"addresses": [
{
"country": "Turkey",
"street_and_city_and_state": "Örnek OSB 3. Cadde No: 12, 41400 Gebze / Kocaeli",
"two_digit_iso_country_code": "TR"
}
],
"phone_numbers": ["+90 262 000 00 00"],
"certifications": ["ISO 9001", "ISO 14001"],
"awards": [],
"key_personnel": [
{ "name": "Ayşe Yılmaz", "title": "Export Manager" }
],
"social_media_links": { "linkedin": "https://www.linkedin.com/company/example-trafo" },
"languages_supported": ["Turkish", "English"],
"payment_methods": [],
"shipping_countries": ["Germany", "Netherlands"],
"important_links": [
{
"url": "https://www.example-trafo.com.tr/contact",
"title": "Contact",
"importance_reason": "Contact details and factory address"
}
],
"contact_count": 2,
"is_catch_all_domain": false,
"is_manually_created": false,
"created_at": "2026-09-07T07:02:39+00:00",
"updated_at": "2026-09-08T08:23:39+00:00",
"is_deleted": false,
"deleted_at": null
}
],
"links": {
"first": null,
"last": null,
"prev": null,
"next": "https://folovup.com/api/partner/v1/companies?per_page=1&country=TR&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wOCAwODoyMzozOSIsImlkIjo0ODIxLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9"
},
"meta": {
"path": "https://folovup.com/api/partner/v1/companies",
"per_page": 1,
"next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wOCAwODoyMzozOSIsImlkIjo0ODIxLCJfcG9pbnRzVG9OZXh0SXRlbXMiOnRydWV9",
"prev_cursor": null
}
}
links.first and links.last are always null. On the last page meta.next_cursor and links.next are null. prev_cursor / links.prev are non-null from the second page on. A page can be empty ("data": []) with next_cursor: null when nothing matches; that is a normal result, not an error.
With include=contacts, each item additionally carries "contacts": [ ...Contact objects... ] placed between is_manually_created and created_at; the embedded objects have exactly the shape documented under Contact field reference, and contacts[].company_id always equals the enclosing company's id.
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired API key. |
| 403 | insufficient_scope |
Key lacks companies:read (required field says so). |
| 403 | account_suspended |
The account that owns the key is suspended. |
| 422 | validation_failed |
Bad parameter value: per_page out of 1–200, country not 2 characters, include_deleted not 1/0, discovery_id not an integer, sector/search longer than 255, unparseable updated_since. Body has errors: {"per_page": ["..."]} keyed by the offending parameter. |
| 422 | invalid_cursor |
cursor is present, non-empty and cannot be decoded. Restart the pass from page 1 (see Pagination and incremental sync). |
| 429 | rate_limited |
Per-key rate limit exceeded; wait Retry-After seconds. |
GET /companies/{id}
Returns one company with its contacts embedded. Accepts either identifier form and also returns soft-deleted companies.
Scope: companies:read
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
id |
path | string | yes | — | Either the ULID id (01J9Z0000000000000000000A1) or the numeric folovup_company_id (4821). A value made only of digits is treated as the numeric id. |
include |
query | string | no | — | Accepted for symmetry with the list endpoint but not needed: contacts is always embedded on this endpoint. |
include_deleted |
query | 1 or 0 |
no | 0 |
Controls only the embedded contacts: with 1, deleted contacts are embedded as tombstones. The company itself is returned whether or not it is deleted. |
Request (ULID and numeric forms return identical bodies):
curl -sS "https://folovup.com/api/partner/v1/companies/01J9Z0000000000000000000A1" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
curl -sS "https://folovup.com/api/partner/v1/companies/4821" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Response 200 — a single object wrapped in data; note the contacts array between is_manually_created and created_at:
{
"data": {
"id": "01J9Z0000000000000000000A1",
"folovup_company_id": 4821,
"discovery_id": 732,
"name": "Örnek Trafo Sanayi A.Ş.",
"legal_name": "Örnek Trafo Sanayi A.Ş.",
"short_name": "Örnek Trafo",
"searched_name": "örnek trafo",
"description": "Örnek Trafo designs and manufactures oil-immersed and dry-type distribution transformers up to 36 kV for utilities, industrial plants and renewable-energy projects. The company operates a single factory in Gebze and exports to Europe and the Middle East.",
"sector": "Electrical Equipment Manufacturing",
"entity_type": "company-organization",
"naics_code": "335311",
"company_code": "ORNK",
"founded_year": "1998",
"established_date": "1998-03-01",
"registration_number": "123456",
"number_of_employees": "50-100",
"annual_revenue": null,
"business_hours": null,
"is_exporter_or_importer": true,
"headquarter_country_iso": "TR",
"website": "https://www.example-trafo.com.tr",
"website_url": "https://example-trafo.com.tr",
"products": ["Oil-immersed distribution transformers", "Dry-type cast-resin transformers", "Special transformers for solar plants", "…"],
"services": ["Transformer maintenance", "On-site commissioning"],
"products_services": null,
"addresses": [
{
"country": "Turkey",
"street_and_city_and_state": "Örnek OSB 3. Cadde No: 12, 41400 Gebze / Kocaeli",
"two_digit_iso_country_code": "TR"
}
],
"phone_numbers": ["+90 262 000 00 00"],
"certifications": ["ISO 9001", "ISO 14001"],
"awards": [],
"key_personnel": [
{ "name": "Ayşe Yılmaz", "title": "Export Manager" }
],
"social_media_links": { "linkedin": "https://www.linkedin.com/company/example-trafo" },
"languages_supported": ["Turkish", "English"],
"payment_methods": [],
"shipping_countries": ["Germany", "Netherlands"],
"important_links": [
{
"url": "https://www.example-trafo.com.tr/contact",
"title": "Contact",
"importance_reason": "Contact details and factory address"
}
],
"contact_count": 2,
"is_catch_all_domain": false,
"is_manually_created": false,
"contacts": [
{
"id": "01J9Z0000000000000000000C1",
"folovup_contact_id": 18341,
"company_id": "01J9Z0000000000000000000A1",
"folovup_company_id": 4821,
"email": "ayse.yilmaz@example-trafo.com.tr",
"name": "Ayşe",
"surname": "Yılmaz",
"position": "Export Manager",
"department": null,
"phone": null,
"linkedin_url": null,
"source": "lead_discovery",
"source_url": "https://www.example-trafo.com.tr/contact",
"verification": {
"status": "valid",
"is_valid": true,
"is_catch_all": false,
"is_role_email": false,
"is_free_email": false,
"validated_at": "2026-09-07T22:14:03+00:00"
},
"status": "active",
"created_at": "2026-09-07T07:02:39+00:00",
"updated_at": "2026-09-07T22:14:03+00:00",
"is_deleted": false,
"deleted_at": null
},
{
"id": "01J9Z0000000000000000000C2",
"folovup_contact_id": 18342,
"company_id": "01J9Z0000000000000000000A1",
"folovup_company_id": 4821,
"email": "mehmet.kaya@example-trafo.com.tr",
"name": null,
"surname": null,
"position": null,
"department": null,
"phone": null,
"linkedin_url": null,
"source": "lead_discovery",
"source_url": "https://www.example-trafo.com.tr/contact",
"verification": {
"status": null,
"is_valid": null,
"is_catch_all": false,
"is_role_email": false,
"is_free_email": false,
"validated_at": null
},
"status": "active",
"created_at": "2026-09-07T07:02:39+00:00",
"updated_at": "2026-09-08T09:12:44+00:00",
"is_deleted": false,
"deleted_at": null
}
],
"created_at": "2026-09-07T07:02:39+00:00",
"updated_at": "2026-09-08T08:23:39+00:00",
"is_deleted": false,
"deleted_at": null
}
}
A soft-deleted company is returned the same way with "is_deleted": true and a non-null deleted_at; you do not need include_deleted=1 to fetch it. A company that was hard-deleted (owner deleted it in the app) no longer exists and returns 404.
Response 404:
{ "error": "not_found", "message": "Firma bulunamadı." }
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Invalid or missing API key. |
| 403 | insufficient_scope |
Key lacks companies:read. |
| 403 | account_suspended |
Owning account is suspended. |
| 404 | not_found |
No company with that id in this account — including ids that belong to another account and hard-deleted companies. |
| 429 | rate_limited |
Per-key rate limit exceeded. |
Contacts
A contact is one email address found on a company's public website (contact, about, imprint or similar pages), with whatever name, position and phone could be read next to it. The contract around contacts is strict:
is_deletedis the only deletion signal;deleted_atstaysnullon the normal deletion path.verification.is_validis three-state:true,falseornull;nullis notfalse.- A contact belongs to one company at a time, but that company can change (see caveats).
One endpoint, requiring the contacts:read scope: GET /contacts. The same objects are embedded in company responses (GET /companies?include=contacts, always on GET /companies/{id}), with the same shape and the same visibility rules.
Contact field reference
| Field | Type | Meaning | Notes / caveats |
|---|---|---|---|
id |
string | Public ULID of the contact. Mirror key. | Never null, never changes. |
folovup_contact_id |
integer | Internal numeric id. | Stable, never reused. |
company_id |
string | null | ULID of the parent company (= Company.id). |
folovup_company_id |
integer | null | Numeric id of the parent company. |
email |
string | The address. Unique within the account. | Never null. |
name |
string | null | First name, when it could be read next to the address. |
surname |
string | null | Last name. |
position |
string | null | Job title as written on the page. |
department |
string | null | |
phone |
string | null | Phone number found next to the address, as written. |
linkedin_url |
string | null | Profile URL when present on the page. |
source |
string | null | How the address was obtained. |
source_url |
string | null | The page the address was found on. |
verification |
object | Result of FolovUp's own SMTP-based verification. See Verification object. | Always present. |
status |
string | null | Record status. |
created_at |
string | null | ISO-8601 UTC with +00:00. |
updated_at |
string | null | ISO-8601 UTC with +00:00. Sort key and updated_since key. |
is_deleted |
boolean | Tombstone. true means "remove this contact from your mirror". |
Authoritative. Tombstones are only returned with include_deleted=1. |
deleted_at |
string | null | Soft-delete time. |
Verification object
{
"verification": {
"status": "valid",
"is_valid": true,
"is_catch_all": false,
"is_role_email": false,
"is_free_email": false,
"validated_at": "2026-09-07T22:14:03+00:00"
}
}
| Field | Type | Meaning |
|---|---|---|
status |
string | null |
is_valid |
boolean | null |
is_catch_all |
boolean | true when the domain's mail server accepts any recipient, so the mailbox could not be confirmed individually. Verified once per domain; for such domains status is catch-all and is_valid is null. false also when not yet checked. |
is_role_email |
boolean | true when the local part is a role mailbox (info, contact, sales, support, admin, office, hello, help, team, marketing, billing, hr, jobs, press, legal, postmaster, noreply, ...). Recorded by verification; for a contact that has not been verified yet it is derived from the address at read time (local part against the role list), so false means "not a role address" in both states. |
is_free_email |
boolean | true when the domain is a public free-mail provider (gmail.com, outlook.com, yahoo.com, gmx.de, mail.ru, ...). Same derivation as is_role_email (domain against the free-provider list when not yet verified): false means "not a free-mail address" whether or not the contact has been verified. Discovery targets corporate domains, so true is rare. |
validated_at |
string | null |
status literals and their is_valid mapping:
status |
is_valid |
Meaning |
|---|---|---|
null |
null |
Never verified. The default state of a newly discovered contact; the large majority of contacts are in this state. |
valid |
true |
The receiving server confirmed the mailbox. A "mailbox full" answer also counts as valid (the mailbox exists). |
invalid |
false |
Syntax error, domain has no mail server, or the server said the user does not exist. |
catch-all |
null |
The domain accepts every recipient; the individual mailbox cannot be confirmed. is_catch_all is true. |
unknown |
null |
The server would not say: "cannot verify" answers, greylisting / temporary failures, policy or reputation blocks, verification budget exhausted, or verification disabled. A policy block is never reported as invalid. |
do_not_mail |
false |
Disposable / throw-away domain. |
spamtrap |
false |
Reserved literal; not produced by the current verification engine. |
abuse |
false |
Reserved literal; not produced by the current verification engine. |
Verification is asynchronous and rate-limited per mail server, so a newly discovered contact stays at status: null for a while — from minutes to days — and can change to any of the values above later. Each change bumps updated_at, so an incremental pass on /contacts delivers the new verdict; the parent company's updated_at does not move.
GET /contacts
Lists the account's contacts, ordered by (updated_at, id) ascending, with cursor pagination. Use it — not include=contacts — for syncing contacts, because it is the only flow with updated_since.
Scope: contacts:read
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
updated_since |
query | string (datetime) | no | — | Same semantics and accepted forms as on GET /companies: inclusive updated_at >= value, compared in UTC; unparseable → 422 validation_failed. |
cursor |
query | string | no | — | Opaque; from meta.next_cursor. Undecodable → 422 invalid_cursor. Carries no filter state. |
per_page |
query | integer | no | 200 |
1–500. Above 500 → 422 validation_failed. |
include_deleted |
query | 1 or 0 |
no | 0 |
1 returns deleted contacts as tombstones (is_deleted: true, deleted_at usually null). Required for incremental sync. Other spellings → 422 validation_failed. |
company_id |
query | string (max 64) | no | — | Restrict to one company. Accepts the company ULID (01J9Z0000000000000000000A1) or the numeric folovup_company_id (4821). An unknown company returns an empty page (200). If the parent company is soft-deleted, only the numeric form finds its contacts; the ULID form returns an empty page. |
verified_only |
query | 1 or 0 |
no | 0 |
1 keeps only contacts with verification.is_valid === true. Trap: this drops null (unknown and catch-all) as well as false. Because most contacts are unverified, verified_only=1 typically returns a small fraction of the account's contacts. It is a convenience for one-off pulls, not a sync filter — never sync with it or you will miss verdicts that arrive later. |
exclude_role |
query | 1 or 0 |
no | 0 |
1 removes role addresses: contacts whose verification recorded is_role_email as true and contacts whose local part is on the same role list the response derives is_role_email from (info@, sales@, …), so unverified role addresses are excluded too. The filter and the field agree: an address returned with is_role_email: true is never returned under exclude_role=1. |
All filters are AND-combined; links.next preserves them.
Request — first page of an incremental pass:
curl -sS -G "https://folovup.com/api/partner/v1/contacts" \
--data-urlencode "updated_since=2026-09-08T09:00:00Z" \
--data-urlencode "include_deleted=1" \
--data-urlencode "per_page=500" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Request — contacts of one company:
curl -sS "https://folovup.com/api/partner/v1/contacts?company_id=01J9Z0000000000000000000A1" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Response 200 (per_page=2 for illustration; the second row is a tombstone, visible because include_deleted=1 was sent):
{
"data": [
{
"id": "01J9Z0000000000000000000C1",
"folovup_contact_id": 18341,
"company_id": "01J9Z0000000000000000000A1",
"folovup_company_id": 4821,
"email": "ayse.yilmaz@example-trafo.com.tr",
"name": "Ayşe",
"surname": "Yılmaz",
"position": "Export Manager",
"department": null,
"phone": null,
"linkedin_url": null,
"source": "lead_discovery",
"source_url": "https://www.example-trafo.com.tr/contact",
"verification": {
"status": "valid",
"is_valid": true,
"is_catch_all": false,
"is_role_email": false,
"is_free_email": false,
"validated_at": "2026-09-07T22:14:03+00:00"
},
"status": "active",
"created_at": "2026-09-07T07:02:39+00:00",
"updated_at": "2026-09-07T22:14:03+00:00",
"is_deleted": false,
"deleted_at": null
},
{
"id": "01J9Z0000000000000000000C3",
"folovup_contact_id": 18342,
"company_id": "01J9Z0000000000000000000A2",
"folovup_company_id": 4822,
"email": "muhasebe@example-kablo.com.tr",
"name": null,
"surname": null,
"position": null,
"department": null,
"phone": null,
"linkedin_url": null,
"source": "web_search",
"source_url": null,
"verification": {
"status": null,
"is_valid": null,
"is_catch_all": false,
"is_role_email": false,
"is_free_email": false,
"validated_at": null
},
"status": "active",
"created_at": "2026-08-15T14:41:34+00:00",
"updated_at": "2026-09-08T09:12:44+00:00",
"is_deleted": true,
"deleted_at": null
}
],
"links": {
"first": null,
"last": null,
"prev": null,
"next": "https://folovup.com/api/partner/v1/contacts?updated_since=2026-09-08T09%3A00%3A00Z&include_deleted=1&per_page=2&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wOCAwOToxMjo0NCIsImlkIjoxODM0MiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ"
},
"meta": {
"path": "https://folovup.com/api/partner/v1/contacts",
"per_page": 2,
"next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wOCAwOToxMjo0NCIsImlkIjoxODM0MiwiX3BvaW50c1RvTmV4dEl0ZW1zIjp0cnVlfQ",
"prev_cursor": null
}
}
Read the tombstone row carefully: is_deleted is true while deleted_at is null and status is "active". That is the normal shape of a deleted contact.
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Invalid or missing API key. |
| 403 | insufficient_scope |
Key lacks contacts:read (required field says so). |
| 403 | account_suspended |
Owning account is suspended. |
| 422 | validation_failed |
per_page out of 1–500, boolean parameter not 1/0, company_id longer than 64 characters, unparseable updated_since. Body has errors: {"per_page": ["..."]} keyed by the offending parameter. |
| 422 | invalid_cursor |
cursor present, non-empty and undecodable. |
| 429 | rate_limited |
Per-key rate limit exceeded; wait Retry-After seconds. |
Building a mirror (sync recipe)
This section turns the two resources into a local copy that stays correct over time. Read Pagination and incremental sync first for the cursor mechanics; this section is about the data-level rules that a naive "walk and upsert" gets wrong.
Ground rules that follow from the resource contracts above:
- Key companies on
Company.idand contacts onContact.id(the ULIDs). Upserts must be idempotent — the same row will be delivered more than once. - Sync companies and contacts as two independent streams. A change to a contact never bumps its company's
updated_at, soGET /companies?updated_since=will not tell you about new, verified, moved or deleted contacts. - Always pass
include_deleted=1on incremental passes so tombstones arrive; applyis_deleted === true→ mark deleted locally. Never look atdeleted_at. - Some deletions have no tombstone (owner-side company deletion) and some changes have no
updated_atbump (discovery_idbecomingnull). A periodic full sweep is part of the design, not an optional extra. - Never advance
updated_sincein the middle of a pass. Persist the cursor between pages; persist the newupdated_sinceonly afternext_cursorcame backnull.
Initial full load
Walk each resource once without updated_since, using the maximum page size, following meta.next_cursor until it is null. Before starting, read server_time from GET /me and store it as pass_started_at — it becomes the updated_since of your first incremental pass. The shell version below needs curl and jq and has no 429 handling; the Python and Node.js versions under Incremental sync loop handle 429 and restarts.
KEY="pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
B="https://folovup.com/api/partner/v1"
H1="Authorization: Bearer $KEY"; H2="Accept: application/json"
PASS_STARTED_AT=$(curl -sS "$B/me" -H "$H1" -H "$H2" | jq -r .server_time)
for RES in "companies 200" "contacts 500"; do
set -- $RES; NAME=$1; PP=$2; CURSOR=""; PAGE=0
while :; do
URL="$B/$NAME?per_page=$PP&include_deleted=1"
[ -n "$CURSOR" ] && URL="$URL&cursor=$CURSOR"
BODY=$(curl -sS "$URL" -H "$H1" -H "$H2")
if [ "$(echo "$BODY" | jq -r '.error // empty')" != "" ]; then
echo "ERROR on $NAME page $PAGE: $BODY" >&2; exit 1
fi
PAGE=$((PAGE+1))
echo "$BODY" | jq -c '.data[]' >> "./$NAME.ndjson" # upsert these rows into your store
CURSOR=$(echo "$BODY" | jq -r '.meta.next_cursor // empty')
[ -z "$CURSOR" ] && break
done
echo "$NAME: $PAGE pages"
done
echo "next updated_since for both resources: $PASS_STARTED_AT"
include_deleted=1 on the initial load is deliberate: it gives you the tombstones that already exist, so the mirror starts in the same state a long-running mirror would be in.
The total number of rows you should end up with (excluding tombstones) is what GET /me reports in data.companies and data.contacts — both counts use the same default visibility as the list endpoints. A difference of a few rows is expected while a discovery is running; a persistent difference means a page was lost — rerun the load.
Incremental sync loop
Run the same walk with updated_since=<pass_started_at of the previous pass> and include_deleted=1, for companies and for contacts separately. (This recipe checkpoints on the server_time read before each pass; the reference loop in Incremental sync algorithm checkpoints on the largest updated_at seen instead. Both are correct; use one consistently.) Because updated_since is inclusive with 1-second resolution, and because rows can be committed with a timestamp slightly before you read server_time, you will see boundary rows again; that is why upserts must be idempotent. Subtracting a safety overlap (for example 120 seconds) from pass_started_at before using it is recommended; it costs a few duplicate upserts and removes the last race.
Python (requests), complete for both resources, restartable in the middle of a pass:
import json, os, time, requests
from datetime import datetime, timedelta, timezone
BASE = "https://folovup.com/api/partner/v1"
HEADERS = {
"Authorization": "Bearer " + os.environ["FOLOVUP_API_KEY"],
"Accept": "application/json",
}
STATE_FILE = "folovup_sync_state.json" # {"companies": {"updated_since": ..., "cursor": ...}, "contacts": {...}}
PAGE_SIZE = {"companies": 200, "contacts": 500}
OVERLAP = timedelta(seconds=120)
def load_state():
if os.path.exists(STATE_FILE):
with open(STATE_FILE) as f:
return json.load(f)
return {"companies": {"updated_since": None, "cursor": None},
"contacts": {"updated_since": None, "cursor": None}}
def save_state(state):
tmp = STATE_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(state, f)
os.replace(tmp, STATE_FILE)
def get(path, params):
while True:
r = requests.get(BASE + path, headers=HEADERS, params=params, timeout=60)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "60")))
continue
body = r.json()
if r.status_code != 200:
raise RuntimeError(f"{r.status_code} {body.get('error')}: {body}")
return body
def server_time():
return get("/me", {})["server_time"] # e.g. "2026-09-08T09:49:46+00:00"
def upsert_company(row): # implement for your store; key = row["id"]
...
def upsert_contact(row): # implement for your store; key = row["id"]; overwrite row["company_id"]
...
def sync(resource, upsert, state):
st = state[resource]
if st["cursor"] is None: # starting a fresh pass
st["pass_started_at"] = server_time()
save_state(state)
params = {"per_page": PAGE_SIZE[resource], "include_deleted": 1}
if st["updated_since"]:
since = datetime.fromisoformat(st["updated_since"]).astimezone(timezone.utc) - OVERLAP
params["updated_since"] = since.strftime("%Y-%m-%dT%H:%M:%SZ")
if st["cursor"]:
params["cursor"] = st["cursor"]
while True:
try:
page = get(f"/{resource}", params)
except RuntimeError as e:
if "invalid_cursor" in str(e): # restart this pass from page 1
st["cursor"] = None; params.pop("cursor", None); save_state(state); continue
raise
for row in page["data"]:
upsert(row) # row["is_deleted"] is True -> mark deleted locally
st["cursor"] = page["meta"]["next_cursor"]
save_state(state) # cursor persisted after every page
if st["cursor"] is None:
break
params["cursor"] = st["cursor"]
st["updated_since"] = st["pass_started_at"] # advance ONLY after a complete pass
save_state(state)
if __name__ == "__main__":
state = load_state()
sync("companies", upsert_company, state)
sync("contacts", upsert_contact, state)
Node.js (fetch, no dependencies) — the same loop for one resource:
const BASE = "https://folovup.com/api/partner/v1";
const HEADERS = { Authorization: "Bearer " + process.env.FOLOVUP_API_KEY, Accept: "application/json" };
async function get(path, params) {
for (;;) {
const url = new URL(BASE + path);
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null) url.searchParams.set(k, v);
const res = await fetch(url, { headers: HEADERS });
if (res.status === 429) { await new Promise(r => setTimeout(r, 1000 * Number(res.headers.get("retry-after") || 60))); continue; }
const body = await res.json();
if (res.status !== 200) throw new Error(`${res.status} ${body.error}: ${JSON.stringify(body)}`);
return body;
}
}
// state = { updated_since: string|null, cursor: string|null, pass_started_at: string|null }
async function sync(resource, perPage, state, upsert, saveState) {
if (!state.cursor) { state.pass_started_at = (await get("/me", {})).server_time; await saveState(state); }
const since = state.updated_since
? new Date(new Date(state.updated_since).getTime() - 120_000).toISOString().replace(/\.\d{3}Z$/, "Z")
: undefined;
for (;;) {
let page;
try {
page = await get(`/${resource}`, { per_page: perPage, include_deleted: 1, updated_since: since, cursor: state.cursor });
} catch (e) {
if (String(e).includes("invalid_cursor")) { state.cursor = null; await saveState(state); continue; }
throw e;
}
for (const row of page.data) await upsert(row); // row.is_deleted === true -> mark deleted
state.cursor = page.meta.next_cursor;
await saveState(state);
if (!state.cursor) break;
}
state.updated_since = state.pass_started_at; // advance only after the whole pass
await saveState(state);
}
How often to run it: FolovUp has no push channel for company or contact changes — webhooks only announce discovery completion (see Webhooks). A practical schedule is an incremental pass every 5–15 minutes, plus one pass immediately after each discovery.completed / discovery.cancelled delivery. Each pass costs one request per 200 changed companies and one per 500 changed contacts, plus one GET /me; check your key's rate limit in Limits and quotas.
What an incremental pass on /contacts delivers, and how to apply it:
| What happened in FolovUp | What you receive | Action |
|---|---|---|
| new contact discovered | new id, is_deleted: false |
insert |
| verification finished / changed | same id, new verification.*, bumped updated_at |
update; store is_valid as a nullable boolean |
| contact deleted by the owner | same id, is_deleted: true, deleted_at: null |
mark deleted |
| deleted contact re-found by a later discovery | same id, still is_deleted: true, bumped updated_at, possibly new company_id |
keep it deleted |
| contact moved to another company | same id, different company_id / folovup_company_id |
overwrite the parent reference |
| contact's company soft-deleted by FolovUp support | same id, company_id: null, folovup_company_id still set — only if the contact itself was also touched; otherwise you learn it from the company stream |
keep the contact; resolve the parent via folovup_company_id if needed |
What an incremental pass on /companies delivers:
| What happened in FolovUp | What you receive | Action |
|---|---|---|
| new company created by a discovery | new id, discovery_id set |
insert |
| company re-analysed / edited | same id, bumped updated_at |
update |
| company soft-deleted by FolovUp support | same id, is_deleted: true, deleted_at set |
mark deleted; its contacts stay live and keep folovup_company_id |
| company hard-deleted by the owner | nothing | detected only by the Reconciliation sweep |
| its discovery deleted | nothing (discovery_id becomes null silently) |
refreshed by the sweep |
Deletions and tombstones
There are three ways data disappears, and they look different on the wire:
| Action | Company stream | Contact stream |
|---|---|---|
| Owner deletes a contact in the FolovUp app | — | tombstone: is_deleted: true, deleted_at: null, updated_at bumped. Delivered with include_deleted=1. |
| Owner deletes a company in the FolovUp app | no tombstone — the row is gone permanently, also from include_deleted=1 results and from GET /companies/{id} (404) |
no tombstone — all of its contacts are removed with it |
| FolovUp support soft-deletes a company | tombstone: is_deleted: true, deleted_at set, updated_at bumped |
contacts remain; in /contacts they show company_id: null with folovup_company_id set |
| Owner deletes a discovery | discovery_id → null on its companies, no updated_at bump |
— |
Consequences:
- A mirror that only follows
updated_sincewill keep owner-deleted companies (and their contacts) forever. The only remedy is a full id sweep; see the next section. - Tombstones can be re-delivered (a deleted address that a later discovery finds again keeps
is_deleted: truebut gets a freshupdated_at). Applying "is_deletedtrue → deleted" idempotently handles this. - Whether you physically delete or just flag tombstoned rows is your choice, but under KVKK/GDPR you must stop using a contact once it is tombstoned (see Data usage and compliance). Keeping the ULID with a
deletedflag is useful so a re-delivered tombstone does not look like a new contact.
Reconciliation sweep
Purpose: catch the two silent changes — hard-deleted companies/contacts and discovery_id set to null — and prove that no page was ever lost.
Cheap check (one request): compare GET /me data.companies and data.contacts with your local counts of rows where is_deleted is false. Do it when the account has no discovery in processing status (GET /discoveries?status=processing returns an empty page), otherwise the counts move while you look. Equal counts do not prove equality of the id sets, but unequal counts prove a problem.
Full sweep (many requests, but read-only and cheap): walk GET /companies?per_page=200 and GET /contacts?per_page=500 without updated_since and without include_deleted, collecting every id, and upserting every row you receive (this also refreshes discovery_id and company_id). When the walk ends (next_cursor: null):
- every local company flagged live whose
idwas not seen → mark deleted (it was hard-deleted; there will never be a tombstone); - every local contact flagged live whose
idwas not seen → mark deleted (either deleted with its company, or a tombstone you never processed).
Do not use the sweep's end time as the next updated_since unless the sweep also carried include_deleted=1; otherwise tombstones created during the sweep would be skipped. The simplest schedule: sweep daily (or after the cheap check fails), and let the normal incremental loop continue with its own updated_since.
Sweep sizing: an account with 3,000 companies and 4,000 contacts is 15 + 8 requests; at a rate limit of 300 requests per minute (check your key's rate_limit_per_minute in GET /me) it finishes in well under a minute.
Cross-flow consistency check
The same contacts are visible in two flows: standalone (GET /contacts) and embedded (GET /companies?include=contacts). The contract guarantees they agree under the same visibility: the set of data[].id from /contacts (default visibility) equals the union of data[].contacts[].id from /companies?include=contacts (default visibility), with two allowed exceptions that appear only in the standalone flow — contacts with company_id: null and contacts whose parent company is soft-deleted.
Run this check once after your initial load and occasionally afterwards; a difference outside the two exceptions is a bug on FolovUp's side and should be reported (see Support) with a few of the differing ids.
KEY="pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
B="https://folovup.com/api/partner/v1"
H1="Authorization: Bearer $KEY"; H2="Accept: application/json"
walk() { # $1 = path with query, $2 = jq expression yielding ids
local C="" U
while :; do
U="$B$1"; [ -n "$C" ] && U="$U&cursor=$C"
BODY=$(curl -sS "$U" -H "$H1" -H "$H2")
echo "$BODY" | jq -r "$2"
C=$(echo "$BODY" | jq -r '.meta.next_cursor // empty'); [ -z "$C" ] && break
done
}
walk "/contacts?per_page=500" '.data[] | select(.company_id != null) | .id' | sort -u > standalone.txt
walk "/companies?per_page=100&include=contacts" '.data[].contacts[].id' | sort -u > embedded.txt
echo "only in /contacts: $(comm -23 standalone.txt embedded.txt | wc -l)"
echo "only in /companies?include=: $(comm -13 standalone.txt embedded.txt | wc -l)"
Both numbers should be 0. Contacts with company_id: null — including those whose parent company is soft-deleted, which the standalone flow reports with company_id: null — are excluded up front by the select.
Recommended mirror schema
Generic SQL; adjust types to your database. The points that matter: ULID primary keys, nullable is_valid, text (not date/integer) for the free-text fields, JSON for the variable-shape arrays, no foreign-key constraint from contacts to companies (a parent can arrive later, be tombstoned, or be hard-deleted while its contacts are still delivered), and a last_seen_at column that the reconciliation sweep uses.
CREATE TABLE folovup_companies (
id CHAR(26) PRIMARY KEY, -- Company.id (ULID)
folovup_company_id BIGINT NOT NULL UNIQUE, -- stable numeric id
discovery_id BIGINT NULL, -- may become NULL silently
name TEXT NOT NULL,
legal_name TEXT NULL,
short_name TEXT NULL,
searched_name TEXT NOT NULL,
description TEXT NULL,
sector TEXT NULL,
entity_type TEXT NULL,
naics_code TEXT NULL,
company_code VARCHAR(8) NULL,
founded_year VARCHAR(4) NULL, -- string on purpose
established_date TEXT NULL, -- free text, never cast
registration_number TEXT NULL,
number_of_employees TEXT NULL, -- free text
annual_revenue TEXT NULL, -- free text
business_hours TEXT NULL,
is_exporter_or_importer BOOLEAN NULL,
headquarter_country_iso CHAR(2) NULL,
website TEXT NULL,
website_url TEXT NULL,
products JSON NULL,
services JSON NULL,
products_services JSON NULL,
addresses JSON NULL, -- array of objects, occasionally a string
phone_numbers JSON NULL,
certifications JSON NULL,
awards JSON NULL,
key_personnel JSON NULL,
social_media_links JSON NULL, -- [] or an object keyed by network
languages_supported JSON NULL,
payment_methods JSON NULL,
shipping_countries JSON NULL,
important_links JSON NULL,
contact_count INTEGER NOT NULL DEFAULT 0,
is_catch_all_domain BOOLEAN NOT NULL DEFAULT FALSE,
is_manually_created BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NULL, -- store as UTC
updated_at TIMESTAMP NULL, -- store as UTC
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMP NULL,
last_seen_at TIMESTAMP NOT NULL -- set on every upsert and every sweep
);
CREATE TABLE folovup_contacts (
id CHAR(26) PRIMARY KEY, -- Contact.id (ULID)
folovup_contact_id BIGINT NOT NULL UNIQUE,
company_id CHAR(26) NULL, -- Company.id; NO foreign key constraint
folovup_company_id BIGINT NULL,
email TEXT NOT NULL,
name TEXT NULL,
surname TEXT NULL,
position TEXT NULL,
department TEXT NULL,
phone TEXT NULL,
linkedin_url TEXT NULL,
source TEXT NULL, -- open string
source_url TEXT NULL,
verification_status VARCHAR(16) NULL, -- NULL | valid | invalid | catch-all | unknown | do_not_mail | spamtrap | abuse
is_valid BOOLEAN NULL, -- THREE-STATE: NULL is a value, never default it to FALSE
is_catch_all BOOLEAN NOT NULL DEFAULT FALSE,
is_role_email BOOLEAN NOT NULL DEFAULT FALSE,
is_free_email BOOLEAN NOT NULL DEFAULT FALSE,
validated_at TIMESTAMP NULL,
status VARCHAR(16) NULL, -- informational; not a deletion signal
created_at TIMESTAMP NULL,
updated_at TIMESTAMP NULL,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMP NULL,
last_seen_at TIMESTAMP NOT NULL
);
CREATE INDEX folovup_contacts_company_idx ON folovup_contacts (company_id);
CREATE INDEX folovup_contacts_email_idx ON folovup_contacts (email);
CREATE TABLE folovup_sync_state (
resource VARCHAR(16) PRIMARY KEY, -- 'companies' | 'contacts'
updated_since VARCHAR(32) NULL, -- last COMPLETED pass's pass_started_at
pass_started_at VARCHAR(32) NULL, -- server_time read before the current pass
cursor TEXT NULL, -- NULL between passes
last_sweep_at TIMESTAMP NULL
);
Upsert rules: on every received row set every column from the payload (including company_id, which may have changed), set last_seen_at = now(), and set is_deleted from the payload. Never derive is_valid from verification_status yourself and never fill a NULL with FALSE.
Mirror checklist
- Key on the ULIDs (
Company.id,Contact.id); storefolovup_company_id/folovup_contact_idalongside. - Two independent streams: companies via
GET /companies, contacts viaGET /contacts. Do not sync contacts throughinclude=contacts. - Every incremental request carries
include_deleted=1;is_deleted === true→ deleted;deleted_atis never consulted. -
updated_since=server_timeread fromGET /mebefore the pass started (minus a small overlap); advanced only afternext_cursorcame backnull; sent as UTC (...Zor+00:00). - Cursor persisted after every page; on 422
invalid_cursorthe pass restarts from page 1 with the sameupdated_since. - Idempotent upserts — the same row can arrive several times.
-
verification.is_validstored as a nullable boolean;nullis never turned intofalse;verified_onlyis never used for syncing. -
verification.statushandles all eight values (nullplus the seven literals). - Contact
company_idoverwritten on every upsert (contacts can move between companies); no FK constraint that would reject an unknown or tombstoned parent. - Free-text fields (
established_date,founded_year,number_of_employees,annual_revenue,business_hours) stored as text. -
addresses,key_personnel,important_links,social_media_linksparsed defensively (object vs array vs string). -
discovery_idtreated as advisory; refreshed by the sweep. - Reconciliation sweep scheduled (daily or after the
GET /mecount check fails); rows not seen in a full sweep are marked deleted. - Cross-flow consistency check run after the initial load.
- 429 handled by sleeping
Retry-Afterseconds and retrying the same request; unknown fields and unknown enum values ignored rather than rejected (see Versioning and changelog). - Tombstoned contacts are no longer used for outreach (see Data usage and compliance).
Discoveries
A discovery is a FolovUp market-research run. You submit one natural-language query that says what you sell and whom you want to find (for example Almanya pazarında dağıtım trafosu üreten fabrikalar arıyoruz; biz trafo sacı ve nüve tedarikçisiyiz). FolovUp interprets the query with AI analysis, derives search terms, runs web searches, fetches candidate pages (including directory, association and chamber member lists), analyses each candidate company's own public website, keeps only the companies that fit the target (sector, buyer role, geography) and saves each one as a company together with the contacts found on its site. Companies already present in the account are not created again; restarting the same query continues past them.
Everything after the HTTP response happens in the background. POST /discoveries answers 202 at once; the run then progresses for minutes to hours. You observe it by polling GET /discoveries/{id} (see Polling a discovery) and, optionally, by receiving a webhook delivery when it reaches a terminal state. You read the companies it produced through GET /companies?discovery_id= or ?include=companies (see Reading a discovery's companies).
Ownership and scopes:
- Every discovery belongs to the account that owns the API key, and its credits are charged to that account's balance (
GET /me→account.credits). GET /discoveriesandGET /discoveries/{id}require scopediscovery:read.POST /discoveriesandPOST /discoveries/{id}/cancelrequirediscovery:write. Write is a separate scope because triggering spends credits, and keys are not issued with it unless you ask; request it explicitly (Getting access).- The list contains every discovery of the account, including runs started from the FolovUp web app or with another key of the same account.
- The query is free text in natural language. All live examples are Turkish; write it as you would brief a colleague: what you sell, who should buy it, which countries.
Discovery modes
mode |
What it does | target_company_count |
How it ends |
|---|---|---|---|
single (default) |
One search round. Every candidate found is analysed at once. | Accepted by validation but silently ignored: the run is stored with target_company_count: null and every response shows null. |
Ends when every candidate has been processed, as completed with stop_reason: null. |
nonstop |
Iterative. After the first round the run waits for the current batch to drain, derives fresh search terms, searches again, and repeats in rounds until a stop condition is met. | Honoured, 1–500. Candidates are released in batches sized by the remaining target, and the run stops once the target is reached. Slight overshoot is possible because candidates already being analysed are allowed to finish. | stop_reason says why: target_reached, dry, ai_limit, credits, max_rounds or ai_provider_unavailable. |
A nonstop run without target_company_count never reaches target_reached: it releases everything it finds at once and runs until it goes dry (6 consecutive rounds without a new company), exhausts its analysis budget, hits the credit floor, or reaches 50 rounds. Send a target unless you deliberately want an open-ended run.
Each discovery has a finite analysis budget. For nonstop it scales with the target; for single it is a fixed allowance. When a nonstop run uses it up the run ends as completed with stop_reason: ai_limit; it does not fail. A single run that uses it up still ends as completed with stop_reason: null — the remaining candidates are simply skipped, so a single run with stop_reason: null is not proof that every candidate was analysed. The budget figures are internal and are not returned by the API.
Credits charged by a discovery
Credits are taken from the balance of the account that owns the key. Read the balance from GET /me (account.credits).
| When | Amount | Detail |
|---|---|---|
POST /discoveries, at request time |
0 (pre-check only) | If account.credits is below 30 the request is refused with 402 insufficient_credits and nothing is created. An account with no active subscription has a balance of 0 and always receives 402. A 202 means the run was queued, not that anything has been charged. |
| Start fee, inside the background run | 30 | Charged after the query has been interpreted and search terms have been produced, before the first web search. If the query cannot be interpreted (query_parse_failed, keyword_gen_failed, round0_infra_error) the 30 credits are never taken — with one exception: when the start step crashes after search terms were produced, the run also ends as keyword_gen_failed (error_message: "Keşif başlatılamadı. Lütfen tekrar deneyin.") and the fee may already have been taken; check GET /me → account.credits if the exact charge matters. If the balance dropped below 30 between the 202 and this point, the run ends as status: failed, stop_reason: credits, error_message: "Yetersiz kredi. Lütfen kredi yükleyip tekrar deneyin." — handle this outcome as well as the 402. |
| Per company created | 2 | Charged each time a company that fits the target is saved. Candidates that are rejected, skipped or recognised as duplicates cost nothing; directory and list pages cost nothing by themselves. |
| Credits run out during the run | — | The balance is checked before each candidate is analysed: below 2, the run ends as status: completed, stop_reason: credits. In nonstop mode no new round is started when the balance is below 10. Companies found so far are kept and never deleted. If the 2-credit charge for a company fails in a race, the company is still kept. |
POST /discoveries/{id}/cancel |
0 refunded | Cancelling refunds nothing; the start fee and every per-company charge already taken stay charged. |
Expected cost of one run: 30 + 2 × counts.companies_created.
Discovery lifecycle
status starts at processing and moves to exactly one terminal value; a terminal value never changes afterwards.
status |
is_terminal |
Meaning |
|---|---|---|
processing |
false |
Queued or running. Counters change while you poll. |
completed |
true |
The run ended by itself. This does not mean success: credits, ai_limit, dry, max_rounds, errors and ai_provider_unavailable all end as completed. Read stop_reason and counts.companies_linked. A single run that finished normally has stop_reason: null. |
failed |
true |
Nothing usable was produced. Either the run could not start (query_parse_failed, keyword_gen_failed, round0_infra_error, or credits when the start fee could not be taken), or repeated analysis errors stopped it before any company was created (stop_reason: null, error_message: "Çok fazla ardışık hata oluştu. İşlem durduruldu."). |
cancelled |
true |
Stopped by POST /discoveries/{id}/cancel or from the FolovUp web app. stop_reason: cancelled. Companies found before the cancel are kept. |
is_terminal is derived from status alone (completed, failed or cancelled). It is the one field to use for "stop polling".
progress is advisory. While the run is processing it approximates processed candidates ÷ candidates found × 100 as an integer. It is not clamped and is not normalised on every terminal transition: one completed single run shows 101 (254 processed rows over 251 candidates), another was left at 94, and failed/cancelled runs keep whatever value they had. Show it to humans; never use progress == 100 as a completion test.
Discovery object fields
Returned by every discovery endpoint: list items, GET /discoveries/{id}, the data of both POST responses, and (a subset) inside webhook deliveries. Keys are always present, in this order, unless noted.
| Name | Type | Meaning | Notes |
|---|---|---|---|
id |
integer | Discovery identifier. | Use it in GET /discoveries/{id}, in cancel, and as discovery_id on GET /companies. Companies carry it as discovery_id. Numeric, not a ULID. |
query |
string | The query exactly as submitted. | |
mode |
string | single or nonstop. |
|
status |
string | processing, completed, failed or cancelled. |
See Discovery lifecycle. |
is_terminal |
boolean | true when status is completed, failed or cancelled. |
Stop polling when true. |
progress |
integer | Advisory progress. | May exceed 100; may be below 100 on a terminal run. |
target_company_count |
integer|null | Target for nonstop. |
Always null for single, even if you sent one. |
counts |
object | Counters; see Discovery counts. | |
stop_reason |
string|null | Why the run stopped. | See Stop reasons. null while processing, on a normally completed single run, and on the zero-company failed path. |
stop_explanation |
string|null | Human-readable Turkish explanation of stop_reason. |
null whenever stop_reason is null. Computed at read time from the historical counts.companies_created, so on an old run with companies_linked: 0 the text could in principle quote a company count; trust companies_linked, not the text. |
error_message |
string|null | Human-readable Turkish failure text. | Set on failed runs; see Failure messages. |
companies |
array | Embedded Company objects (without their contacts key). |
Key present only with ?include=companies on GET /discoveries/{id}; absent otherwise. Absent is not the same as an empty array. |
started_at |
string|null | ISO-8601 with +00:00. |
Set when the run is created. |
completed_at |
string|null | ISO-8601. | Set when the run reaches a terminal state, including cancel. null while processing. |
last_activity_at |
string|null | ISO-8601. | Coordinator heartbeat. null on runs created before June 2026. Not a progress indicator; do not use it to detect stalls. |
created_at |
string|null | ISO-8601. | |
updated_at |
string|null | ISO-8601. | Moves while the run is processing (counter updates), on every terminal transition and on cancel. Drives updated_since and list ordering. |
Not returned on purpose: internal budget counters, the parsed interpretation of your query, and the search terms used.
Discovery counts
| Name | Type | Meaning | Notes |
|---|---|---|---|
counts.links_found |
integer | Candidate rows the run considered: unique search-result URLs, plus member entries extracted from directory pages, plus follow-up directory pages. | Not "web pages fetched" and not "companies". |
counts.analyzed |
integer | Candidate rows that reached a final state (analysed, rejected, skipped, failed, or processed as a directory). | Can exceed links_found by a few units: analyzed is an incrementing counter and work that finishes after the run's final recount still adds to it, while links_found is the exact number of candidate rows. Do not assume analyzed / links_found <= 1. |
counts.relevant |
integer | Candidates judged to fit the target. | For runs created after 2 June 2026 always equal to companies_created. Older runs show relevant < companies_created. |
counts.companies_created |
integer | Historical counter of companies created by this run, as reported at the time. | Kept for continuity. Runs created before 2 June 2026 have the counter but no link between run and companies, and a later run can also drift if its companies were removed, so companies_created: 53 next to companies_linked: 0 is possible. Never size a fetch by this number. |
counts.companies_linked |
integer|null | Live number of companies that carry this run's id and are not deleted. |
Exactly the number of rows GET /companies?discovery_id={id} returns by default (without include_deleted). Use this for mirrors. On GET /discoveries, GET /discoveries/{id}, cancel and the 200 idempotent replay it is always an integer; in the 202 body of POST /discoveries it is null (the run has no companies yet) — treat null as 0 there. |
Stop reasons
stop_reason is the stable contract. stop_explanation is display text for humans (Turkish) and may be reworded. In the texts below <N> is counts.companies_created, <T> is target_company_count, <P> is the percentage of the run's analysis budget that was used (rounded) and <U> is 100 − <P>.
stop_reason |
Resulting status |
When it is set | stop_explanation (verbatim) |
|---|---|---|---|
target_reached |
completed |
A nonstop run reached target_company_count. It is finalised only after in-flight work drains plus a 90-second quiet period, so <N> may exceed <T>. |
Hedeflenen firma sayısına ulaşıldı (<N>/<T>). — without a target the suffix is (<N>). |
dry |
completed |
nonstop: 6 consecutive rounds produced no new company. Temporary AI-service errors do not count as empty rounds. |
Üst üste yeni firma üretmeyen turlar sonrası durduruldu. followed by, when <P> < 60: Yapay zeka bütçesinin %<U>'i kullanılmadı; sınırlayıcı bütçe değil, kaynak çeşitliliğiydi. otherwise: Yapay zeka bütçesinin büyük kısmı (%<P>) kullanıldı. |
ai_limit |
completed |
The run's analysis budget is exhausted (nonstop only; a single run that exhausts its budget ends with stop_reason: null). |
With a target: Bu sektörde firma başına tarama maliyeti yüksek olduğu için işlem bütçesi doldu (<N>/<T> firma bulundu). Aynı sorguyu yeniden başlatırsanız bulunanlar atlanır ve keşif kaldığı yerden derinleşir. Without a target: Yapay zeka işlem bütçesi doldu (<N> firma bulundu). |
credits |
completed or failed |
completed: the balance fell below 2 before a candidate, or below 10 before a new nonstop round. failed: the 30-credit start fee could not be taken because the balance dropped after the 202. |
Kredi alt sınırına ulaşıldığı için durduruldu. |
max_rounds |
completed |
A nonstop run reached 50 rounds. |
Maksimum tur sayısına ulaşıldı. |
cancelled |
cancelled |
Cancelled through the API or the web app. | Kullanıcı tarafından durduruldu. |
query_parse_failed |
failed |
The query could not be interpreted. No credits charged. | Arama isteği yorumlanamadı. |
keyword_gen_failed |
failed |
Search terms could not be produced (no credits charged), or the start step crashed (error_message: Keşif başlatılamadı. Lütfen tekrar deneyin.). In the crash case the 30-credit start fee may already have been taken if the crash happened after search terms were produced. |
Arama kelimeleri üretilemedi. |
round0_infra_error |
failed |
The AI service did not answer during the start step after 3 attempts. No credits charged. | Yapay zeka servisi geçici olarak yanıt vermedi; krediniz düşülmedi. Lütfen birkaç dakika sonra tekrar deneyin. |
ai_provider_unavailable |
completed |
nonstop: the AI service failed to answer at 15 consecutive round starts (about 30 minutes). Not a problem with your query. |
<N> > 0: Yapay zeka servisi uzun süre yanıt vermediği için duraklatıldı (<N> firma bulundu). Sorgunuzda bir sorun yok; aynı sorguyla yeniden başlatırsanız bulunanlar atlanır ve keşif kaldığı yerden devam eder. <N> = 0: Yapay zeka servisi uzun süre yanıt vermediği için duraklatıldı. Sorgunuzda bir sorun yok; lütfen birkaç dakika sonra tekrar deneyin. |
errors |
completed |
Repeated analysis errors stopped the run after at least one company had been created. | Bazı sonuçlar teknik hata verdiği için durduruldu — <N> firma bulundu. |
wedged |
— | Reserved. Listed in the OpenAPI enum; no run emits it today. | Keşif teknik bir nedenle ilerleyemediği için durduruldu (<N> firma bulundu). Aynı sorguyla yeniden başlatırsanız bulunanlar atlanır ve keşif kaldığı yerden devam eder. |
| any other non-null value | — | New values may appear (see Versioning and changelog). | Durum: <value>. |
null |
processing, completed or failed |
While running; a single run that finished normally; the zero-company failed path. |
stop_explanation is null. |
Treat an unknown stop_reason as "terminal; inspect status and counts".
Failure messages
error_message is Turkish display text and may be reworded; match on status and stop_reason, never on this text. Current values:
status |
stop_reason |
error_message |
|---|---|---|
failed |
query_parse_failed |
Arama isteği yorumlanamadı. Lütfen tekrar deneyin. |
failed |
keyword_gen_failed |
Arama kelimeleri üretilemedi. Lütfen tekrar deneyin. or Keşif başlatılamadı. Lütfen tekrar deneyin. |
failed |
round0_infra_error |
Yapay zeka servisi şu anda yoğun; birkaç deneme sonrasında yanıt alınamadı. Krediniz düşülmedi, lütfen birkaç dakika sonra tekrar deneyin. |
failed |
credits |
Yetersiz kredi. Lütfen kredi yükleyip tekrar deneyin. |
failed |
null |
Çok fazla ardışık hata oluştu. İşlem durduruldu. |
processing, completed, cancelled |
any | null |
For query_parse_failed and keyword_gen_failed, rephrase the query (what you sell, who should buy it, which countries) and start a new run. For round0_infra_error and ai_provider_unavailable, retry the same query a few minutes later.
How long a discovery runs
There is no guaranteed duration. What the mechanics and the live data (read 2026-09-08) support:
single: no rounds. A candidate that stays unprocessed is swept after 20 minutes (if analysis had started) or 90 minutes (if it was still queued). Completion of an unattended run is checked every 5 minutes, and everyGET /discoveries/{id}you make checks it immediately. Observed over 14 completed runs: average 44 minutes, minimum 6, maximum 398; at most 251 candidates and 56 companies.nonstop: one coordinator pass per minute; at least 90 seconds of quiet between rounds; at most 50 rounds; at most 6 consecutive empty rounds; finite analysis budget. Observed over 22 completed runs: average about 5 hours, minimum about 15 minutes, maximum about 45 hours (one figure attributed to a stall that has since been fixed); up to 18 rounds, 5189 candidates and 500 companies. Stop reasons among those 22:target_reached13,ai_limit6,dry2,ai_provider_unavailable1.
Plan for "minutes to hours". Do not apply a stall timeout of your own: FolovUp has stall detection, and a run that is processing with unchanged counters for a while is not necessarily stuck. If you cancel on a timer you lose the rest of the run; the companies found so far are kept either way.
Polling a discovery
- Wait
meta.poll_afterseconds (currently always10) after the202. GET /discoveries/{id}. Ifdata.is_terminalistrue, stop. Otherwise wait and repeat.- Back off: double the interval after each non-terminal answer, capped at 60 seconds for
singleand 300 seconds fornonstop(a recommendation, not a server rule). On429, sleep for theRetry-Aftervalue and continue. - When a webhook delivery arrives (
discovery.completed,discovery.failedordiscovery.cancelled), make one finalGET /discoveries/{id}and stop polling. Delivery is not guaranteed; polling stays the source of truth.
Two facts about the show endpoint matter here:
GET /discoveries/{id}is not a pure read. On anyprocessingrun it recomputescounts.analyzed/relevant/companies_createdandprogress(movingupdated_atwhen they change). On aprocessingsinglerun it additionally sweeps stale candidates and, if every candidate is done, flips the run tocompleted(queuing thediscovery.completedwebhook). Your poll can be what finalises asinglerun.GET /discoveries(the list) and the200idempotent replay ofPOST /discoveriesdo not do this, so poll with the show endpoint.- Use
is_terminal, notprogress, and takecounts.companies_linkedfrom the terminal response to know how many companies to fetch.
Python:
import time
import requests
BASE = "https://folovup.com/api/partner/v1"
HEADERS = {
"Authorization": "Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"Accept": "application/json",
}
def wait_for_discovery(discovery_id: int, first_delay: int = 10, cap: int = 60) -> dict:
delay = first_delay
while True:
time.sleep(delay)
r = requests.get(f"{BASE}/discoveries/{discovery_id}", headers=HEADERS, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "60")))
continue
r.raise_for_status()
d = r.json()["data"]
if d["is_terminal"]:
return d
delay = min(delay * 2, cap)
result = wait_for_discovery(733, cap=300) # nonstop run: cap at 300 s
print(result["status"], result["stop_reason"], result["counts"]["companies_linked"])
Node.js:
const BASE = "https://folovup.com/api/partner/v1";
const HEADERS = {
Authorization: "Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
Accept: "application/json",
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForDiscovery(id, firstDelay = 10, cap = 60) {
let delay = firstDelay;
for (;;) {
await sleep(delay * 1000);
const res = await fetch(`${BASE}/discoveries/${id}`, { headers: HEADERS });
if (res.status === 429) {
await sleep(Number(res.headers.get("retry-after") ?? 60) * 1000);
continue;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data } = await res.json();
if (data.is_terminal) return data;
delay = Math.min(delay * 2, cap);
}
}
const result = await waitForDiscovery(733, 10, 300);
console.log(result.status, result.stop_reason, result.counts.companies_linked);
Reading a discovery's companies
Two ways. Both exclude deleted companies unless stated.
GET /discoveries/{id}?include=companies |
GET /companies?discovery_id={id} |
|
|---|---|---|
| Result | Every linked company embedded in data.companies in one response. |
Cursor-paginated Company list. |
| Ordering | None guaranteed. | updated_at, then id, ascending. |
| Size limit | None (a run can link 500 companies). | per_page default 100, max 200. |
| Contacts | Never embedded; the contacts key is absent on embedded companies. include=contacts is ignored here. |
include=contacts embeds them. |
| Tombstones | Never; there is no include_deleted on this endpoint. |
include_deleted=1 adds deleted companies with is_deleted: true; the row count can then exceed companies_linked. |
| Incremental | No. | updated_since + cursor, as in Pagination and incremental sync. |
| Use for | Small runs, one-shot reads. | Mirrors, and anything that needs contacts or deletions. |
Without include_deleted, expect exactly counts.companies_linked rows from /companies?discovery_id=. If the run is later deleted in the FolovUp web app, GET /discoveries/{id} returns 404 but its companies survive with discovery_id: null and stay reachable through GET /companies. The Partner API has no delete endpoint.
GET /discoveries
Lists the account's discoveries, oldest-updated first, with cursor pagination and optional incremental sync.
Scope: discovery:read
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
status |
query | string | no | — | Exact match; one of processing, completed, failed, cancelled. Any other value → 422 validation_failed. |
updated_since |
query | string | no | — | Returns runs with updated_at >= this instant (inclusive, 1-second resolution, compared in UTC). Accepted: ISO-8601 datetime (2026-09-08T09:00:00Z, 2026-09-08T09:00:00+00:00, 2026-09-08T12:00:00+03:00, naive 2026-09-08T09:00:00 = UTC), 2026-09-08 09:00:00 (UTC), 2026-09-08 (midnight UTC). URL-encode + as %2B. Unparseable → 422 validation_failed. Because updated_at moves while a run is processing, a running discovery reappears in every incremental pass until it is terminal, and a cancel makes an old run reappear. |
cursor |
query | string | no | — | Opaque; copy it from meta.next_cursor (or follow links.next). Undecodable → 422 invalid_cursor. |
per_page |
query | integer | no | 50 |
1–100. Above 100 → 422 validation_failed. |
There is no filter by mode and no sort option. Rows are ordered by (updated_at, id) ascending; to see the most recently changed runs use updated_since. GET /discoveries?status=processing is the way to find runs to resume polling after a restart of your own system (the list does not run the completion check; the show endpoint does).
Request:
curl -sS "https://folovup.com/api/partner/v1/discoveries?per_page=2" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Incremental pass (runs changed since an instant, then follow the cursor):
curl -sS "https://folovup.com/api/partner/v1/discoveries?updated_since=2026-09-08T00%3A00%3A00%2B00%3A00&per_page=100" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
curl -sS "https://folovup.com/api/partner/v1/discoveries?updated_since=2026-09-08T00%3A00%3A00%2B00%3A00&per_page=100&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wNyAwOTo0MTo1MCIsImlkIjo3MzIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Response 200 (the first item is a run from before June 2026, hence companies_created: 53 next to companies_linked: 0; the second is a current nonstop run):
{
"data": [
{
"id": 731,
"query": "Kablo makarası üretiyoruz; Avrupa pazarında bu makaraları satın alabilecek kablo üreticilerini arıyoruz",
"mode": "single",
"status": "completed",
"is_terminal": true,
"progress": 100,
"target_company_count": null,
"counts": {
"links_found": 50,
"analyzed": 50,
"relevant": 20,
"companies_created": 53,
"companies_linked": 0
},
"stop_reason": null,
"stop_explanation": null,
"error_message": null,
"started_at": "2025-10-15T14:10:32+00:00",
"completed_at": "2025-10-15T14:39:38+00:00",
"last_activity_at": null,
"created_at": "2025-10-15T14:10:32+00:00",
"updated_at": "2025-10-15T14:39:38+00:00"
},
{
"id": 732,
"query": "Türkiye pazarında dağıtım trafosu üreten fabrikalar arıyoruz; biz trafo sacı ve nüve tedarikçisiyiz",
"mode": "nonstop",
"status": "completed",
"is_terminal": true,
"progress": 100,
"target_company_count": 25,
"counts": {
"links_found": 412,
"analyzed": 419,
"relevant": 27,
"companies_created": 27,
"companies_linked": 27
},
"stop_reason": "target_reached",
"stop_explanation": "Hedeflenen firma sayısına ulaşıldı (27/25).",
"error_message": null,
"started_at": "2026-09-07T06:12:04+00:00",
"completed_at": "2026-09-07T09:41:50+00:00",
"last_activity_at": "2026-09-07T09:40:19+00:00",
"created_at": "2026-09-07T06:12:04+00:00",
"updated_at": "2026-09-07T09:41:50+00:00"
}
],
"links": {
"first": null,
"last": null,
"prev": null,
"next": "https://folovup.com/api/partner/v1/discoveries?per_page=2&cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wNyAwOTo0MTo1MCIsImlkIjo3MzIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0"
},
"meta": {
"path": "https://folovup.com/api/partner/v1/discoveries",
"per_page": 2,
"next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0wOS0wNyAwOTo0MTo1MCIsImlkIjo3MzIsIl9wb2ludHNUb05leHRJdGVtcyI6dHJ1ZX0",
"prev_cursor": null
}
}
links.first and links.last are always null. links.next and meta.next_cursor are null on the last page. links.next preserves your filters (status, updated_since, per_page).
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired key. |
| 403 | insufficient_scope |
Key lacks discovery:read; the body's required names the scope. |
| 403 | account_suspended |
The account that owns the key is suspended. |
| 422 | validation_failed |
Bad status, per_page outside 1–100, or unparseable updated_since; errors.<field> lists the reason. |
| 422 | invalid_cursor |
cursor present but undecodable. |
| 429 | rate_limited |
Per-key limit exceeded; wait Retry-After seconds. |
GET /discoveries/{id}
Returns the current state of one discovery and, on a processing single run, performs its completion check (see Polling a discovery).
Scope: discovery:read
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
id |
path | integer | yes | — | The discovery's numeric id. A non-numeric value (for example a company ULID) is not a valid route and currently returns 500 server_error; always pass the integer from data.id. |
include |
query | string | no | — | Comma-separated list. Only companies is recognised; every other value (including contacts) is ignored. |
Request:
curl -sS "https://folovup.com/api/partner/v1/discoveries/733" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Response 200 for a run in progress:
{
"data": {
"id": 733,
"query": "Almanya pazarında dağıtım trafosu üreten fabrikalar arıyoruz; biz trafo sacı ve nüve tedarikçisiyiz",
"mode": "nonstop",
"status": "processing",
"is_terminal": false,
"progress": 38,
"target_company_count": 25,
"counts": {
"links_found": 164,
"analyzed": 62,
"relevant": 9,
"companies_created": 9,
"companies_linked": 9
},
"stop_reason": null,
"stop_explanation": null,
"error_message": null,
"started_at": "2026-09-08T10:04:11+00:00",
"completed_at": null,
"last_activity_at": "2026-09-08T10:31:47+00:00",
"created_at": "2026-09-08T10:04:11+00:00",
"updated_at": "2026-09-08T10:31:52+00:00"
}
}
With ?include=companies on a terminal run, the companies key appears after error_message and holds every non-deleted linked company in no guaranteed order. Each element is a Company object as described in Companies, without a contacts key (the array is trimmed with … here):
curl -sS "https://folovup.com/api/partner/v1/discoveries/732?include=companies" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
{
"data": {
"id": 732,
"query": "Türkiye pazarında dağıtım trafosu üreten fabrikalar arıyoruz; biz trafo sacı ve nüve tedarikçisiyiz",
"mode": "nonstop",
"status": "completed",
"is_terminal": true,
"progress": 100,
"target_company_count": 25,
"counts": {
"links_found": 412,
"analyzed": 419,
"relevant": 27,
"companies_created": 27,
"companies_linked": 27
},
"stop_reason": "target_reached",
"stop_explanation": "Hedeflenen firma sayısına ulaşıldı (27/25).",
"error_message": null,
"companies": [
{
"id": "01J9Z0000000000000000000A1",
"folovup_company_id": 4821,
"discovery_id": 732,
"name": "Örnek Trafo Sanayi A.Ş.",
"legal_name": "Örnek Trafo Sanayi A.Ş.",
"short_name": "Örnek Trafo",
"searched_name": "örnek trafo",
"description": "Örnek Trafo designs and manufactures oil-immersed and dry-type distribution transformers up to 36 kV for utilities, industrial plants and renewable-energy projects. The company operates a single factory in Gebze and exports to Europe and the Middle East.",
"sector": "Electrical Equipment Manufacturing",
"entity_type": "company-organization",
"naics_code": "335311",
"company_code": "ORNK",
"founded_year": "1998",
"established_date": "1998-03-01",
"registration_number": "123456",
"number_of_employees": "50-100",
"annual_revenue": null,
"business_hours": null,
"is_exporter_or_importer": true,
"headquarter_country_iso": "TR",
"website": "https://www.example-trafo.com.tr",
"website_url": "https://example-trafo.com.tr",
"products": ["Oil-immersed distribution transformers", "Dry-type cast-resin transformers", "Special transformers for solar plants", "…"],
"services": ["Transformer maintenance", "On-site commissioning"],
"products_services": null,
"addresses": [
{
"country": "Turkey",
"street_and_city_and_state": "Örnek OSB 3. Cadde No: 12, 41400 Gebze / Kocaeli",
"two_digit_iso_country_code": "TR"
}
],
"phone_numbers": ["+90 262 000 00 00"],
"certifications": ["ISO 9001", "ISO 14001"],
"awards": [],
"key_personnel": [
{ "name": "Ayşe Yılmaz", "title": "Export Manager" }
],
"social_media_links": { "linkedin": "https://www.linkedin.com/company/example-trafo" },
"languages_supported": ["Turkish", "English"],
"payment_methods": [],
"shipping_countries": ["Germany", "Netherlands"],
"important_links": [
{
"url": "https://www.example-trafo.com.tr/contact",
"title": "Contact",
"importance_reason": "Contact details and factory address"
}
],
"contact_count": 2,
"is_catch_all_domain": false,
"is_manually_created": false,
"created_at": "2026-09-07T07:02:39+00:00",
"updated_at": "2026-09-08T08:23:39+00:00",
"is_deleted": false,
"deleted_at": null
},
…
],
"started_at": "2026-09-07T06:12:04+00:00",
"completed_at": "2026-09-07T09:41:50+00:00",
"last_activity_at": "2026-09-07T09:40:19+00:00",
"created_at": "2026-09-07T06:12:04+00:00",
"updated_at": "2026-09-07T09:41:50+00:00"
}
}
A run with companies_linked: 0 returns "companies": [] (key present, empty array).
Response 404 (unknown id, or a discovery owned by another account, or a run deleted in the web app):
{"error":"not_found","message":"Keşif bulunamadı."}
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired key. |
| 403 | insufficient_scope |
Key lacks discovery:read. |
| 403 | account_suspended |
The account that owns the key is suspended. |
| 404 | not_found |
No discovery with this id in the key's account. Ids of other accounts also return 404, never 403. |
| 500 | server_error |
Non-numeric id in the path, or an unexpected failure. |
| 429 | rate_limited |
Per-key limit exceeded; wait Retry-After seconds. |
POST /discoveries
Starts a discovery for the key's account and returns 202 immediately; the run continues in the background. This endpoint spends credits (see Credits charged by a discovery). Always send an Idempotency-Key.
Scope: discovery:write
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
Idempotency-Key |
header | string | no, strongly recommended | — | At most 128 bytes (measured as bytes, so a 128-character key containing multibyte characters is rejected). Longer → 422 invalid_idempotency_key. Use ASCII; a 64-character hex SHA-256 fits. Semantics below. |
query |
body | string | yes | — | 10–500 characters of natural language. |
mode |
body | string | no | single |
single or nonstop. |
target_company_count |
body | integer|null | no | null |
1–500. Send a JSON integer; numeric strings such as "25" and integral floats such as 25.0 are accepted and coerced, non-integral values (25.5, "abc") → 422. Stored only when mode is nonstop; for single it is accepted and discarded (the response shows null). |
The body is JSON (Content-Type: application/json).
Validation rules:
| Field | Rule | errors.<field> message when violated (English; illustrative, may change) |
|---|---|---|
query |
required, string, 10–500 characters | The query field is required. / The query field must be at least 10 characters. / The query field must not be greater than 500 characters. |
mode |
optional, single or nonstop |
The selected mode is invalid. |
target_company_count |
optional, null or integer 1–500 | The target company count field must be an integer. / The target company count field must not be greater than 500. / The target company count field must be at least 1. |
Order in which the server decides, and what each outcome does to your Idempotency-Key:
- Body validation. Failure →
422 validation_failed. The key is not claimed. - Key length check. Longer than 128 bytes →
422 invalid_idempotency_key. Not claimed. - Key lookup (only if a key was sent): first use claims it; a request still in flight under the same key →
409 idempotency_in_progress; a finished use →200replay (see below). - Credit pre-check.
account.credits < 30→402 insufficient_credits. The key is released, so a retry with the same key after topping up works. - Run creation. Success →
202, and the key is bound to the new discovery for 24 hours. Failure to create →422 discovery_failed, key released. Unexpected server error →500 server_error, key released (no stuck409for 24 hours).
Idempotency-Key semantics:
- Purpose: a retry after a timeout or a lost response must not start a second run and charge a second time. Without the header every call starts a new run.
- Scope: per API key, not per account. The same string sent with a different key of the same account starts a separate discovery.
- Retention: 24 hours from the
202. Within that window, a repeat with the same key returns200with the current state of the original run andmeta.idempotent_replay: true; the run is not touched (no completion check, nopoll_after). After 24 hours the same key starts a new run. - The request body is not compared. A repeat with the same key but a different
query,modeortarget_company_countstill returns the first run. Derive the key from a stable hash of your intent, for examplesha256("<your tenant id>|<your job id>|<normalised query>|<mode>|<target>"), and keep it identical across retries of that one job. Never reuse a key for a new intent. 409: the first request is still being processed. Wait a few seconds and repeat with the same key; you will then receive200(replay) or a new outcome. Do not switch to a new key on409— that would start a second run.- Replay is limited to the key's own account. If the original run was deleted in the web app, the key is released and the repeat starts a new run (
202). - Every response of this endpoint except
401,403and429counts against the per-key rate limit, including402,409and422.
Request:
curl -sS -X POST "https://folovup.com/api/partner/v1/discoveries" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f9c2a7e1b4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f" \
-d '{"query":"Almanya pazarında dağıtım trafosu üreten fabrikalar arıyoruz; biz trafo sacı ve nüve tedarikçisiyiz","mode":"nonstop","target_company_count":25}'
Response 202 (accepted; nothing charged yet; poll after meta.poll_after seconds):
{
"data": {
"id": 733,
"query": "Almanya pazarında dağıtım trafosu üreten fabrikalar arıyoruz; biz trafo sacı ve nüve tedarikçisiyiz",
"mode": "nonstop",
"status": "processing",
"is_terminal": false,
"progress": 0,
"target_company_count": 25,
"counts": {
"links_found": 0,
"analyzed": 0,
"relevant": 0,
"companies_created": 0,
"companies_linked": null
},
"stop_reason": null,
"stop_explanation": null,
"error_message": null,
"started_at": "2026-09-08T10:04:11+00:00",
"completed_at": null,
"last_activity_at": "2026-09-08T10:04:11+00:00",
"created_at": "2026-09-08T10:04:11+00:00",
"updated_at": "2026-09-08T10:04:11+00:00"
},
"meta": {
"accepted": true,
"poll_after": 10
}
}
Response 200 — the same request repeated with the same Idempotency-Key within 24 hours (note the current state, and meta.idempotent_replay instead of accepted/poll_after):
{
"data": {
"id": 733,
"query": "Almanya pazarında dağıtım trafosu üreten fabrikalar arıyoruz; biz trafo sacı ve nüve tedarikçisiyiz",
"mode": "nonstop",
"status": "processing",
"is_terminal": false,
"progress": 12,
"target_company_count": 25,
"counts": {
"links_found": 96,
"analyzed": 12,
"relevant": 2,
"companies_created": 2,
"companies_linked": 2
},
"stop_reason": null,
"stop_explanation": null,
"error_message": null,
"started_at": "2026-09-08T10:04:11+00:00",
"completed_at": null,
"last_activity_at": "2026-09-08T10:09:33+00:00",
"created_at": "2026-09-08T10:04:11+00:00",
"updated_at": "2026-09-08T10:09:41+00:00"
},
"meta": {
"idempotent_replay": true
}
}
Response 402 (balance below 30 at request time; nothing created; key released):
{"error":"insufficient_credits","message":"Yetersiz kredi. FolovUp AI kullanımı için 30 kredi gereklidir."}
Response 409 (a request with the same Idempotency-Key is still in flight; retry in a few seconds with the same key):
{"error":"idempotency_in_progress","message":"Aynı Idempotency-Key ile bir istek hâlâ işleniyor. Birkaç saniye sonra tekrar deneyin."}
Response 422 validation_failed (body {"query":"kısa","mode":"fast","target_company_count":900}; key not claimed):
{
"error": "validation_failed",
"message": "The query field must be at least 10 characters. (and 2 more errors)",
"errors": {
"query": [
"The query field must be at least 10 characters."
],
"mode": [
"The selected mode is invalid."
],
"target_company_count": [
"The target company count field must not be greater than 500."
]
}
}
Response 422 invalid_idempotency_key (header longer than 128 bytes; key not claimed):
{"error":"invalid_idempotency_key","message":"Idempotency-Key en fazla 128 karakter olabilir."}
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired key. |
| 402 | insufficient_credits |
account.credits below 30 at request time. Key released. Top up, then retry with the same key. |
| 403 | insufficient_scope |
Key lacks discovery:write; the body's required is discovery:write. |
| 403 | account_suspended |
The account that owns the key is suspended. |
| 409 | idempotency_in_progress |
Same Idempotency-Key, first request still in flight. Retry shortly with the same key. |
| 422 | validation_failed |
Body invalid; errors lists fields. Key not claimed. |
| 422 | invalid_idempotency_key |
Header longer than 128 bytes. Key not claimed. |
| 422 | discovery_failed |
The run could not be created for a reason other than credits (message explains). Key released. Retry later; if it persists, contact Support. |
| 429 | rate_limited |
Per-key limit exceeded; wait Retry-After seconds. |
| 500 | server_error |
Unexpected failure. Key released; safe to retry with the same key. |
POST /discoveries/{id}/cancel
Stops a processing discovery. Companies found so far are kept; nothing is refunded.
Scope: discovery:write
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
id |
path | integer | yes | — | The discovery's numeric id. |
No request body.
What a successful cancel does:
status→cancelled,stop_reason→cancelled,stop_explanation→Kullanıcı tarafından durduruldu.,completed_atandupdated_at→ now,is_terminal→true.progressandcounts.links_found/analyzed/relevant/companies_createdare left at their last values;progressis not forced to 100.counts.companies_linkedstays live.- Companies already created are kept. No credits are refunded: the 30-credit start fee and every per-company charge stay charged, and a candidate whose analysis was already underway can still be saved and charged 2 credits shortly after the
200. - Remaining candidates stop being analysed at their next checkpoint. A candidate whose analysis was already underway at the moment of the cancel can still be saved shortly afterwards; re-read
counts.companies_linkeda little later before you size a fetch. - A
discovery.cancelledwebhook delivery is queued. - Because
updated_atmoves, the run reappears inupdated_sincepasses.
A discovery that is already terminal (completed, failed or cancelled) is refused with 422 cancel_failed and nothing is modified.
Request:
curl -sS -X POST "https://folovup.com/api/partner/v1/discoveries/733/cancel" \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Response 200 (the run was processing; note progress stays at 41):
{
"data": {
"id": 733,
"query": "Almanya pazarında dağıtım trafosu üreten fabrikalar arıyoruz; biz trafo sacı ve nüve tedarikçisiyiz",
"mode": "nonstop",
"status": "cancelled",
"is_terminal": true,
"progress": 41,
"target_company_count": 25,
"counts": {
"links_found": 164,
"analyzed": 68,
"relevant": 10,
"companies_created": 10,
"companies_linked": 10
},
"stop_reason": "cancelled",
"stop_explanation": "Kullanıcı tarafından durduruldu.",
"error_message": null,
"started_at": "2026-09-08T10:04:11+00:00",
"completed_at": "2026-09-08T10:36:05+00:00",
"last_activity_at": "2026-09-08T10:35:12+00:00",
"created_at": "2026-09-08T10:04:11+00:00",
"updated_at": "2026-09-08T10:36:05+00:00"
}
}
Response 422 (the run was already terminal; nothing changed):
{"error":"cancel_failed","message":"Keşif zaten sonlanmış (status: completed)."}
Response 404:
{"error":"not_found","message":"Keşif bulunamadı."}
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired key. |
| 403 | insufficient_scope |
Key lacks discovery:write. |
| 403 | account_suspended |
The account that owns the key is suspended. |
| 404 | not_found |
No discovery with this id in the key's account. |
| 500 | server_error |
Non-numeric id in the path, or an unexpected failure. |
| 422 | cancel_failed |
The discovery is already completed, failed or cancelled (its message names the current status), or the cancel could not be applied. Nothing is modified; read GET /discoveries/{id} for the current state. |
| 429 | rate_limited |
Per-key limit exceeded; wait Retry-After seconds. |
Webhooks
A webhook is a signed HTTP POST that FolovUp sends to an HTTPS URL you own when a discovery of your account reaches a terminal state (completed, failed or cancelled). Use it to stop polling early and to start pulling the discovery's companies as soon as they exist.
A webhook delivery is an accelerator, not the source of truth. Delivery is not guaranteed: if your endpoint stays unreachable for longer than the retry window (about 22.5 minutes, see Webhook retry schedule) or a delivery is lost on our side, no notification arrives. Keep polling GET /discoveries/{id} until is_terminal is true (see Discoveries) and treat a delivery as "poll now".
What holds for every webhook:
- One webhook endpoint per API key.
PUT /webhookcreates it or replaces it; there is no list and no second endpoint on the same key. Use a second key if you need a second endpoint (for example staging and production). - No scope is needed. Any valid key manages its own endpoint whatever its scopes are; a key never sees another key's endpoint.
- Account-wide fan-out. The endpoint receives the terminal event of every discovery of the account that owns the key, including discoveries started with another key or by a person in the FolovUp web app. See Who receives webhook deliveries.
- Signed. Every delivery carries
X-Folovup-Signature, an HMAC-SHA256 over the raw request body with a per-endpoint secret that is shown exactly once. See Webhook signature. - At-least-once. The same delivery can arrive more than once; deduplicate on
X-Folovup-Delivery. See At-least-once delivery and deduplication. - Only a 2xx response counts as success. Anything else is retried up to 4 HTTP attempts in total, then the delivery is abandoned. Failures are visible in
GET /webhook; an endpoint is never disabled automatically.
Webhook events
Exactly one of the three events is sent per discovery: the three terminal states are mutually exclusive, and POST /discoveries/{id}/cancel refuses a discovery that is already terminal (see POST /discoveries/{id}/cancel), so a discovery can never produce two events. In every real delivery data.discovery.is_terminal is true.
event |
Fires when | data.discovery.status |
data.discovery.stop_reason |
|---|---|---|---|
discovery.completed |
The discovery reached completed. This includes every completed-with-a-stop-reason ending: the target was reached, the search ran dry, the analysis budget was exhausted, the maximum number of rounds was reached, the account ran out of credits during analysis, repeated technical errors stopped the run after at least one company had been created, or the AI service stayed unavailable for a long time. completed does not mean "target reached" — always read stop_reason. |
completed |
target_reached, dry, ai_limit, max_rounds, credits, errors, ai_provider_unavailable; null for a single discovery that processed all of its results |
discovery.failed |
The status became failed, on every failure path: the query could not be interpreted (query_parse_failed), search terms could not be generated (keyword_gen_failed), an infrastructure error occurred while starting (round0_infra_error), the start-up credit charge could not be taken (credits), or repeated analysis errors stopped the run before any company was created (stop_reason is null; read error_message). |
failed |
query_parse_failed, keyword_gen_failed, round0_infra_error, credits, or null |
discovery.cancelled |
The discovery was cancelled while it was still processing, either through POST /discoveries/{id}/cancel (any key of the account) or by the account owner in the FolovUp web app. Companies found so far are kept; credits are not refunded. |
cancelled |
cancelled |
The full meaning of each stop_reason value, the Turkish stop_explanation text that accompanies it and the error_message values are documented in Discoveries. New stop_reason values may be added over time; treat unknown values as "stopped, read stop_explanation".
When the delivery is queued relative to the terminal moment:
| Situation | Delivery is queued |
|---|---|
nonstop discovery stops for any reason other than target_reached |
Within about one minute of the stop condition (a monitor checks nonstop discoveries once a minute). |
nonstop discovery reaches its target |
After in-flight analysis has drained and the discovery has been quiet for 90 seconds, then within about one minute. |
single discovery processed all of its results |
When the status is next observed: by any status poll (including your own GET /discoveries/{id}) or by a background check that runs every 5 minutes. Your own poll can be what triggers the delivery. |
| Failure during start-up, credits exhausted during analysis, cancellation | Immediately. |
Queued deliveries are sent one at a time in order; a slow or failing endpoint of yours delays your later deliveries, never the discoveries themselves.
Who receives webhook deliveries
Fan-out is by account, not by key. When a discovery of an account reaches a terminal state, FolovUp looks up every webhook endpoint that belongs to any non-revoked key of that account, keeps the ones with is_active: true whose events list contains the event, and queues one delivery per endpoint, each with its own X-Folovup-Delivery id. It does not matter whether the discovery was started through POST /discoveries with this key, with another key of the same account, or by a person in the web app.
Consequences you must design for:
- You will receive events for discoveries you did not start and whose
data.discovery.idyou have never seen. Look them up withGET /discoveries/{id}(scopediscovery:read) and pull their companies like any other; or ignore ids you do not know, if your integration only cares about discoveries it started itself. - Two keys of the same account with two endpoints receive two deliveries with different
X-Folovup-Deliveryids for the same logical event. If both endpoints feed the same system, deduplicate on (event,data.discovery.id) as well — see At-least-once delivery and deduplication. - Revoking a key stops its endpoint from receiving anything new. A key that has merely expired but was not revoked still receives deliveries; ask FolovUp to revoke the key (see Getting access) if that is not what you want, since an expired key can no longer call
DELETE /webhookitself. - An endpoint registered after the terminal moment receives nothing for that discovery; there is no replay of past events. Use
GET /discoveries?updated_since=…to catch up (see Pagination and incremental sync). - One delivery per (
event, discovery) is dispatched to each endpoint, guarded for one hour. In rare recovery situations on our side the same logical event can be dispatched again later under a new delivery id (not guaranteed to never happen), which is the second reason to deduplicate on (event,data.discovery.id).
Webhook delivery request
Method and target: POST to the exact url you registered. FolovUp connects with an 8-second connection timeout and a 15-second total timeout per attempt.
Request headers set by FolovUp:
| Header | Value | Notes |
|---|---|---|
Content-Type |
application/json |
The body is UTF-8 JSON. |
X-Folovup-Event |
discovery.completed, discovery.failed or discovery.cancelled |
Same value as the body field event. The test delivery uses discovery.completed. |
X-Folovup-Delivery |
ULID, 26 uppercase Crockford-base32 characters, e.g. 01J9Z0000000000000000000D1 |
Unique per delivery; stays the same across retries of that delivery. Same value as the body field delivery_id. Deduplicate on it. |
X-Folovup-Signature |
t=<unix seconds>,v1=<64 lowercase hex> |
See Webhook signature. Changes on every retry (fresh t). |
User-Agent |
FolovUp-Webhook/1 |
|
Content-Length |
byte length of the body | Added by the HTTP client. |
No other application headers are sent: no Accept, no Authorization, no API key. Authenticity comes from the signature only. Redirects: do not rely on them. A 301/302/303 turns the delivery into a body-less GET at the new location, an https:// to http:// hop is followed, and more than 5 hops is a failure — register the final https:// URL.
Body: a compact JSON document (no whitespace, no pretty-printing) with these top-level keys in this fixed order. It is encoded with unescaped unicode and unescaped slashes: ü, ş, İ and / appear literally, never as ü or \/. Note that the management endpoints in this section (GET /webhook and friends) respond with the API's usual escaped encoding; the two are unrelated.
| Field | Type | Meaning |
|---|---|---|
event |
string | discovery.completed, discovery.failed or discovery.cancelled. Equal to X-Folovup-Event. |
delivery_id |
string | ULID; equal to X-Folovup-Delivery. Stable across retries. |
created_at |
string | ISO-8601 UTC with +00:00 offset; the moment this attempt's body was built. It changes on every retry. Use data.discovery.completed_at as the event time, never created_at. |
data |
object | { "discovery": {…} } for real events; { "test": true, "discovery": {…} } for the test delivery. |
data.discovery carries the same core fields, with the same meanings, as the object returned by GET /discoveries/{id} (see Discoveries) so that one parser serves both:
| Field | Type | Notes |
|---|---|---|
id |
integer | The discovery id to use with GET /discoveries/{id} and GET /companies?discovery_id=. 0 only in the test delivery. |
query |
string | The query text as submitted (up to 500 characters, any unicode). |
mode |
string | single or nonstop. |
status |
string | completed, failed or cancelled in a real delivery. |
is_terminal |
boolean | Always true in a real delivery. |
progress |
integer | Advisory. Not forced to 100 on failed/cancelled and not clamped; use is_terminal, not progress. |
target_company_count |
integer or null | null for single discoveries and for nonstop discoveries started without a target. |
counts.links_found |
integer | Web results collected. |
counts.analyzed |
integer | Results that reached a terminal outcome. |
counts.relevant |
integer | Results judged relevant to the target. |
counts.companies_created |
integer | Historical counter of companies created by this discovery. |
counts.companies_linked |
integer | Live count of companies you can pull for this discovery right now (never null in a delivery). This is the number of rows to expect from GET /companies?discovery_id=<id>; it can be lower than companies_created and can drop later if the account deletes companies. |
stop_reason |
string or null | See the events table above. |
stop_explanation |
string or null | Turkish, human-readable, derived from stop_reason; null exactly when stop_reason is null. Display it; do not parse it. |
error_message |
string or null | Turkish, human-readable; set on failures. |
completed_at |
string or null | ISO-8601 UTC +00:00; the terminal moment. Use this as the event time. |
The delivery does not contain started_at, last_activity_at, created_at/updated_at of the discovery, or the companies themselves. Fetch those with GET /discoveries/{id} and GET /companies?discovery_id=<id>. Nothing about internal analysis budgets is ever included. New fields may be added at any time; ignore unknown fields.
Test delivery (POST /webhook/test): data is exactly {"test":true,"discovery":{"id":0,"status":"completed","is_terminal":true}}. All other data.discovery keys are absent, event is discovery.completed, and data.test === true together with id: 0 is how you recognise it. Your parser must tolerate the missing keys.
Webhook delivery examples
All four examples below are signed with the documentation secret whsec_Example0Secret0DoNotUse123456789ABCDEFab at t=1788868800 (2026-09-08T12:00:00+00:00). Each "raw body" line is the exact byte sequence that was signed; the v1 values are real and you can use them as test vectors for your verifier. The pretty-printed blocks are the same documents reformatted for reading — the wire body is always the single compact line.
discovery.completed with a stop reason (nonstop, stopped because the account ran out of credits at 40 of 100 companies):
POST /webhooks/folovup HTTP/1.1
Host: partner.example.com
Content-Type: application/json
Content-Length: 570
User-Agent: FolovUp-Webhook/1
X-Folovup-Event: discovery.completed
X-Folovup-Delivery: 01J9Z0000000000000000000D1
X-Folovup-Signature: t=1788868800,v1=a0511b06bf2e55b7142ab2449ac1cc8ce932c7044df3a37fb7c811e90856ee57
Raw body (570 bytes, one line):
{"event":"discovery.completed","delivery_id":"01J9Z0000000000000000000D1","created_at":"2026-09-08T12:00:00+00:00","data":{"discovery":{"id":735,"query":"Türkiye'de trafo imalatı yapan fabrikalar","mode":"nonstop","status":"completed","is_terminal":true,"progress":100,"target_company_count":100,"counts":{"links_found":1893,"analyzed":1893,"relevant":40,"companies_created":40,"companies_linked":40},"stop_reason":"credits","stop_explanation":"Kredi alt sınırına ulaşıldığı için durduruldu.","error_message":null,"completed_at":"2026-09-08T11:58:41+00:00"}}}
The same body, pretty-printed:
{
"event": "discovery.completed",
"delivery_id": "01J9Z0000000000000000000D1",
"created_at": "2026-09-08T12:00:00+00:00",
"data": {
"discovery": {
"id": 735,
"query": "Türkiye'de trafo imalatı yapan fabrikalar",
"mode": "nonstop",
"status": "completed",
"is_terminal": true,
"progress": 100,
"target_company_count": 100,
"counts": {
"links_found": 1893,
"analyzed": 1893,
"relevant": 40,
"companies_created": 40,
"companies_linked": 40
},
"stop_reason": "credits",
"stop_explanation": "Kredi alt sınırına ulaşıldığı için durduruldu.",
"error_message": null,
"completed_at": "2026-09-08T11:58:41+00:00"
}
}
}
discovery.failed (single, the query could not be interpreted; nothing was searched, no credits were charged):
POST /webhooks/folovup HTTP/1.1
Host: partner.example.com
Content-Type: application/json
Content-Length: 590
User-Agent: FolovUp-Webhook/1
X-Folovup-Event: discovery.failed
X-Folovup-Delivery: 01J9Z0000000000000000000D2
X-Folovup-Signature: t=1788868800,v1=bb8d9236a48016cab0e73422dda40ebc09aa67e807b13df3cfd37870c4cb3cbf
Raw body (590 bytes, one line):
{"event":"discovery.failed","delivery_id":"01J9Z0000000000000000000D2","created_at":"2026-09-08T12:00:00+00:00","data":{"discovery":{"id":736,"query":"Almanya'da endüstriyel ambalaj üreticileri","mode":"single","status":"failed","is_terminal":true,"progress":0,"target_company_count":null,"counts":{"links_found":0,"analyzed":0,"relevant":0,"companies_created":0,"companies_linked":0},"stop_reason":"query_parse_failed","stop_explanation":"Arama isteği yorumlanamadı.","error_message":"Arama isteği yorumlanamadı. Lütfen tekrar deneyin.","completed_at":"2026-09-08T11:59:02+00:00"}}}
Pretty-printed:
{
"event": "discovery.failed",
"delivery_id": "01J9Z0000000000000000000D2",
"created_at": "2026-09-08T12:00:00+00:00",
"data": {
"discovery": {
"id": 736,
"query": "Almanya'da endüstriyel ambalaj üreticileri",
"mode": "single",
"status": "failed",
"is_terminal": true,
"progress": 0,
"target_company_count": null,
"counts": {
"links_found": 0,
"analyzed": 0,
"relevant": 0,
"companies_created": 0,
"companies_linked": 0
},
"stop_reason": "query_parse_failed",
"stop_explanation": "Arama isteği yorumlanamadı.",
"error_message": "Arama isteği yorumlanamadı. Lütfen tekrar deneyin.",
"completed_at": "2026-09-08T11:59:02+00:00"
}
}
}
A discovery.failed caused by repeated analysis errors before any company was created looks the same except stop_reason: null, stop_explanation: null and error_message: "Çok fazla ardışık hata oluştu. İşlem durduruldu.".
discovery.cancelled (nonstop, cancelled at 12 of 50 companies; the 12 companies remain available):
POST /webhooks/folovup HTTP/1.1
Host: partner.example.com
Content-Type: application/json
Content-Length: 540
User-Agent: FolovUp-Webhook/1
X-Folovup-Event: discovery.cancelled
X-Folovup-Delivery: 01J9Z0000000000000000000D3
X-Folovup-Signature: t=1788868800,v1=1eafd722705d27584f2d0c7b47235ced3c177b57c2845e2d4b003a6ae7c90dd7
Raw body (540 bytes, one line):
{"event":"discovery.cancelled","delivery_id":"01J9Z0000000000000000000D3","created_at":"2026-09-08T12:00:00+00:00","data":{"discovery":{"id":737,"query":"İtalya'da mobilya ithalatçıları","mode":"nonstop","status":"cancelled","is_terminal":true,"progress":37,"target_company_count":50,"counts":{"links_found":412,"analyzed":151,"relevant":12,"companies_created":12,"companies_linked":12},"stop_reason":"cancelled","stop_explanation":"Kullanıcı tarafından durduruldu.","error_message":null,"completed_at":"2026-09-08T11:59:30+00:00"}}}
Pretty-printed:
{
"event": "discovery.cancelled",
"delivery_id": "01J9Z0000000000000000000D3",
"created_at": "2026-09-08T12:00:00+00:00",
"data": {
"discovery": {
"id": 737,
"query": "İtalya'da mobilya ithalatçıları",
"mode": "nonstop",
"status": "cancelled",
"is_terminal": true,
"progress": 37,
"target_company_count": 50,
"counts": {
"links_found": 412,
"analyzed": 151,
"relevant": 12,
"companies_created": 12,
"companies_linked": 12
},
"stop_reason": "cancelled",
"stop_explanation": "Kullanıcı tarafından durduruldu.",
"error_message": null,
"completed_at": "2026-09-08T11:59:30+00:00"
}
}
}
Test delivery (what POST /webhook/test sends):
POST /webhooks/folovup HTTP/1.1
Host: partner.example.com
Content-Type: application/json
Content-Length: 197
User-Agent: FolovUp-Webhook/1
X-Folovup-Event: discovery.completed
X-Folovup-Delivery: 01J9Z0000000000000000000T1
X-Folovup-Signature: t=1788868800,v1=2a4a99046bb4ce72d443e5708cc68f0e6826833ef456f9942002c68e0791d3e9
Raw body (197 bytes, one line):
{"event":"discovery.completed","delivery_id":"01J9Z0000000000000000000T1","created_at":"2026-09-08T12:00:00+00:00","data":{"test":true,"discovery":{"id":0,"status":"completed","is_terminal":true}}}
Pretty-printed:
{
"event": "discovery.completed",
"delivery_id": "01J9Z0000000000000000000T1",
"created_at": "2026-09-08T12:00:00+00:00",
"data": {
"test": true,
"discovery": {
"id": 0,
"status": "completed",
"is_terminal": true
}
}
}
At-least-once delivery and deduplication
FolovUp retries a delivery whenever it did not observe a 2xx response — including the case where your endpoint did process the request but answered too slowly (after 15 seconds), answered with a non-2xx status, or the connection dropped after processing. The retried request carries the same X-Folovup-Delivery (and the same delivery_id in the body) with a fresh created_at and a fresh signature.
Deduplicate at two levels:
- Delivery level (mandatory): record
X-Folovup-Deliveryin a durable store with a unique constraint before you perform side effects; if the id is already present, answer200and do nothing. The header and the body field always carry the same value; either one is the key. - Logical-event level (recommended): also treat (
event,data.discovery.id) as unique. This covers a second endpoint of the same account feeding the same system and the rare case where the same terminal event is dispatched again under a new delivery id. Exclude the test delivery (data.test === true,id: 0) from this rule, otherwise your second test is dropped as a duplicate.
Do not deduplicate on created_at or on the signature: both change on every retry. Do not treat a missing delivery as "the discovery is still running": deliveries can be lost; GET /discoveries/{id} is the truth.
Webhook signature
Every delivery is signed with the secret returned by PUT /webhook (once, at creation or rotation). The signature_contract object in that response restates the rules below.
| Item | Value |
|---|---|
| Header | X-Folovup-Signature |
| Header format | t=<t>,v1=<v1> — no spaces, exactly one t and one v1; no other schemes or versions exist |
t |
Unix time in seconds, UTC, decimal digits, no fraction; the moment this attempt was signed |
| Signed string | <t> + . (one ASCII period) + the raw request body bytes exactly as received |
| Algorithm | HMAC-SHA256 |
| Key | The entire secret string as received, e.g. whsec_Example0Secret0DoNotUse123456789ABCDEFab (46 characters: whsec_ + 40 characters from [A-Za-z0-9]), used as raw UTF-8 bytes — not stripped of its prefix, not hex- or base64-decoded |
v1 |
The HMAC digest as 64 lowercase hexadecimal characters |
| Tolerance | Reject if abs(now − t) > 300 seconds. Enforced by you; FolovUp does not enforce it. Keep your server clock synchronised. |
| Comparison | Constant-time (hmac.compare_digest, crypto.timingSafeEqual, hash_equals, hmac.Equal) |
| Retries | Every retry is signed again with a fresh t (and has a fresh created_at in the body) while X-Folovup-Delivery stays the same |
| Secret rotation | From the moment PUT /webhook with rotate_secret: true returns, every delivery — including retries of deliveries queued before the rotation — is signed with the new secret; the secret is read at send time |
Verify over the raw bytes. The body is produced with unescaped unicode and unescaped slashes and without whitespace. Parsing the JSON and serialising it again with your language's default settings usually produces different bytes (escaped ü, escaped \/, added spaces, reordered keys) and a signature mismatch. Some serialisers happen to reproduce the bytes for some bodies — do not depend on it. Read the raw body from the request stream before any JSON middleware touches it, verify, and only then parse.
Two vectors to check your implementation against before you go live:
# Vector 1 — minimal
secret : whsec_abc
t : 1788822010
body : {"event":"discovery.completed"}
header : X-Folovup-Signature: t=1788822010,v1=60aac5d9bc16b0d79c6bfcd07e841fb30a9464fdf8d09944d3c7070dff399978
# Vector 2 — the test delivery from "Webhook delivery examples"
secret : whsec_Example0Secret0DoNotUse123456789ABCDEFab
t : 1788868800
body : {"event":"discovery.completed","delivery_id":"01J9Z0000000000000000000T1","created_at":"2026-09-08T12:00:00+00:00","data":{"test":true,"discovery":{"id":0,"status":"completed","is_terminal":true}}}
header : X-Folovup-Signature: t=1788868800,v1=2a4a99046bb4ce72d443e5708cc68f0e6826833ef456f9942002c68e0791d3e9
The three other bodies in Webhook delivery examples (with their v1 values, same secret, same t) exercise unicode and apostrophes.
Language-neutral algorithm:
raw = request body bytes # before any JSON parsing
sig = header "X-Folovup-Signature"
m = match(sig, /^t=(\d+),v1=([a-f0-9]{64})$/) # no match -> reject
t, v1 = m[1], m[2]
if abs(now_unix_seconds - int(t)) > 300 -> reject
expected = hex_lower(HMAC_SHA256(key = utf8(secret), message = utf8(t) + "." + raw))
if not constant_time_equal(expected, v1) -> reject
delivery = header "X-Folovup-Delivery" # == body.delivery_id
if seen(delivery) -> respond 2xx, stop
mark_seen(delivery); respond 2xx (within 15 s); process(parse_json(raw)) asynchronously
Answer a signature failure with a non-2xx status (for example 401). FolovUp will retry with a fresh t, which is what you want if the cause was clock skew; a forged request is never retried because it did not come from FolovUp.
Verifying the webhook signature
Each sample is a complete receiver: raw-body capture, header parsing, tolerance check, constant-time HMAC comparison, delivery-level deduplication, a fast 200 and asynchronous processing. The in-memory seen sets are placeholders — use a durable store with a unique index on the delivery id. Load the secret from configuration exactly as PUT /webhook returned it.
Node.js (Node 18+, npm install express; only crypto from the standard library is used for verification):
const crypto = require('crypto');
const express = require('express');
const SECRET = process.env.FOLOVUP_WEBHOOK_SECRET; // whsec_… exactly as returned by PUT /webhook
const TOLERANCE_SECONDS = 300;
function verifyFolovupSignature(secret, signatureHeader, rawBody, nowUnix = Math.floor(Date.now() / 1000)) {
const m = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(signatureHeader || '');
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(nowUnix - Number(t)) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac('sha256', secret).update(t + '.').update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const seen = new Set(); // placeholder — use a durable store with a unique index on delivery_id
const app = express();
// RAW body on this route. Do not mount express.json() in front of it: a JSON parser
// re-serialises the payload and the signature no longer matches.
app.post('/webhooks/folovup', express.raw({ type: '*/*', limit: '1mb' }), (req, res) => {
const raw = req.body; // Buffer with the exact bytes FolovUp signed
if (!verifyFolovupSignature(SECRET, req.get('X-Folovup-Signature'), raw)) {
return res.status(401).end();
}
const deliveryId = req.get('X-Folovup-Delivery');
if (seen.has(deliveryId)) {
return res.status(200).end(); // duplicate delivery: acknowledge, do nothing
}
seen.add(deliveryId);
const payload = JSON.parse(raw.toString('utf8'));
res.status(200).end(); // acknowledge first, well inside the 15 s window …
setImmediate(() => handle(payload)); // … then do the real work
});
function handle(payload) {
if (payload.data.test === true) return; // test delivery from POST /webhook/test
const discoveryId = payload.data.discovery.id;
// e.g. GET /discoveries/{discoveryId}, then page GET /companies?discovery_id={discoveryId}
}
app.listen(8080);
Python (3.10+, pip install fastapi uvicorn; verification uses only hmac, hashlib, re, time):
import hashlib
import hmac
import json
import os
import re
import time
from fastapi import BackgroundTasks, FastAPI, Request, Response
SECRET = os.environ["FOLOVUP_WEBHOOK_SECRET"] # whsec_… exactly as returned by PUT /webhook
TOLERANCE_SECONDS = 300
SIGNATURE_RE = re.compile(r"^t=(\d+),v1=([a-f0-9]{64})$")
def verify_folovup_signature(
secret: str, signature_header: str | None, raw_body: bytes, now_unix: int | None = None
) -> bool:
m = SIGNATURE_RE.match(signature_header or "")
if not m:
return False
t, v1 = m.group(1), m.group(2)
now = int(time.time()) if now_unix is None else now_unix
if abs(now - int(t)) > TOLERANCE_SECONDS:
return False
signed = t.encode("ascii") + b"." + raw_body # bytes, never a re-serialised dict
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
app = FastAPI()
seen: set[str] = set() # placeholder — use a durable store with a unique index on delivery_id
@app.post("/webhooks/folovup")
async def folovup_webhook(request: Request, background: BackgroundTasks) -> Response:
raw = await request.body() # exact bytes; do not call request.json() before verifying
if not verify_folovup_signature(SECRET, request.headers.get("x-folovup-signature"), raw):
return Response(status_code=401)
delivery_id = request.headers.get("x-folovup-delivery", "")
if delivery_id in seen:
return Response(status_code=200) # duplicate delivery: acknowledge, do nothing
seen.add(delivery_id)
payload = json.loads(raw.decode("utf-8"))
background.add_task(handle, payload) # runs after the 200 has been sent
return Response(status_code=200)
def handle(payload: dict) -> None:
if payload["data"].get("test") is True:
return # test delivery from POST /webhook/test
discovery_id = payload["data"]["discovery"]["id"]
# e.g. GET /discoveries/{discovery_id}, then page GET /companies?discovery_id={discovery_id}
Run with uvicorn app:app --port 8080 (file named app.py).
PHP (8.0+, no framework; deploy the file at the registered URL):
<?php
declare(strict_types=1);
const TOLERANCE_SECONDS = 300;
$secret = (string) getenv('FOLOVUP_WEBHOOK_SECRET'); // whsec_… exactly as returned by PUT /webhook
function verifyFolovupSignature(string $secret, ?string $signatureHeader, string $rawBody, ?int $nowUnix = null): bool
{
if (!preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', (string) $signatureHeader, $m)) {
return false;
}
$now = $nowUnix ?? time();
if (abs($now - (int) $m[1]) > TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $m[1] . '.' . $rawBody, $secret);
return hash_equals($expected, $m[2]);
}
// Placeholders — replace with a table that has a UNIQUE index on delivery_id.
function alreadySeen(string $deliveryId): bool
{
return preg_match('/^[0-9A-Z]{26}$/', $deliveryId) === 1
&& file_exists(sys_get_temp_dir() . '/folovup-delivery-' . $deliveryId);
}
function markSeen(string $deliveryId): void
{
if (preg_match('/^[0-9A-Z]{26}$/', $deliveryId) === 1) {
touch(sys_get_temp_dir() . '/folovup-delivery-' . $deliveryId);
}
}
function handle(array $payload): void
{
if (($payload['data']['test'] ?? false) === true) {
return; // test delivery from POST /webhook/test
}
$discoveryId = (int) $payload['data']['discovery']['id'];
// e.g. GET /discoveries/{$discoveryId}, then page GET /companies?discovery_id={$discoveryId}
}
$raw = (string) file_get_contents('php://input'); // exact bytes; never json_decode() + json_encode()
$signature = $_SERVER['HTTP_X_FOLOVUP_SIGNATURE'] ?? null;
$deliveryId = $_SERVER['HTTP_X_FOLOVUP_DELIVERY'] ?? '';
if (!verifyFolovupSignature($secret, $signature, $raw)) {
http_response_code(401);
exit;
}
if (alreadySeen($deliveryId)) {
http_response_code(200); // duplicate delivery: acknowledge, do nothing
exit;
}
markSeen($deliveryId);
$payload = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // the 200 leaves now; the work below no longer delays it
}
handle($payload);
Go (1.18+, standard library only):
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"regexp"
"strconv"
"sync"
"time"
)
const toleranceSeconds int64 = 300
var signatureRe = regexp.MustCompile(`^t=(\d+),v1=([a-f0-9]{64})$`)
func verifyFolovupSignature(secret, signatureHeader string, rawBody []byte, nowUnix int64) bool {
m := signatureRe.FindStringSubmatch(signatureHeader)
if m == nil {
return false
}
t, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return false
}
diff := nowUnix - t
if diff < 0 {
diff = -diff
}
if diff > toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(m[1]))
mac.Write([]byte("."))
mac.Write(rawBody) // exact bytes; never decode and re-encode before verifying
expected := mac.Sum(nil)
got, err := hex.DecodeString(m[2])
if err != nil {
return false
}
return hmac.Equal(expected, got)
}
type envelope struct {
Event string `json:"event"`
DeliveryID string `json:"delivery_id"`
CreatedAt string `json:"created_at"`
Data struct {
Test bool `json:"test"`
Discovery struct {
ID int `json:"id"`
Status string `json:"status"`
IsTerminal bool `json:"is_terminal"`
StopReason *string `json:"stop_reason"`
} `json:"discovery"`
} `json:"data"`
}
var (
secret = os.Getenv("FOLOVUP_WEBHOOK_SECRET") // whsec_… exactly as returned by PUT /webhook
seenMu sync.Mutex
seen = map[string]struct{}{} // placeholder — use a durable store with a unique index on delivery_id
)
func handle(p envelope) {
if p.Data.Test {
return // test delivery from POST /webhook/test
}
discoveryID := p.Data.Discovery.ID
_ = discoveryID
// e.g. GET /discoveries/{discoveryID}, then page GET /companies?discovery_id={discoveryID}
}
func main() {
http.HandleFunc("/webhooks/folovup", func(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !verifyFolovupSignature(secret, r.Header.Get("X-Folovup-Signature"), raw, time.Now().Unix()) {
w.WriteHeader(http.StatusUnauthorized)
return
}
deliveryID := r.Header.Get("X-Folovup-Delivery")
seenMu.Lock()
_, dup := seen[deliveryID]
if !dup {
seen[deliveryID] = struct{}{}
}
seenMu.Unlock()
if dup {
w.WriteHeader(http.StatusOK) // duplicate delivery: acknowledge, do nothing
return
}
var payload envelope
if err := json.Unmarshal(raw, &payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK) // acknowledge first, well inside the 15 s window …
go handle(payload) // … then do the real work
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Webhook retry schedule
One delivery is at most 4 HTTP attempts. Each attempt is a fresh POST with the same X-Folovup-Delivery, a fresh t/signature and a fresh created_at.
| Attempt | Earliest time (T = delivery queued) | Delay after the previous failed attempt |
|---|---|---|
| 1 | T (immediately, as soon as the delivery is picked up) | — |
| 2 | T + 30 s | 30 s |
| 3 | T + 150 s | 120 s |
| 4 | T + 750 s | 600 s |
| abandoned | ≈ T + 1350 s (about 22.5 minutes); no fifth request is sent | 600 s |
- Times are minimums: deliveries are sent one at a time in order, so a backlog pushes them later.
- Per attempt: 8-second connection timeout, 15-second total timeout. Your endpoint must have sent its response within 15 seconds of the request starting.
- Success = the final HTTP status is 2xx (
200–299). The response body is ignored. - Failure = any other final status (including 3xx that could not be followed, 4xx, 5xx), a timeout, a DNS/TLS/connection error, or more than 5 redirects. Redirects are followed but a
301/302/303converts the request into a body-lessGET; register the finalhttps://URL so no redirect happens at all. - Deleting the webhook (
DELETE /webhook) or settingis_active: falsedrops queued and not-yet-abandoned deliveries silently; they are not attempted and leave no trace inGET /webhook. - An abandoned delivery is not replayed later. The discovery itself is unaffected; fetch its state with
GET /discoveries/{id}.
Webhook failure bookkeeping
GET /webhook exposes delivery health. Every real and test delivery updates it.
| Field | Updated when | Meaning |
|---|---|---|
last_success_at |
any attempt gets a 2xx | Time of the most recent successful attempt. null until the first success. |
consecutive_failures |
+1 on every failed attempt of any delivery (a fully abandoned delivery adds 4); reset to 0 by the next successful attempt and by every PUT /webhook |
Failed attempts since the last success. Grows without bound while your endpoint is broken. |
last_failure_at |
any failed attempt, and again when a delivery is abandoned | Time of the most recent failure. Never cleared — compare it with last_success_at to see whether you have recovered. |
last_failure_reason |
any failed attempt; the abandonment; cleared to null by the next successful attempt (not by PUT) |
HTTP <status> (for example HTTP 500, HTTP 404) when your endpoint answered with a non-2xx status; otherwise the transport error text, at most 250 characters (timeouts, connection refused, TLS errors, too many redirects). After a delivery is abandoned it starts with VAZGECILDI (4 deneme): (Turkish for "gave up (4 attempts)") followed by the last error text — this prefix is how you tell "still retrying" from "permanently lost". |
is_active |
only by PUT /webhook |
FolovUp never disables an endpoint automatically, however many failures accumulate. Deliveries keep being attempted until you fix the endpoint, update it, deactivate it or delete it. |
Recommended monitoring: poll GET /webhook on a schedule and alert when consecutive_failures > 0 for longer than your tolerance, or when last_failure_reason starts with VAZGECILDI. A PUT /webhook resets consecutive_failures even if nothing changed, so do not use it as a "still failing" probe; the reason and timestamp fields survive the reset.
Webhook secret rotation
The secret is returned exactly once, by the PUT /webhook that created the endpoint or that carried rotate_secret: true. There is no way to read it back; if you lose it, rotate.
Zero-downtime runbook:
- Deploy your endpoint so that it accepts two secrets: the current one and a "next" one. Verify against the current secret first; if that fails and a next secret is configured, verify against the next secret.
- Call
PUT /webhookwithrotate_secret: true. Sendurltoo — it is required on everyPUT(send the same value). Do not sendevents/is_activeunless you want to change them; omitted fields keep their values. - Store the
secretfrom the response as the next secret and make it live in your endpoint. From the instant thePUTreturned, every delivery — including retries of deliveries queued before the rotation — is signed with the new secret, because the secret is read at send time. Only a request that was already on the wire at that instant can still carry the old signature. - One minute later, drop the old secret. Anything still signed with it is a forgery.
- Note that the
PUTalso resetconsecutive_failuresto0.
If you cannot deploy dual-secret verification, rotate at a quiet time: from the PUT until your new secret is live, deliveries fail verification on your side and are retried (T+30 s, T+150 s, T+750 s); as long as the new secret is live within about 12 minutes, the third or fourth attempt succeeds and nothing is lost. Longer than 22.5 minutes and those deliveries are abandoned — catch up with GET /discoveries?updated_since=….
Webhook endpoint checklist
- The registered
urlstarts withhttps://, has a valid certificate that public clients trust, is reachable from the public internet, is at most 512 characters, and is the final URL (no redirect). - Accepts
POSTwithContent-Type: application/json; bodies are small (the examples above are 197–590 bytes; the query is at most 500 characters andstop_explanationis a short sentence, so a body stays in the low kilobytes). - Reads the raw body before any JSON parser or framework middleware runs.
- Parses
X-Folovup-Signaturestrictly (^t=(\d+),v1=([a-f0-9]{64})$), rejects whenabs(now − t) > 300, computes HMAC-SHA256 overt + "." + raw bodywith the wholewhsec_…secret, compares in constant time. - Answers a bad signature with a non-2xx status; never processes an unverified body.
- Responds
2xxwithin 15 seconds (connection accepted within 8 seconds), and does the actual work after responding. - Is idempotent on
X-Folovup-Delivery, and on (event,data.discovery.id) for non-test deliveries. - Recognises the test delivery (
data.test === true,data.discovery.id === 0, most keys absent) and does not try to fetch discovery0. - Uses the delivery as a trigger: fetches
GET /discoveries/{id}and pagesGET /companies?discovery_id=<id>rather than treating the delivered counts as final. - Uses
data.discovery.completed_atas the event time, notcreated_at. - Ignores unknown
eventvalues, unknown fields and unknownstop_reasonvalues (the API is additive). - Keeps polling
GET /discoveries/{id}untilis_terminalfor discoveries it started; webhooks only shorten the wait. - Has its clock synchronised (NTP); a skew above 300 seconds rejects every delivery.
- Watches
GET /webhook(consecutive_failures,last_failure_reason) and has a rotation procedure for the secret.
Testing a webhook endpoint locally
FolovUp must be able to reach your endpoint over public HTTPS. For local development expose your port through any HTTPS tunnel and register the tunnel's https:// URL. The URL is validated for format only (https:// prefix, at most 512 characters); it is not resolved or probed at registration time, so a typo shows up as delivery failures in GET /webhook, not as a 422.
- Register the endpoint and keep the secret:
curl -sS -X PUT https://folovup.com/api/partner/v1/webhook \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"url":"https://partner.example.com/webhooks/folovup"}'
- Trigger a test delivery. It is sent whatever your
eventslist says (you can test before subscribing); it is refused only when no endpoint exists (not_configured) or the endpoint isis_active: false(webhook_inactive):
curl -sS -X POST https://folovup.com/api/partner/v1/webhook/test \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
{"queued":true,"delivery_id":"01J9Z0000000000000000000T1","message":"Test teslimatı kuyruğa alındı."}
-
Within a few seconds your endpoint receives a
POSTwhoseX-Folovup-Deliveryequals thedelivery_idabove,X-Folovup-Event: discovery.completed, and bodydataof{"test":true,"discovery":{"id":0,"status":"completed","is_terminal":true}}. Verify the signature with the secret from step 1 and answer200. -
Confirm from FolovUp's side:
curl -sS https://folovup.com/api/partner/v1/webhook \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
last_success_at is set and consecutive_failures is 0. If instead consecutive_failures is 1 or more, last_failure_reason tells you why (HTTP 401 means your verifier rejected the signature; a transport message means FolovUp could not reach the URL). The test delivery follows the same retry schedule as a real one, so a fixed endpoint receives the retry with the same delivery_id.
- Offline, before any of this, run your verifier against the two vectors in Webhook signature and the four bodies in Webhook delivery examples.
The test delivery updates the health fields exactly like a real delivery. Each POST /webhook/test produces a new delivery_id.
GET /webhook
Returns the webhook endpoint registered for the calling API key and its delivery health. The secret is never returned.
Scope: none — any valid API key, regardless of its scopes.
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
| — | — | — | — | — | No parameters. |
Request:
curl -sS https://folovup.com/api/partner/v1/webhook \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Response 200 when an endpoint is configured:
{
"configured": true,
"webhook": {
"url": "https://partner.example.com/webhooks/folovup",
"events": ["discovery.completed", "discovery.failed", "discovery.cancelled"],
"is_active": true,
"last_success_at": "2026-09-08T09:49:46+00:00",
"last_failure_at": null,
"last_failure_reason": null,
"consecutive_failures": 0
}
}
Response 200 when no endpoint is configured (also what you get after DELETE /webhook). events lists every event that exists, which is what an endpoint created without an explicit events list subscribes to:
{
"configured": false,
"message": "Bu anahtar için kayıtlı webhook ucu yok.",
"events": ["discovery.completed", "discovery.failed", "discovery.cancelled"]
}
webhook object:
| Field | Type | Meaning | Notes |
|---|---|---|---|
url |
string | The registered endpoint | Always https://. |
events |
array of string | Events this endpoint receives | Values from discovery.completed, discovery.failed, discovery.cancelled. An endpoint created without events shows the full list. [] means it receives nothing (except test deliveries). |
is_active |
boolean | Whether deliveries are sent | Only you change it. |
last_success_at |
string or null | Most recent successful attempt, ISO-8601 UTC +00:00 |
|
last_failure_at |
string or null | Most recent failed attempt or abandonment | Never cleared. |
last_failure_reason |
string or null | Why the last attempt failed | HTTP <status>, a transport error, or VAZGECILDI (4 deneme): … after abandonment. See Webhook failure bookkeeping. |
consecutive_failures |
integer | Failed attempts since the last success | Reset by success and by every PUT. |
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired key. |
| 403 | account_suspended |
The account that owns the key is suspended. |
| 429 | rate_limited |
Per-key limit exceeded; see Rate limiting. |
PUT /webhook
Creates the webhook endpoint for the calling API key, or updates the existing one. Returns the signing secret only when the endpoint is created or when rotate_secret is true; it is never shown again.
Scope: none — any valid API key, regardless of its scopes.
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
url |
body | string | yes, on every PUT (also on a pure rotation) | — | Must start with https://; at most 512 characters; must parse as a URL (host or IP, optional port, path, query). Format-only validation: no DNS lookup, no reachability probe, no private-address block. Register the final URL — no redirects. Every PUT overwrites the stored URL with this value. |
events |
body | array of string | no | create: all events; update: unchanged | Each element one of discovery.completed, discovery.failed, discovery.cancelled; any other value → 422. [] is accepted and means "receive no events" (test deliveries are still sent). null → 422. To return to "all events" after narrowing, send the full list. |
is_active |
body | boolean | no | create: true; update: unchanged |
Accepted encodings: true, false, 1, 0, "1", "0". The JSON strings "true"/"false" → 422. false stops all deliveries (test included) without deleting the endpoint or its secret. |
rotate_secret |
body | boolean | no | false |
On update, true generates a new secret and returns it once. Ignored on create (a fresh secret is generated and returned anyway). Same accepted encodings as is_active. |
Side effects of every PUT, create or update: consecutive_failures is reset to 0 (last_failure_at and last_failure_reason are kept). Deliveries already queued are sent to the new URL with the current secret at send time.
Request — create with all events (or update the URL, keeping everything else):
curl -sS -X PUT https://folovup.com/api/partner/v1/webhook \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"url":"https://partner.example.com/webhooks/folovup"}'
Request — create or update with an explicit subscription:
curl -sS -X PUT https://folovup.com/api/partner/v1/webhook \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"url":"https://partner.example.com/webhooks/folovup","events":["discovery.completed","discovery.cancelled"],"is_active":true}'
Request — rotate the secret (see Webhook secret rotation):
curl -sS -X PUT https://folovup.com/api/partner/v1/webhook \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"url":"https://partner.example.com/webhooks/folovup","rotate_secret":true}'
Python (requests):
import requests
r = requests.put(
"https://folovup.com/api/partner/v1/webhook",
headers={"Authorization": "Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"Accept": "application/json"},
json={"url": "https://partner.example.com/webhooks/folovup",
"events": ["discovery.completed", "discovery.failed", "discovery.cancelled"]},
timeout=30,
)
r.raise_for_status()
body = r.json()
if body.get("secret"):
store_secret_securely(body["secret"]) # shown only now
Node.js (fetch, Node 18+; top-level await requires an ES module — otherwise wrap in an async function):
const res = await fetch('https://folovup.com/api/partner/v1/webhook', {
method: 'PUT',
headers: {
Authorization: 'Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ url: 'https://partner.example.com/webhooks/folovup' }),
});
if (!res.ok) throw new Error(`PUT /webhook failed: ${res.status}`);
const body = await res.json();
if (body.secret) storeSecretSecurely(body.secret); // shown only now
Response 200 — created (the secret is present; store it now). events reflects what was sent, or the full list when omitted:
{
"configured": true,
"webhook": {
"url": "https://partner.example.com/webhooks/folovup",
"events": ["discovery.completed", "discovery.failed", "discovery.cancelled"],
"is_active": true,
"last_success_at": null,
"last_failure_at": null,
"last_failure_reason": null,
"consecutive_failures": 0
},
"secret": "whsec_Example0Secret0DoNotUse123456789ABCDEFab",
"signature_contract": {
"header": "X-Folovup-Signature",
"format": "t=<unix>,v1=<hex>",
"signed_payload": "{t} + \".\" + RAW request body",
"algorithm": "HMAC-SHA256",
"tolerance_seconds": 300,
"idempotency_header": "X-Folovup-Delivery",
"note": "Gövdeyi yeniden serileştirmeyin; HAM gövde üzerinden doğrulayın. Aynı X-Folovup-Delivery tekrar gelebilir (en-az-bir-kez teslimat). Webhook teslimatı garanti DEĞİLDİR — polling yedeğini koruyun."
}
}
Response 200 — updated without rotation (secret is null; the existing secret stays valid):
{
"configured": true,
"webhook": {
"url": "https://partner.example.com/webhooks/folovup",
"events": ["discovery.completed", "discovery.cancelled"],
"is_active": true,
"last_success_at": "2026-09-08T09:49:46+00:00",
"last_failure_at": "2026-09-08T08:12:03+00:00",
"last_failure_reason": "HTTP 503",
"consecutive_failures": 0
},
"secret": null,
"signature_contract": {
"header": "X-Folovup-Signature",
"format": "t=<unix>,v1=<hex>",
"signed_payload": "{t} + \".\" + RAW request body",
"algorithm": "HMAC-SHA256",
"tolerance_seconds": 300,
"idempotency_header": "X-Folovup-Delivery",
"note": "Gövdeyi yeniden serileştirmeyin; HAM gövde üzerinden doğrulayın. Aynı X-Folovup-Delivery tekrar gelebilir (en-az-bir-kez teslimat). Webhook teslimatı garanti DEĞİLDİR — polling yedeğini koruyun."
}
}
Response 200 — rotated (rotate_secret: true; a new, different secret; everything else unchanged because it was omitted):
{
"configured": true,
"webhook": {
"url": "https://partner.example.com/webhooks/folovup",
"events": ["discovery.completed", "discovery.cancelled"],
"is_active": true,
"last_success_at": "2026-09-08T09:49:46+00:00",
"last_failure_at": "2026-09-08T08:12:03+00:00",
"last_failure_reason": "HTTP 503",
"consecutive_failures": 0
},
"secret": "whsec_Rotated0Secret0DoNotUse987654321ZYXWVUcd",
"signature_contract": {
"header": "X-Folovup-Signature",
"format": "t=<unix>,v1=<hex>",
"signed_payload": "{t} + \".\" + RAW request body",
"algorithm": "HMAC-SHA256",
"tolerance_seconds": 300,
"idempotency_header": "X-Folovup-Delivery",
"note": "Gövdeyi yeniden serileştirmeyin; HAM gövde üzerinden doğrulayın. Aynı X-Folovup-Delivery tekrar gelebilir (en-az-bir-kez teslimat). Webhook teslimatı garanti DEĞİLDİR — polling yedeğini koruyun."
}
}
signature_contract is a machine-readable restatement of Webhook signature: header, format, signed_payload, algorithm (strings), tolerance_seconds (integer, 300), idempotency_header (string) and a Turkish note. Its values are constant.
Response 422 — url is not https:// (the errors object follows the validation envelope described in Conventions):
{
"error": "validation_failed",
"message": "The url field must start with one of the following: https://.",
"errors": {
"url": ["The url field must start with one of the following: https://."]
}
}
Response 422 — unknown event name (errors keys are indexed per element, e.g. events.0):
{
"error": "validation_failed",
"message": "The selected events.0 is invalid.",
"errors": {
"events.0": ["The selected events.0 is invalid."]
}
}
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired key. |
| 403 | account_suspended |
The account that owns the key is suspended. |
| 422 | validation_failed |
url missing, not https://, longer than 512 characters or not a URL; an element of events is not one of the three event names; events is not an array (including null); is_active or rotate_secret is not a boolean encoding (true, false, 1, 0, "1", "0"). |
| 429 | rate_limited |
Per-key limit exceeded; see Rate limiting. |
DELETE /webhook
Deletes the calling key's webhook endpoint together with its secret. Idempotent: deleting when nothing is configured is also 200.
Scope: none — any valid API key, regardless of its scopes.
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
| — | — | — | — | — | No parameters. |
Request:
curl -sS -X DELETE https://folovup.com/api/partner/v1/webhook \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Response 200 (identical whether or not an endpoint existed):
{"configured":false}
After deletion: deliveries already queued for this endpoint are dropped silently (not attempted, nothing recorded); GET /webhook returns the unconfigured shape; a later PUT /webhook creates a new endpoint with a new secret. Deleting the endpoint does not affect the API key. Deleting only affects this key's endpoint; other keys of the same account keep theirs.
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired key. |
| 403 | account_suspended |
The account that owns the key is suspended. |
| 429 | rate_limited |
Per-key limit exceeded; see Rate limiting. |
POST /webhook/test
Queues one real, signed delivery with a synthetic body to the calling key's endpoint so you can verify transport and signature handling end to end. The events subscription list is ignored for the test (you can test before subscribing); is_active is honoured.
Scope: none — any valid API key, regardless of its scopes.
| Name | In | Type | Required | Default | Notes |
|---|---|---|---|---|---|
| — | — | — | — | — | No parameters and no request body. |
Request:
curl -sS -X POST https://folovup.com/api/partner/v1/webhook/test \
-H "Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Accept: application/json"
Response 202 — queued. delivery_id is the value your endpoint will see in X-Folovup-Delivery and in the body's delivery_id:
{
"queued": true,
"delivery_id": "01J9Z0000000000000000000T1",
"message": "Test teslimatı kuyruğa alındı."
}
What arrives at your endpoint: X-Folovup-Event: discovery.completed, X-Folovup-Delivery: <delivery_id>, a valid X-Folovup-Signature, and the body {"event":"discovery.completed","delivery_id":"<delivery_id>","created_at":"<now>","data":{"test":true,"discovery":{"id":0,"status":"completed","is_terminal":true}}} — see the fourth example in Webhook delivery examples. It follows the normal retry schedule and updates GET /webhook health fields exactly like a real delivery. 202 means "queued", not "delivered": check your endpoint's log or GET /webhook.
Response 422 — no endpoint registered:
{"error":"not_configured","message":"Önce bir webhook ucu kaydedin."}
Response 422 — endpoint exists but is_active is false (set is_active: true with PUT /webhook, then test again):
{"error":"webhook_inactive","message":"Webhook ucu pasif (is_active=false); test teslimatı gönderilmez."}
Errors:
| Status | error |
When |
|---|---|---|
| 401 | unauthenticated |
Missing, malformed, unknown, revoked or expired key. |
| 403 | account_suspended |
The account that owns the key is suspended. |
| 422 | not_configured |
No webhook endpoint exists for this key. |
| 422 | webhook_inactive |
The endpoint exists but is_active is false. |
| 429 | rate_limited |
Per-key limit exceeded; see Rate limiting. |
Integrating from an AI agent
This section is written for an autonomous agent (or the developer wiring one up). It gives you eight tool definitions that map 1:1 onto the endpoints, the policy rules the tool descriptions encode, a worked control loop, a system-prompt block, and notes on loading the OpenAPI document or exposing the tools over MCP. Everything here is derived from the endpoint sections above; where a rule has a reason, the reason is linked.
The machine-readable inputs an agent should load — the Markdown twin of this guide, the public OpenAPI document and the llms.txt files — are listed once in Machine-readable resources; every API response also carries a Link header pointing at them (Transport).
Tool definitions
The eight definitions below use a provider-neutral function-calling shape: name, description, and a JSON Schema under parameters. If your framework expects input_schema (or inputSchema) instead of parameters, rename that one key; nothing else changes. Your wrapper is responsible for three translations that are deliberately kept out of the schemas so the model never has to think about them:
- Add
Authorization: Bearer <API key>andAccept: application/jsonto every request; addContent-Type: application/jsonto every request with a body. The key must live in the wrapper's environment, never in the model's context. - Send booleans on the query string as
1/0(the API rejects the stringstrue/falsein query parameters with422 validation_failed). Mapinclude_contacts: truetoinclude=contactsandinclude_companies: truetoinclude=companies. - Refuse to call
POST /discoveriesandPOST /discoveries/{id}/cancelunlessconfirmed_by_humanistrue, and always pass the model-suppliedidempotency_keyas theIdempotency-Keyheader.
The descriptions carry the safety rules on purpose: a model that only sees the tool list still sees the rules.
[
{
"name": "folovup_me",
"description": "Read-only. Returns the API key (name, 20-character prefix, scopes, per-minute rate limit, expiry), the owning account (id, name, credit balance), the number of companies and contacts visible by default, and the server time in UTC. Call this first in every session: the scopes tell you which other tools will work, and account.credits tells you whether a discovery can be started (it needs at least 30 credits). Spends no credits.",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"name": "folovup_list_companies",
"description": "Read-only. Lists the account's companies ordered by (updated_at, id) ascending, with cursor pagination. To walk all pages, pass the previous response's meta.next_cursor as cursor, unchanged, until it is null. Use updated_since only at the START of an incremental pass and never change it (or any filter) while a cursor walk is in progress. A row with is_deleted=true is a tombstone: remove it from your store. A company's updated_at does not change when its contacts change, so use folovup_list_contacts for contact changes. Without include_contacts the response has no contacts key at all (absent, not empty). Requires scope companies:read. Spends no credits.",
"parameters": {
"type": "object",
"properties": {
"updated_since": {
"type": "string",
"description": "ISO-8601 date-time. Compared in UTC as updated_at >= value (inclusive, 1-second resolution). Recommended form: 2026-09-08T09:00:00Z. Expect the boundary row to be delivered again on the next pass; upsert by id."
},
"cursor": {
"type": "string",
"description": "Opaque cursor copied verbatim from meta.next_cursor (forward) or meta.prev_cursor (backward). Never construct, edit or decode it. A cursor that cannot be decoded returns 422 invalid_cursor."
},
"per_page": {
"type": "integer",
"minimum": 1,
"maximum": 200,
"default": 100
},
"include_deleted": {
"type": "boolean",
"default": false,
"description": "Also return tombstones (is_deleted=true). Needed for a mirror; not needed for a one-off lookup."
},
"include_contacts": {
"type": "boolean",
"default": false,
"description": "Embed every default-visible contact of each company on the page (no cap, no defined order). With include_deleted=true the embedded contacts include tombstones too."
},
"sector": {
"type": "string",
"maxLength": 255,
"description": "Exact match on the free-text sector value (case- and accent-insensitive). Not a substring search."
},
"country": {
"type": "string",
"minLength": 2,
"maxLength": 2,
"description": "ISO-3166-1 alpha-2 code of the headquarters country, e.g. TR, DE. Exactly 2 characters; case-insensitive."
},
"discovery_id": {
"type": "integer",
"description": "Only companies produced by this discovery id. Use counts.companies_linked of that discovery as the expected row count."
},
"search": {
"type": "string",
"maxLength": 255,
"description": "Substring match on legal name, short name, the original searched name, or the website. The characters % and _ act as wildcards."
}
},
"additionalProperties": false
}
},
{
"name": "folovup_get_company",
"description": "Read-only. Returns one company by its 26-character ULID id or its numeric folovup_company_id, ALWAYS with its default-visible contacts embedded under contacts. Returns the company even if it is deleted (check is_deleted). Returns 404 not_found for ids that belong to another account or do not exist. Requires scope companies:read. Spends no credits.",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Company id: the ULID from a list response (preferred) or the numeric folovup_company_id as a string."
},
"include_deleted": {
"type": "boolean",
"default": false,
"description": "Also embed deleted contacts (tombstones) of this company."
}
},
"required": ["id"],
"additionalProperties": false
}
},
{
"name": "folovup_list_contacts",
"description": "Read-only. Lists the account's contacts (email addresses with the person and company they belong to) ordered by (updated_at, id) ascending, with cursor pagination identical to folovup_list_companies. verification.is_valid is THREE-valued: true = mailbox confirmed, false = rejected, null = unknown or catch-all domain. Never treat null as false. verified_only=true returns only is_valid=true rows and therefore drops every null. A contact's company_id can change over time; identify contacts by their own id (ULID), not by (company, email). Requires scope contacts:read. Spends no credits.",
"parameters": {
"type": "object",
"properties": {
"updated_since": {
"type": "string",
"description": "ISO-8601 date-time, compared in UTC, inclusive. Same rules as folovup_list_companies."
},
"cursor": {
"type": "string",
"description": "Opaque cursor from meta.next_cursor. Never construct or edit it."
},
"per_page": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"default": 200
},
"include_deleted": {
"type": "boolean",
"default": false,
"description": "Also return tombstones (is_deleted=true). Deleted contacts keep deleted_at=null and status=active; is_deleted is the only reliable signal."
},
"company_id": {
"type": "string",
"maxLength": 64,
"description": "Restrict to one company: its ULID id or numeric folovup_company_id."
},
"verified_only": {
"type": "boolean",
"default": false,
"description": "Only contacts with verification.is_valid=true. Excludes null (unknown/catch-all) as well as false."
},
"exclude_role": {
"type": "boolean",
"default": false,
"description": "Drop role addresses (info@, sales@, ...): both contacts whose verification recorded is_role_email=true and unverified contacts whose local part is on the role list. Any contact returned with is_role_email=true is excluded."
}
},
"additionalProperties": false
}
},
{
"name": "folovup_list_discoveries",
"description": "Read-only. Lists the account's discoveries (market-research runs) ordered by (updated_at, id) ascending, with cursor pagination. Includes discoveries started from the FolovUp web app, not only those started through the API. Each item carries status, is_terminal, counts, stop_reason and stop_explanation. Requires scope discovery:read. Spends no credits.",
"parameters": {
"type": "object",
"properties": {
"updated_since": {
"type": "string",
"description": "ISO-8601 date-time, compared in UTC, inclusive."
},
"cursor": {
"type": "string",
"description": "Opaque cursor from meta.next_cursor."
},
"per_page": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50
},
"status": {
"type": "string",
"enum": ["processing", "completed", "failed", "cancelled"],
"description": "Exact status filter. Use status=processing to detect a run that is already in flight before starting a new one."
}
},
"additionalProperties": false
}
},
{
"name": "folovup_get_discovery",
"description": "Read-only. Returns the current state of one discovery. Poll this until is_terminal is true (first poll after the poll_after seconds returned when the discovery was started, then back off up to 60 seconds). status=completed does NOT mean the target was reached: read stop_reason and stop_explanation. progress is advisory and is not guaranteed to be 100 on a terminal discovery. counts.companies_linked is the number of companies you can fetch with folovup_list_companies(discovery_id=...); counts.companies_created is a historical counter and may be higher. Returns 404 not_found for another account's discovery. Requires scope discovery:read. Spends no credits.",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Discovery id (integer) from folovup_start_discovery or folovup_list_discoveries."
},
"include_companies": {
"type": "boolean",
"default": false,
"description": "Embed every non-deleted company of this discovery (no pagination, no defined order, contacts not embedded). For large runs prefer folovup_list_companies(discovery_id=...) with pagination."
}
},
"required": ["id"],
"additionalProperties": false
}
},
{
"name": "folovup_start_discovery",
"description": "WRITE. Starts an asynchronous market-research run and SPENDS CREDITS from the account that owns the API key: 30 credits when the run starts plus 2 credits per company created, non-refundable, not reversible by cancelling. NEVER call this without an explicit human confirmation of this exact query, mode and target in the current conversation; report account.credits from folovup_me and the estimated cost before asking. Always pass idempotency_key derived from a stable hash of the confirmed intent, and reuse the same key when retrying the same confirmed request: the API returns 202 for a new run and 200 with meta.idempotent_replay=true if a run with that key already exists (nothing is charged twice). 402 insufficient_credits means the balance is below 30. 409 idempotency_in_progress means the first request is still being processed: wait a few seconds and retry with the same key. The run can take from minutes (mode=single) to hours (mode=nonstop). Requires scope discovery:write.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"minLength": 10,
"maxLength": 500,
"description": "Natural-language description of the target companies, e.g. 'Manufacturers of distribution transformers in Turkey that export to the EU'. Include sector, geography and the buyer/seller role."
},
"mode": {
"type": "string",
"enum": ["single", "nonstop"],
"default": "single",
"description": "single = one search round, completes on its own, target ignored. nonstop = keeps searching in rounds until target_company_count is reached or a stop condition occurs; can run for hours."
},
"target_company_count": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"description": "Only used with mode=nonstop; with mode=single it is accepted but stored as null. Cost ceiling estimate: 30 + 2 x this number."
},
"idempotency_key": {
"type": "string",
"maxLength": 128,
"description": "At most 128 BYTES. Recommended: 'agent:' + SHA-256 hex of account_id|normalized_query|mode|target|confirmation_id. Kept for 24 hours per API key."
},
"confirmed_by_human": {
"type": "boolean",
"description": "Must be true, and true only when a human explicitly approved starting this run. The wrapper refuses the call otherwise."
}
},
"required": ["query", "idempotency_key", "confirmed_by_human"],
"additionalProperties": false
}
},
{
"name": "folovup_cancel_discovery",
"description": "WRITE. Stops a discovery that is still processing. Companies found so far are kept; credits already spent are not refunded; queued candidates are not analysed. Only works while status=processing: a discovery that is already completed, failed or cancelled returns 422 cancel_failed and is left unchanged. Requires an explicit human confirmation in the current conversation. Requires scope discovery:write.",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"description": "Discovery id to cancel."
},
"confirmed_by_human": {
"type": "boolean",
"description": "Must be true, and true only when a human explicitly approved cancelling this run."
}
},
"required": ["id", "confirmed_by_human"],
"additionalProperties": false
}
}
]
Agent policy rules
These rules are the ones a model most often gets wrong. Each one exists because of a measurable behaviour of the API; the link tells you where.
- Never start or cancel a discovery without explicit human confirmation.
POST /discoveriesspends credits of the account that owns the key: 30 credits at start plus 2 per company created, and nothing is refunded on cancel. The 402 check at request time only tests for a balance of at least 30; the actual charge happens a few seconds later inside the run. See POST /discoveries and POST /discoveries/{id}/cancel. Ask once, with the query, the mode, the target and the estimated maximum cost (30 + 2 x target) shown to the human, then act on exactly what was confirmed. - Always send an
Idempotency-Key, derived from a stable hash of the confirmed intent. The API does not compare request bodies on replay: two requests with the same key return the first discovery even if the query differs. Therefore the key must change whenever the intent changes and must stay the same when you retry the same intent. Recommended:"agent:" + sha256_hex(account_id + "|" + normalized_query + "|" + mode + "|" + target + "|" + confirmation_id). That is about 70 bytes, under the 128-byte limit, and it is unique per human approval. A200withmeta.idempotent_replay: truemeans "already started"; treat it as success and do not start another run. - Treat
verification.is_validas three-valued.true= mailbox confirmed,false= rejected,null= unknown or catch-all domain. Store it as a nullable boolean; never coercenulltofalse, never describe anulladdress as "verified" or "invalid".verified_only=1removes thenullrows, which today is almost all of them. See Contacts. - Respect
429andRetry-After. The rate limit is per key, a fixed 60-second window anchored at your first request in that window, and it is fully restored when the window ends. On429sleep for exactly theRetry-Afterseconds (also in the body asretry_after), then continue. There is noX-RateLimit-Resetheader. Do not run concurrent loops against the same key: a burst at the window boundary can be accepted slightly beyond the limit, andX-RateLimit-Remaining(clamped at0) will not show it. Every request that passes authentication, the suspension check and the scope check counts, including ones that end in404or422. - Never advance
updated_sincein the middle of a pass. Pickupdated_sinceonce (theserver_timeyou recorded at the start of the previous completed pass), walkmeta.next_cursoruntil it isnull, and only then record the newserver_timefor the next pass. Changingupdated_sinceor any filter between pages restarts the keyset and loses rows. See Pagination and incremental sync. - Store ULIDs. The
idof a company or contact is a 26-character ULID that never changes. Use it as your primary key and as the argument forfolovup_get_companyandcompany_idfilters. Keepfolovup_company_id/folovup_contact_idonly as secondary references. A contact'scompany_idcan change when the same address is re-discovered under another company, so never key contacts by(company, email). status: "completed"is not "success". A discovery that ran out of credits, hit its analysis budget, went dry, hit the round limit, or stopped after repeated technical errors still ends ascompleted; the reason is instop_reason/stop_explanation. Report partial results as partial.faileddiscoveries may havestop_reason: nullwith the reason only inerror_message.- Use
counts.companies_linked, notcompanies_created, to size the fetch. The historical counter can be higher than what you can actually retrieve. - Poll, do not spin. First poll after
meta.poll_afterseconds (10), then double the interval up to 60 seconds, and stop as soon asis_terminalistrue. A poll of a single-mode discovery may itself be what finalises it; that is expected. - What the API cannot do. There is no endpoint that sends email, edits a company or contact, deletes data, creates a key, or changes credits. FolovUp does not send email at all. If the human asks for any of these, say so instead of improvising.
- Never expose the key. The model must never see, echo, or write the API key. When reporting a problem, quote only the 20-character prefix (
pk_live_plus 12 characters) as shown inGET /me→api_key.prefix.
Worked agent loop
Pseudo-code for the common task "find companies matching a description, then hand back verified contacts". api.* are the eight tools above; approval is a logged human confirmation with a stable id.
me = api.folovup_me()
if "discovery:write" not in me.api_key.scopes:
stop("This key cannot start discoveries (missing scope discovery:write).")
if me.account.credits < 30:
stop("Balance is " + me.account.credits + " credits; at least 30 are needed.")
intent = { query: normalize(request.query), mode: "nonstop", target: 50 }
tell_human("Start a nonstop discovery for '" + intent.query + "' with target 50? "
"Maximum cost 30 + 2 x 50 = 130 credits; current balance " + me.account.credits + ".")
approval = wait_for_explicit_yes() # anything else -> stop, do not call the API
idem = "agent:" + sha256_hex(me.account.id + "|" + intent.query + "|" + intent.mode + "|" + intent.target + "|" + approval.id)
attempt = 0
loop:
resp = api.folovup_start_discovery(query=intent.query, mode=intent.mode,
target_company_count=intent.target,
idempotency_key=idem, confirmed_by_human=true)
if resp.status == 202: d = resp.data; wait = resp.meta.poll_after; break # 10 s
if resp.status == 200: d = resp.data; wait = 10; break # replay: already running
if resp.status == 409: sleep(5); attempt += 1; if attempt > 3: stop("still in progress"); continue
if resp.status == 402: stop("Insufficient credits (need >= 30).")
if resp.status == 422 and resp.error == "validation_failed": stop(resp.errors) # fix the named field
if resp.status == 422: stop(resp.error + ": " + resp.message) # discovery_failed, invalid_idempotency_key
if resp.status == 429: sleep(resp.headers["Retry-After"]); continue
if resp.status >= 500:
sleep(backoff()); inflight = api.folovup_list_discoveries(status="processing")
if any(x.query == intent.query for x in inflight.data): d = that; wait = 10; break
attempt += 1; if attempt > 2: stop("server error"); continue
while not d.is_terminal:
sleep(wait); wait = min(wait * 2, 60)
r = api.folovup_get_discovery(id=d.id)
if r.status == 429: sleep(r.headers["Retry-After"]); continue
d = r.data
if d.status == "failed":
stop("Discovery failed: " + (d.stop_explanation or d.error_message))
if d.status == "cancelled":
note("Cancelled; using the companies found so far.")
if d.stop_reason not in [null, "target_reached"]:
note("Stopped early: " + d.stop_explanation) # completed but partial
expected = d.counts.companies_linked
companies = []; cursor = null
loop:
page = api.folovup_list_companies(discovery_id=d.id, include_contacts=true, per_page=200, cursor=cursor)
companies += page.data
cursor = page.meta.next_cursor
if cursor == null: break
# len(companies) == expected unless something was deleted meanwhile
for c in companies:
store_company(key=c.id, folovup_id=c.folovup_company_id, name=c.name, website=c.website,
country=c.headquarter_country_iso, deleted=c.is_deleted)
for k in c.contacts:
store_contact(key=k.id, company_key=k.company_id, email=k.email,
is_valid=k.verification.is_valid, # true | false | null, kept as is
status=k.verification.status, deleted=k.is_deleted)
report(summary: expected, "verified" = count(is_valid == true),
"unknown" = count(is_valid == null), "rejected" = count(is_valid == false),
stop_reason=d.stop_reason, cost_estimate = 30 + 2 * d.counts.companies_created)
System prompt snippet
Paste this into the system prompt of an agent that has the eight tools. It restates the policy rules in the imperative form models follow best.
You can use the FolovUp Partner API through the folovup_* tools. Follow these rules without exception:
1. Never call folovup_start_discovery or folovup_cancel_discovery unless the user explicitly confirmed that exact action in this conversation. Starting a discovery spends credits from the account (30 to start plus 2 per company found); cancelling keeps the companies found so far and refunds nothing. Before asking for confirmation, call folovup_me and state the current credit balance and the maximum cost (30 + 2 x target).
2. When starting a discovery always pass idempotency_key = "agent:" + SHA-256 hex of account_id|normalized_query|mode|target|confirmation_id, and reuse the same key when retrying the same confirmed request. A 200 reply with meta.idempotent_replay=true means the discovery already exists: do not start another.
3. Poll with folovup_get_discovery every 10 seconds at first, then back off to at most 60 seconds, and stop as soon as is_terminal is true. status "completed" does not mean the target was reached: read stop_reason and stop_explanation and report partial results as partial. Use counts.companies_linked, not companies_created, as the number of companies you can fetch.
4. verification.is_valid has three values: true (mailbox confirmed), false (rejected), null (unknown or catch-all). Never present a null address as invalid or as verified. verified_only=true excludes null.
5. On HTTP 429 wait exactly Retry-After seconds before the next FolovUp call. Do not run parallel loops against the API.
6. When paginating, pass meta.next_cursor unchanged until it is null. Never change updated_since or any filter in the middle of a walk. Never construct or decode a cursor.
7. Identify companies and contacts by their id (26-character ULID). Rows with is_deleted=true are deleted: drop them.
8. This API cannot send email, edit or delete records, or change credits, and FolovUp itself never sends email. If asked, explain that it is not possible through this integration.
9. Never reveal the API key. If you must identify it, use only the 20-character prefix returned by folovup_me.
Loading the OpenAPI document into a tool-calling framework
- Fetch
https://folovup.com/en/developers/openapi.json(no key needed). It is OpenAPI 3.1.0; the base URL isservers[0].url=https://folovup.com/api/partner/v1. - Authentication is the
ApiKeyBearersecurity scheme (type: http,scheme: bearer), applied globally. Configure your framework to injectAuthorization: Bearer <key>from a secret store; never put the key in the spec or in generated tool descriptions. - Generate one tool per
(method, path)underpaths. Thirteen operations exist:GET /me,GET /openapi.json,GET /companies,GET /companies/{id},GET /contacts,GET /discoveries,GET /discoveries/{id},POST /discoveries,POST /discoveries/{id}/cancel,GET /webhook,PUT /webhook,DELETE /webhook,POST /webhook/test. Thewebhookstop-level section describes messages FolovUp sends to you; do not turn those into callable tools. - The required scope of an operation is written in that operation's
descriptiontext (Gerekli scope: \...``), not in a machine-readablesecurityscopes list. Compare it withapi_key.scopesfromGET /mebefore offering the tool to the model. - Operation summaries, descriptions and tag names in the document are in Turkish. For an English-speaking model, replace the generated descriptions with the ones in Tool definitions, or keep only the schemas from the document and use this guide for the text.
- Mark
POST /discoveriesandPOST /discoveries/{id}/cancelas destructive / confirmation-required in your framework's terms, and either hidePUT /webhook,DELETE /webhookandPOST /webhook/testfrom the model or gate them the same way: they change the account's delivery settings and need no extra scope. - Boolean query parameters are declared
type: boolean; your HTTP layer must serialise them as1/0, not as the wordstrue/false. - List responses are
{ "data": [...], "links": {...}, "meta": { "next_cursor": ... } }and single resources are{ "data": {...} }. Teach the framework to hand the modeldataplusmeta.next_cursor, and to keep the rest out of context.
MCP note
If you expose FolovUp to agents over the Model Context Protocol, wrap the eight tools above as MCP tools with the same names, descriptions and schemas; do not expose a generic "HTTP request" tool. Keep the API key in the MCP server's environment. Implement the three wrapper translations from Tool definitions inside the server (headers, 1/0 booleans, confirmed_by_human gate plus Idempotency-Key header). Flag folovup_start_discovery and folovup_cancel_discovery as non-read-only / destructive in your tool annotations so hosts prompt for confirmation, and flag the other six as read-only and idempotent. An MCP server usually cannot receive HTTP callbacks, so do not rely on Webhooks inside the agent; poll folovup_get_discovery instead. Webhooks remain useful for the backend that hosts the server.
Intent to call decision table
| The user wants to... | Call | Notes |
|---|---|---|
| know what this key can do / how many credits are left | folovup_me |
Read api_key.scopes, account.credits, rate_limit_per_minute. |
| list companies in a country or sector | folovup_list_companies(country=..., sector=...) |
sector is an exact match; walk next_cursor. |
| find a company by name or website | folovup_list_companies(search=...) |
Substring match. |
| see everything about one company, including its contacts | folovup_get_company(id) |
Contacts are always embedded. |
| get email addresses for a company | folovup_list_contacts(company_id=...) |
Or read contacts from folovup_get_company. Report is_valid per address. |
| get only verified addresses | folovup_list_contacts(verified_only=true) |
Explain that unknown/catch-all (null) addresses are excluded, not rejected. |
| mirror all data / sync since the last run | folovup_list_companies(updated_since=...) then folovup_list_contacts(updated_since=...), both with include_deleted=true |
One updated_since per pass; see Building a mirror (sync recipe). |
| find new companies that match a description | folovup_me → ask for confirmation → folovup_start_discovery |
Spends credits. Then poll and fetch as in the worked loop. |
| check whether a run is finished | folovup_get_discovery(id) |
Stop polling at is_terminal. |
| get the companies a run produced | folovup_list_companies(discovery_id=id, include_contacts=true) |
Expect counts.companies_linked rows. |
| stop a running run | ask for confirmation → folovup_cancel_discovery(id) |
Companies kept, no refund; 422 cancel_failed if already terminal. |
| see past runs / whether one is already running | folovup_list_discoveries(status=...) |
Includes runs started from the web app. |
| send an email to a contact | none | Not possible; FolovUp does not send email. Hand the address to the user's own system. |
| delete or edit a company or contact | none | Not possible through the API; only through the FolovUp web app by the account owner. |
| buy credits, create a key, change scopes | none | Not possible through the API; see Getting access. |
Error reference
Every error response from https://folovup.com/api/partner/v1/* is JSON with a stable error code and a human-readable message. Match on error (and the HTTP status); never match on message, which is Turkish for hand-built errors and English for validation errors and may change without notice. Some codes add one extra field, listed in the "Extra fields" column.
Consolidated error table
| HTTP | error |
Endpoints | Meaning | Extra fields | What to do |
|---|---|---|---|---|---|
| 401 | unauthenticated |
all | No Authorization: Bearer header, malformed key, unknown key, wrong secret, revoked key, or expired key. All cases return the same body. |
docs (URL of the developer page) |
Check the header format Authorization: Bearer pk_live_...; call GET /me; if the key was revoked or expired, obtain a new one (see Getting access). Do not retry automatically. |
| 402 | insufficient_credits |
POST /discoveries |
The owning account has fewer than 30 credits at request time. Nothing was started and the Idempotency-Key was released. |
— | Report the balance from GET /me; the account owner must add credits. Retry later with the same key. |
| 403 | insufficient_scope |
scoped endpoints | The key is active but lacks the scope this endpoint requires. | required (the missing scope, e.g. contacts:read) |
Do not retry. Compare with api_key.scopes from GET /me; request a key with the scope. |
| 403 | account_suspended |
all | The account that owns the key is suspended. Checked after authentication and before the scope check and the rate limiter: no X-RateLimit-* headers, no quota consumed. |
— | Stop calling. The account owner must contact Support. |
| 404 | not_found |
GET /companies/{id}, GET /discoveries/{id}, POST /discoveries/{id}/cancel |
The resource does not exist or belongs to another account (the two cases are indistinguishable by design). | — | Do not retry the same id. For a company, check that you used the ULID id or the numeric folovup_company_id. |
| 404 | not_found |
any unknown path under /api/partner/ |
No such route. Returned before authentication. | — | Fix the path. Note the base is /api/partner/v1. |
| 405 | method_not_allowed |
any known path | The HTTP method is not supported on this path. Returned before authentication, with an Allow header. |
— | Use one of the methods in Allow. |
| 409 | idempotency_in_progress |
POST /discoveries |
A request with the same Idempotency-Key is still being processed by this key. |
— | Wait a few seconds and retry with the same key. If the first request failed with an unexpected server error, the key has been released and the retry starts the run. |
| 422 | validation_failed |
GET /companies, GET /contacts, GET /discoveries, POST /discoveries, PUT /webhook |
A query parameter or body field failed validation. See Reading a validation_failed response. | errors (object: field → list of messages) |
Fix the named field. Do not retry unchanged. |
| 422 | invalid_cursor |
GET /companies, GET /contacts, GET /discoveries |
The cursor value cannot be decoded. Nothing was returned; the previous position is not lost. |
— | Re-send the last meta.next_cursor you received verbatim (check URL-encoding). If you no longer have it, restart the walk from the beginning with the same updated_since (Cursor mechanics). |
| 422 | invalid_idempotency_key |
POST /discoveries |
The Idempotency-Key header is longer than 128 bytes. Nothing was started. |
— | Shorten the key (hash it). |
| 422 | discovery_failed |
POST /discoveries |
The run could not be started for a reason other than credits (for example the owner account could not be loaded). The Idempotency-Key was released. |
— | Retry once after a short delay with the same key; if it persists, report the message to Support. |
| 422 | cancel_failed |
POST /discoveries/{id}/cancel |
The discovery is already terminal (completed, failed or cancelled), or it disappeared between lookup and update. Nothing was modified. |
— | Read the current state with GET /discoveries/{id}; there is nothing to cancel. |
| 422 | not_configured |
POST /webhook/test |
This key has no webhook endpoint registered. | — | PUT /webhook first. |
| 422 | webhook_inactive |
POST /webhook/test |
The key's webhook endpoint exists but has is_active: false. |
— | PUT /webhook with "is_active": true, then test again. |
| 429 | rate_limited |
all | The key exceeded its per-minute quota in the current 60-second window. | retry_after (integer seconds); header Retry-After |
Sleep Retry-After seconds, then continue. See Limits and quotas. |
| 500 | server_error |
all | Unexpected failure on FolovUp's side. No internal details are included. | — | Retry with exponential backoff. For POST /discoveries, retry with the same Idempotency-Key after checking GET /discoveries?status=processing for an already-started run. If it persists, report it to Support with the UTC timestamp and path. |
Example bodies as emitted on 2026-09-08 (field names and types are exact; message text may change):
{"error":"unauthenticated","message":"Geçerli bir API anahtarı gerekli.","docs":"https://folovup.com/en/developers"}
{"error":"insufficient_scope","message":"Bu anahtar bu işlem için yetkili değil.","required":"contacts:read"}
{"error":"rate_limited","message":"İstek sınırı aşıldı.","retry_after":60}
{"error":"account_suspended","message":"Bu anahtarın bağlı olduğu hesap askıya alınmış."}
{"error":"not_found","message":"Firma bulunamadı."}
{"error":"not_found","message":"Böyle bir uç yok. Doküman: https://folovup.com/en/developers"}
{"error":"method_not_allowed","message":"Bu uç bu HTTP metodunu desteklemiyor."}
{"error":"invalid_cursor","message":"cursor değeri çözümlenemedi. Yalnız bir önceki yanıttaki meta.next_cursor / meta.prev_cursor değerini gönderin."}
{"error":"cancel_failed","message":"Keşif zaten sonlanmış (status: completed)."}
{"error":"webhook_inactive","message":"Webhook ucu pasif (is_active=false); test teslimatı gönderilmez."}
{"error":"server_error","message":"Beklenmeyen bir hata oluştu; tekrar deneyin. Sürerse destek@folovup.com."}
The message of cancel_failed interpolates the discovery's current status (completed, failed or cancelled).
Reading a validation_failed response
Validation errors keep field-level detail next to the stable code; the shape (error, message, errors) and a full example are defined once in Error envelope.
errorsis an object whose keys are the offending parameter names; nested array items use dot notation, for exampleevents.0for the first element of theeventsarray inPUT /webhook.messageis the first error message, with(and N more errors)appended when several fields failed. Readerrors, notmessage.- Typical triggers:
per_pageabove the endpoint maximum,queryshorter than 10 or longer than 500 characters,target_company_countoutside 1–500 or not an integer,countrynot exactly 2 characters, a boolean query parameter sent astrue/falseinstead of1/0, anupdated_sincevalue that is not a recognised date-time, a webhookurlthat does not start withhttps://, an unknowneventsvalue. - A JSON body sent without
Content-Type: application/jsonis not parsed at all, so every body field is reported as missing ("The query field is required."). Fix the header, not the body. - For
POST /discoveries, body validation runs before theIdempotency-Keyis examined and before the key is claimed; a422never consumes the key.
Which responses consume rate-limit quota and carry X-RateLimit-* headers is defined once in Rate limiting: 401, both 403 codes, 429 and the pre-authentication 404/405 are free; every other response — 200, 202, 402, 404 from a known route, 409, 422, 500 — counts.
Limits and quotas
| Item | Value | Notes |
|---|---|---|
| Base URL | https://folovup.com/api/partner/v1 |
HTTPS only; plain http:// and www. are redirected with 301, which turns a POST into a GET. Never rely on the redirect. |
| API key format | pk_live_ + 12 lowercase characters + _ + 32 characters (53 characters total) |
Only the first 20 characters (api_key.prefix) may be quoted anywhere. |
| Rate limit per key | Set per key when it is issued: 60 requests/minute unless a different value was requested; configurable range 1–6000 | Read the effective value in GET /me → api_key.rate_limit_per_minute and in the X-RateLimit-Limit header. |
| Rate-limit window | Fixed 60 seconds, anchored at the first request of the window | The full quota returns at once when the window expires. No X-RateLimit-Reset header; on 429 use Retry-After. |
| Rate-limit scope | Per API key, not per account or IP | Several keys on one account have independent quotas. |
per_page — GET /companies |
default 100, maximum 200 | Above the maximum → 422 validation_failed. |
per_page — GET /contacts |
default 200, maximum 500 | |
per_page — GET /discoveries |
default 50, maximum 100 | |
Embedded contacts (include=contacts, GET /companies/{id}) |
no cap | Every default-visible contact of the company is embedded. |
Embedded companies (GET /discoveries/{id}?include=companies) |
no cap, no pagination | Prefer GET /companies?discovery_id=. |
updated_since |
any ISO-8601 date-time, YYYY-MM-DD HH:MM:SS (UTC) or YYYY-MM-DD (midnight UTC); compared in UTC, inclusive, 1-second resolution |
Unparseable → 422 validation_failed. |
search, sector |
at most 255 characters | |
country |
exactly 2 characters | Case-insensitive. |
company_id (contacts filter) |
at most 64 characters | ULID or numeric id. |
query (POST /discoveries) |
10–500 characters | |
mode |
single (default) or nonstop |
|
target_company_count |
1–500, nonstop only |
Accepted with single but stored as null. |
| Credits to start a discovery | balance of at least 30 at request time, else 402 |
|
| Cost of a discovery | 30 credits at start + 2 credits per company created | Charged to the key's owning account; nothing is charged for rejected candidates or when the query cannot be interpreted; no refund on cancel. |
Idempotency-Key |
at most 128 bytes (measured in bytes, not characters); remembered for 24 hours per API key | Longer → 422 invalid_idempotency_key. Released immediately if the start fails (402/422) or the server errors mid-request. |
| First poll hint | meta.poll_after = 10 seconds |
Then back off up to 60 seconds. |
Discovery runtime — single |
typically tens of minutes; observed average about 44 minutes, maximum about 6.6 hours | Completes on its own when every candidate is processed. |
Discovery runtime — nonstop |
hours; observed average about 5 hours, longest observed about 45 hours (figures from production data as of 2026-09-08) | Stops on target_reached, after 6 consecutive rounds without a new company (dry), after 50 rounds (max_rounds), when the analysis budget is spent (ai_limit), or when the owner's balance drops below 10 credits between rounds / below 2 credits per company (credits). |
| Discoveries per account | no documented limit | Each start needs the 30-credit balance check. |
| Webhook endpoints per key | 1 | PUT /webhook replaces the existing one. Several keys on one account can each have one. |
Webhook url |
at most 512 characters, must start with https:// |
Format-only validation; register the final URL (no redirects). |
Webhook events |
subset of discovery.completed, discovery.failed, discovery.cancelled; omitted on create = all three (and any event type added later); omitted on update = unchanged |
[] = subscribe to nothing. |
| Webhook secret | whsec_ + 40 characters (46 characters), returned only on create or rotate_secret: true |
|
| Signature tolerance | 300 seconds between the t in X-Folovup-Signature and your clock |
Enforced by you, not by FolovUp. |
| Delivery attempts | 4 HTTP attempts per delivery: immediately, then about +30 s, +120 s, +600 s after the previous failure (about T, T+30 s, T+150 s, T+750 s); give-up recorded about T+1350 s | Same X-Folovup-Delivery on every attempt; new t, new created_at, new signature. |
| Delivery HTTP timeout | 15 seconds total (8 seconds to connect) | Only a 2xx final response counts as success. |
| Delivery dedupe | one delivery per (event, discovery) within 1 hour | Dedupe on X-Folovup-Delivery, and on event + data.discovery.id. |
| Delivery ordering | not guaranteed | Retries of one delivery can interleave with newer deliveries; order by data.discovery.completed_at, not by arrival. |
| Timestamps | ISO-8601 with +00:00, second precision, UTC |
e.g. 2026-09-08T09:49:46+00:00. |
| Identifiers | ULID: 26 characters, Crockford base32, uppercase | e.g. 01J9Z0000000000000000000A1. |
| Response encoding | JSON, UTF-8, Content-Type: application/json; Accept is ignored |
API responses escape non-ASCII (\u015e) and slashes (\/) — decode normally. Only GET /openapi.json and webhook delivery bodies are emitted unescaped. |
| CORS | any Origin is allowed with credentials; exposed headers X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After, Link |
Calling from a browser exposes the key to the browser; use a backend. |
Data usage and compliance
Where the data comes from
- Companies are found through web search and public business directories (trade associations, chambers of commerce, industrial-zone member lists) in response to a query by the account owner. Company details and contact data are then extracted from each company's own public website (contact, about, imprint pages). FolovUp discovers business (B2B) company and corporate-contact data only; consumer or private-life data is not targeted.
- FolovUp is research-only. It does not send email, does not run campaigns, and does not automate outreach. The Partner API has no endpoint that sends a message. Any outreach happens through your own systems, under your own responsibility.
- Email addresses are checked by FolovUp's own SMTP-based verification system. The result is a status word (
verification.status) and a three-valuedis_valid; no third-party verification provider is named or exposed anywhere in the API. - FolovUp is a brand of Lutfios LLC (Wyoming, USA), which is the data controller for the data FolovUp holds. Data may be processed and stored on servers outside the country of the data subject, including in the USA.
- FolovUp relies on legitimate interest (GDPR art. 6(1)(f); KVKK art. 5(2)(f)) for processing public, business-purpose company and contact data for B2B market research. Results belong to the account that ran the discovery; FolovUp does not share one account's results with other accounts and does not sell them.
Partner obligations
Using the API makes you a separate controller of the copy you hold. You agree to the FolovUp terms (linked below) on behalf of the account that owns your key, and in particular you must:
- Have your own lawful basis for storing and using the data (KVKK, GDPR where applicable, and the e-commerce and unfair-competition rules that apply to you). Legitimate interest is FolovUp's basis for collection; it does not transfer to your downstream use automatically.
- Use the data only for legitimate B2B business purposes. Sending spam or unsolicited commercial electronic messages, profiling that harms data subjects, harassment, discrimination, or any use contrary to the permissions of the account owner is prohibited.
- Honour tombstones promptly. When a company or contact comes back with
is_deleted: true, delete it (or mark it deleted and stop using it) in every downstream system on your next sync. Because user-side company deletions leave no tombstone, run a periodic full walk and remove rows that no longer exist. See Building a mirror (sync recipe). - Do not resell, rent, sublicense or transfer the data to third parties. The licence covers the key owner's own internal business purposes only.
- Forward data-subject requests that concern FolovUp's copy to
kvkk@folovup.com, and honour requests that concern your copy within the statutory deadlines. - Store the API key securely. Keep it in a server-side secret store; never in browser or mobile code, source repositories, tickets or chat. Use one key per environment. If a key may have leaked, ask FolovUp to revoke it and issue a new one immediately (see Getting access); revocation is instant and irreversible. Treat the webhook secret the same way and rotate it with
rotate_secret: true. - Do not overload the service. Stay within your per-key rate limit and do not scrape the FolovUp web application; the API is the supported machine interface.
- Remember who pays. Every discovery you start is charged to the account that owns the key, and consumed credits are not refundable.
Legal pages
The binding documents are published on folovup.com in Turkish only; there is no English version. Machine translation is fine for orientation, but the Turkish text prevails.
| Document | URL |
|---|---|
| Terms of Service (Kullanım Koşulları) | https://folovup.com/kullanim-sartlari |
| Privacy Policy (Gizlilik Politikası) | https://folovup.com/gizlilik-politikasi |
| KVKK Notice and Explicit Consent (KVKK Aydınlatma Metni ve Açık Rıza Beyanı) | https://folovup.com/kvkk |
| Cookie Policy (Çerez Politikası) | https://folovup.com/cerez-politikasi |
| Distance Sales and Subscription Agreement (Mesafeli Satış ve Abonelik Sözleşmesi) | https://folovup.com/mesafeli-satis-sozlesmesi |
Contacts for legal matters: legal@folovup.com (contractual questions), kvkk@folovup.com (data-protection requests).
Versioning and changelog
Additive change policy
- The version is in the path:
/api/partner/v1. Everything in this guide describes v1. - Within v1, changes are additive only. FolovUp may, without a new version: add new endpoints; add optional query parameters, body fields and headers; add new fields to any response object or webhook payload; add new values to open enumerations (
stop_reason,error,source, webhookeventnames,entity_type); relax a validation rule; add newLinkrelations. - Your client must therefore: ignore unknown JSON fields; treat unknown enumeration values as "other" rather than failing; not depend on key order (except where a raw body is signed); not depend on
messagetext; and, if it subscribes to webhooks witheventsomitted, be ready to receive event types that did not exist when it was written (send an expliciteventslist to pin the set). - The seven
verification.statusliterals (valid,invalid,catch-all,unknown,spamtrap,abuse,do_not_mail) and the fourstatusvalues of a discovery (processing,completed,failed,cancelled) are closed sets and will not gain values within v1. - Anything that removes or renames a field, endpoint or parameter, changes a type, tightens validation on existing input, or changes the signature scheme is a breaking change and will be published under a new path version (
/api/partner/v2). No deprecation timeline for v1 is promised in advance; the changelog below is the record. - The OpenAPI document reports
info.version(currently1.0.0); compare it with the changelog below when you refresh a cached copy.
Changelog
2026-09-08 — v1.0.0 — initial public guide. This release publishes the guide, the public OpenAPI copy and the following improvements to the v1 surface (all additive; no field was removed or renamed):
updated_sinceaccepts any ISO-8601 form (Z,+00:00, other offsets, naive = UTC),YYYY-MM-DD HH:MM:SSandYYYY-MM-DD, and is compared in UTC. Unparseable values return422 validation_failedinstead of an empty page.- An undecodable
cursorreturns422 invalid_cursorinstead of silently restarting from the first page. - Every error carries a stable
errorcode. New codes:validation_failed(witherrors),not_foundfor unknown paths,method_not_allowed(withAllow),server_error,account_suspended,webhook_inactive. Error bodies document the optionalrequired,retry_after,docsanderrorsfields. POST /discoveries/{id}/cancelrefuses discoveries that are already terminal with422 cancel_failedand leaves them unchanged.- Webhooks: new event
discovery.cancelled;discovery.failednow fires on every failure path, including failures at start (query not interpretable, keyword generation, infrastructure error, insufficient credits at start). POST /webhook/testsends a real signed delivery regardless of theeventssubscription and returns422 webhook_inactivewhen the endpoint is deactivated.contact_countequals the number of contacts the API returns for the company by default (deleted contacts excluded).401bodies includedocs; every response carries theLinkheader (rel="service-desc",rel="service-doc"); CORS exposesX-RateLimit-Limit,X-RateLimit-Remaining,Retry-AfterandLink.- An
Idempotency-Keyclaimed by a request that fails with a server error is released immediately (no 24-hour409). - OpenAPI corrections:
{ "data": ... }wrapper on single resources,links+meta.pathon lists,GET /companies/{id}documented as always embeddingcontacts,social_media_linkstypedarray | object | null,stop_reasonenum includes the reserved valuewedged(not emitted today), unconfiguredGET /webhookandDELETE /webhookshapes,eventsomission semantics (create = all, update = unchanged), a top-levelwebhookssection,externalDocspointing to this guide. - A legacy
contact.sourcevalue is rewritten toemail_verification; known values arelead_discovery,ai_analysis,web_search,manual,linkedin_import,web_scraping,business_card,email_verification(open set).
Support
- Technical support:
destek@folovup.com. General questions and partnership enquiries:info@folovup.com. Support hours are Monday to Friday, 09:00–18:00 (UTC+3); replies usually arrive within one working day. - Keys are issued, revoked and re-issued by the FolovUp team on request — contact
destek@folovup.com(or your FolovUp contact); there is no self-service endpoint (see Getting access).
Include the following in a report so it can be investigated without a round trip:
| Item | Example | Why |
|---|---|---|
| Key prefix — never the full key | pk_live_abc123def456 |
Identifies the key; the first 20 characters are safe to share. |
| Request method and path, with query string | GET /api/partner/v1/contacts?company_id=01J9Z0000000000000000000A1&per_page=200 |
|
| UTC timestamp of the request | 2026-09-08T09:49:46+00:00 |
Logs are in UTC. |
| HTTP status and the full response body | 422 {"error":"invalid_cursor","message":"cursor değeri çözümlenemedi. …"} |
The error code is the key fact. |
X-RateLimit-Limit / X-RateLimit-Remaining / Retry-After |
60 / 0 / 41 |
For rate-limit questions. |
Discovery id |
733 |
For discovery questions. |
X-Folovup-Delivery (or body delivery_id) and X-Folovup-Event |
01J9Z0000000000000000000D1, discovery.completed |
For webhook questions. |
Output of GET /webhook |
last_failure_reason, consecutive_failures, last_failure_at |
Shows what FolovUp saw when delivering. |
| Your endpoint's response to the delivery | status code and time to respond | Only a 2xx within 15 seconds counts. |
Do not include the API key, the webhook secret, or personal data beyond the ids needed to reproduce the problem.
FAQ
Why does X-RateLimit-Remaining drop on a 404 or 422?
Because the quota unit is consumed when the request passes authentication and scope checks, before the endpoint runs. Only 401, 403 and 429 (and the pre-authentication 404/405 for unknown paths or methods) are free. Budget for your error responses too.
Is the rate-limit window sliding? Why is there no X-RateLimit-Reset?
It is a fixed 60-second window that starts at your first request in the window; when it expires the full quota is available again. There is no reset header. On 429 sleep exactly Retry-After seconds and continue; do not compute your own reset time.
I send valid JSON but get validation_failed saying every field is required.
The request body is only parsed as JSON when the Content-Type header contains application/json. Without it the body is invisible to validation. Add the header. Query-string booleans have a similar trap: send 1/0, not true/false.
Why does GET /companies/{id} include contacts but GET /companies does not?
The single-company endpoint always embeds the company's default-visible contacts. The list omits the contacts key entirely unless you pass include=contacts; an absent key is not an empty array. Code that reads company.contacts.length on list rows without include=contacts will fail.
My incremental walk of /companies never shows new or changed contacts.
Correct: a contact change does not update the parent company's updated_at. A mirror needs two incremental loops, one over /companies?updated_since=... and one over /contacts?updated_since=..., each with include_deleted=1. See Building a mirror (sync recipe).
I never receive a company tombstone, yet companies disappear.
When the account owner deletes a company in the FolovUp web app, the company and its contacts are removed outright; no is_deleted: true row appears in either feed. Tombstones exist for contacts deleted by the owner and for companies removed by FolovUp staff. To catch the rest, run a periodic full walk (no updated_since) and delete local rows whose id was not seen.
A contact's company_id changed since I imported it.
The same email address is stored once per account; when a later discovery finds it on another company's website the contact is re-attached to that company and its updated_at moves. Key your store on the contact id (ULID) and update company_id on each sync.
Why is verification.is_valid null for almost every contact, and does verified_only=1 drop them?
Most contacts have not been through verification yet, and a verified address on a catch-all domain is also null because the mailbox cannot be confirmed. null means "unknown", not "invalid". verified_only=1 returns only is_valid: true, so it removes null rows as well as false; say "unverified" for them, never "invalid". Store the field as a nullable boolean. The related flags is_role_email / is_free_email are derived from the address itself while the contact is unverified, so false means "not a role / free-mail address" in both states.
I received discovery.completed, but the run found far fewer companies than the target.
completed is the terminal state for every non-failure outcome: target_reached, dry (six rounds without a new company), ai_limit (analysis budget spent), credits, max_rounds, errors (repeated technical errors after at least one company), ai_provider_unavailable. Read stop_reason and show stop_explanation to the user. Only start-up failures and the zero-company error case end as failed.
My second POST /discoveries with the same Idempotency-Key returned the first run even though I changed the query.
Replay is decided by the key alone; the body is not compared. Derive the key from a hash of the intent (query, mode, target, confirmation id) so a different intent always gets a different key, and reuse the key only for retries of the same intent. Keys live 24 hours and are scoped to your API key.
The webhook signature does not verify, or the same X-Folovup-Delivery arrives with different signatures.
Verify the HMAC over the raw request bytes exactly as received; do not parse and re-serialise the JSON (FolovUp encodes with unescaped unicode and slashes, which most serialisers change). Each retry of the same delivery is re-signed with a fresh t and created_at, so different signatures for one X-Folovup-Delivery are normal; dedupe on the delivery id, and take the event time from data.discovery.completed_at. Tolerance is 300 seconds. See Webhooks.
POST /webhook/test returned 202, but nothing reached my endpoint.
The delivery is queued and sent shortly after; check GET /webhook for last_success_at / last_failure_reason. Common causes: the endpoint answers after the 15-second timeout, returns a non-2xx, or redirects (a 301/302 turns the POST into a body-less GET). If the endpoint is deactivated the test now returns 422 webhook_inactive instead of a silent drop, and 422 not_configured if no endpoint is registered.
Which discovery numbers can I trust? progress is 101, or 94 on a finished run; companies_created is 53 but companies_linked is 0.
progress is advisory and not clamped; use is_terminal to decide whether a run is over. companies_created is the historical counter kept while the run executed; companies_linked is the number of non-deleted companies you can fetch right now with GET /companies?discovery_id=. Runs from before June 2026 have a counter but no linked companies. Size your fetch by companies_linked.
Can I call the API directly from a browser?
Technically yes: any Origin is allowed, and X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After and Link are exposed to scripts. Practically no: the API key would be visible to every user of the page. Keep the key on a server and proxy the calls.
I passed target_company_count with mode=single and the response shows null.
target_company_count applies to nonstop only. With single it passes validation but is not stored; a single run processes one search round and completes on its own. Use nonstop when a target matters.