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:

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

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:

  1. 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.
  2. The scopes you need, from companies:read, contacts:read, discovery:read, discovery:write. Say explicitly if you need discovery:write: it spends credits and is not included unless requested.
  3. 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.
  4. Whether the key should expire (a date, UTC) or stay valid until revoked.
  5. 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

Key lifecycle

Storing and handling the key

Authentication

Authorization header

Every request carries the key as a bearer token:

Authorization: Bearer pk_live_abc123def456_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Rules:

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:

{
  "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

Request and response format

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:

Null, empty and absent values

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.

  1. Keep your own token bucket at the key's rate_limit_per_minute (read it from GET /me at startup) and never exceed it from your side. Leave headroom (for example 80 %) if more than one process shares the key.
  2. On 429: sleep max(1, Retry-After) + random(0, 1) seconds, then retry the same request. Do not count the 429 as an attempt against a retry budget — it is not an error, it is scheduling.
  3. On 500, 502, 503, 504 or a network timeout: retry the same request with exponential backoff 1 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 /discoveries is safe to retry only with the same Idempotency-Key.
  4. On 401, 403 and any other 4xx: stop; these do not fix themselves.
  5. If X-RateLimit-Remaining reaches 0 and you have no Retry-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

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:

  1. Inclusive. If you store the largest updated_at you 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.
  2. UTC, one-second resolution. Fractional seconds are accepted but rows are compared at whole seconds.
  3. Advance it only after a complete walk. While links.next is non-null you are inside one consistent walk; change updated_since only when next_cursor came back null. Advancing it mid-walk permanently loses the pages you had not fetched yet.
  4. Never a silent empty page. A value the server cannot parse is a 422, not an empty result.
  5. Combine with include_deleted=1 in incremental walks. Deletions are ordinary updates (updated_at moves, is_deleted becomes true); 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:

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

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:

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:

  1. Key companies on Company.id and contacts on Contact.id (the ULIDs). Upserts must be idempotent — the same row will be delivered more than once.
  2. Sync companies and contacts as two independent streams. A change to a contact never bumps its company's updated_at, so GET /companies?updated_since= will not tell you about new, verified, moved or deleted contacts.
  3. Always pass include_deleted=1 on incremental passes so tombstones arrive; apply is_deleted === true → mark deleted locally. Never look at deleted_at.
  4. Some deletions have no tombstone (owner-side company deletion) and some changes have no updated_at bump (discovery_id becoming null). A periodic full sweep is part of the design, not an optional extra.
  5. Never advance updated_since in the middle of a pass. Persist the cursor between pages; persist the new updated_since only after next_cursor came back null.

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:

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):

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.

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

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:

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:

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

  1. Wait meta.poll_after seconds (currently always 10) after the 202.
  2. GET /discoveries/{id}. If data.is_terminal is true, stop. Otherwise wait and repeat.
  3. Back off: double the interval after each non-terminal answer, capped at 60 seconds for single and 300 seconds for nonstop (a recommendation, not a server rule). On 429, sleep for the Retry-After value and continue.
  4. When a webhook delivery arrives (discovery.completed, discovery.failed or discovery.cancelled), make one final GET /discoveries/{id} and stop polling. Delivery is not guaranteed; polling stays the source of truth.

Two facts about the show endpoint matter here:

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:

  1. Body validation. Failure → 422 validation_failed. The key is not claimed.
  2. Key length check. Longer than 128 bytes → 422 invalid_idempotency_key. Not claimed.
  3. 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 → 200 replay (see below).
  4. Credit pre-check. account.credits < 30 → 402 insufficient_credits. The key is released, so a retry with the same key after topping up works.
  5. 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 stuck 409 for 24 hours).

Idempotency-Key semantics:

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:

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:

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:

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:

  1. Delivery level (mandatory): record X-Folovup-Delivery in a durable store with a unique constraint before you perform side effects; if the id is already present, answer 200 and do nothing. The header and the body field always carry the same value; either one is the key.
  2. 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

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:

  1. 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.
  2. Call PUT /webhook with rotate_secret: true. Send url too — it is required on every PUT (send the same value). Do not send events/is_active unless you want to change them; omitted fields keep their values.
  3. Store the secret from the response as the next secret and make it live in your endpoint. From the instant the PUT returned, 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.
  4. One minute later, drop the old secret. Anything still signed with it is a forgery.
  5. Note that the PUT also reset consecutive_failures to 0.

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

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.

  1. 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"}'
  1. Trigger a test delivery. It is sent whatever your events list says (you can test before subscribing); it is refused only when no endpoint exists (not_configured) or the endpoint is is_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ı."}
  1. Within a few seconds your endpoint receives a POST whose X-Folovup-Delivery equals the delivery_id above, X-Folovup-Event: discovery.completed, and body data of {"test":true,"discovery":{"id":0,"status":"completed","is_terminal":true}}. Verify the signature with the secret from step 1 and answer 200.

  2. 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.

  1. 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:

  1. Add Authorization: Bearer <API key> and Accept: application/json to every request; add Content-Type: application/json to every request with a body. The key must live in the wrapper's environment, never in the model's context.
  2. Send booleans on the query string as 1 / 0 (the API rejects the strings true / false in query parameters with 422 validation_failed). Map include_contacts: true to include=contacts and include_companies: true to include=companies.
  3. Refuse to call POST /discoveries and POST /discoveries/{id}/cancel unless confirmed_by_human is true, and always pass the model-supplied idempotency_key as the Idempotency-Key header.

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.

  1. Never start or cancel a discovery without explicit human confirmation. POST /discoveries spends 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.
  2. 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. A 200 with meta.idempotent_replay: true means "already started"; treat it as success and do not start another run.
  3. Treat verification.is_valid as three-valued. true = mailbox confirmed, false = rejected, null = unknown or catch-all domain. Store it as a nullable boolean; never coerce null to false, never describe a null address as "verified" or "invalid". verified_only=1 removes the null rows, which today is almost all of them. See Contacts.
  4. Respect 429 and Retry-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. On 429 sleep for exactly the Retry-After seconds (also in the body as retry_after), then continue. There is no X-RateLimit-Reset header. Do not run concurrent loops against the same key: a burst at the window boundary can be accepted slightly beyond the limit, and X-RateLimit-Remaining (clamped at 0) will not show it. Every request that passes authentication, the suspension check and the scope check counts, including ones that end in 404 or 422.
  5. Never advance updated_since in the middle of a pass. Pick updated_since once (the server_time you recorded at the start of the previous completed pass), walk meta.next_cursor until it is null, and only then record the new server_time for the next pass. Changing updated_since or any filter between pages restarts the keyset and loses rows. See Pagination and incremental sync.
  6. Store ULIDs. The id of a company or contact is a 26-character ULID that never changes. Use it as your primary key and as the argument for folovup_get_company and company_id filters. Keep folovup_company_id / folovup_contact_id only as secondary references. A contact's company_id can change when the same address is re-discovered under another company, so never key contacts by (company, email).
  7. 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 as completed; the reason is in stop_reason / stop_explanation. Report partial results as partial. failed discoveries may have stop_reason: null with the reason only in error_message.
  8. Use counts.companies_linked, not companies_created, to size the fetch. The historical counter can be higher than what you can actually retrieve.
  9. Poll, do not spin. First poll after meta.poll_after seconds (10), then double the interval up to 60 seconds, and stop as soon as is_terminal is true. A poll of a single-mode discovery may itself be what finalises it; that is expected.
  10. 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.
  11. 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 in GET /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

  1. Fetch https://folovup.com/en/developers/openapi.json (no key needed). It is OpenAPI 3.1.0; the base URL is servers[0].url = https://folovup.com/api/partner/v1.
  2. Authentication is the ApiKeyBearer security scheme (type: http, scheme: bearer), applied globally. Configure your framework to inject Authorization: Bearer <key> from a secret store; never put the key in the spec or in generated tool descriptions.
  3. Generate one tool per (method, path) under paths. 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. The webhooks top-level section describes messages FolovUp sends to you; do not turn those into callable tools.
  4. The required scope of an operation is written in that operation's description text (Gerekli scope: \...``), not in a machine-readable security scopes list. Compare it with api_key.scopes from GET /me before offering the tool to the model.
  5. 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.
  6. Mark POST /discoveries and POST /discoveries/{id}/cancel as destructive / confirmation-required in your framework's terms, and either hide PUT /webhook, DELETE /webhook and POST /webhook/test from the model or gate them the same way: they change the account's delivery settings and need no extra scope.
  7. Boolean query parameters are declared type: boolean; your HTTP layer must serialise them as 1 / 0, not as the words true / false.
  8. List responses are { "data": [...], "links": {...}, "meta": { "next_cursor": ... } } and single resources are { "data": {...} }. Teach the framework to hand the model data plus meta.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.

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

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:

  1. 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.
  2. 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.
  3. 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).
  4. Do not resell, rent, sublicense or transfer the data to third parties. The licence covers the key owner's own internal business purposes only.
  5. Forward data-subject requests that concern FolovUp's copy to kvkk@folovup.com, and honour requests that concern your copy within the statutory deadlines.
  6. 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.
  7. 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.
  8. Remember who pays. Every discovery you start is charged to the account that owns the key, and consumed credits are not refundable.

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

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):

Support

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.