# Funnelfeedr API > Funnelfeedr is a B2B prospecting platform with Nordic organization and contact data. The Funnelfeedr API is a REST API at https://api.funnelfeedr.com/external/v1, authenticated with an account-level API key, for matching organizations and people, reading organizations' target-role contacts and switchboards, and revealing organization and contact details for credits from your own tools and agents. Every lookup and reveal takes an array of 1-25 items and answers per item. Errors are RFC 9457 problem+json with a stable `code`. Credits are spent only by reveals: an organization's details, and a contact's email and phone. ## Instructions Rules for AI agents calling the Funnelfeedr API on a user's behalf: 1. Only reveals spend credits: `POST /contacts/reveal`, and `POST /organizations/match`, `POST /people/match` or `POST /organizations/switchboards/match` with `reveal` set and `dryRun` false. Matching, contacts, switchboards and the balance are free. 2. Before any reveal, send the same request with `dryRun: true`, show the user the quoted cost (`creditsQuoted`), and reveal only after they approve it. 3. Always set `maxCredits` on a reveal to the amount the user approved. A request that would cost more fails with `credit_cap_exceeded` and spends nothing. 4. Always send an `Idempotency-Key` header (a new UUID per logical request) on a call that spends, and on every `POST /contacts/reveal`, dry runs included. Reuse the same key, with the same body, only to retry that same request. 5. Never loop reveals one item at a time and never reveal the same contact or organization twice. Batch up to 25 items in one request. 6. On `402 insufficient_credits`, stop and tell the user. Nothing was spent; do not retry until they have topped up. 7. On an `ambiguous` match, ask the user which candidate they mean, or pick one by `id` from your own data. Never reveal every candidate. 8. On `429 rate_limited`, wait the number of seconds in `Retry-After` before retrying. Retry other errors only when the body says `"retryable": true`. 9. Read the key from the `FUNNELFEEDR_API_KEY` environment variable. Never print, log or commit it, and never send it from a browser or mobile app. --- Source: https://funnelfeedr.com/developers # Funnelfeedr API > Match organizations and people against Funnelfeedr's Nordic B2B data, find the right contacts and switchboards, and reveal organization and contact details from your own systems and AI agents, with an account-level API key. The Funnelfeedr API is a REST API for prospecting. Use it to enrich a CRM, clean an import, feed a lead pipeline or give your own AI agent access to Funnelfeedr's organization and contact data. ```text https://api.funnelfeedr.com/external/v1 ``` ## Start with your AI agent Building with Claude Code, Cursor, Codex or another agent? Send it this message. It points the agent to the docs and the credit rules, and it reads the rest on its own. ```text I want to use the Funnelfeedr API. Start by reading https://funnelfeedr.com/developers/llms.txt: it links every docs page and the OpenAPI spec, and lists the credit rules you must follow before any call that spends credits. ``` ## What you can do | Method and path | Cost | What it does | |---|---|---| | `POST /organizations/match` | Free; details 0.2 credits per organization with `reveal` | Find organizations by Funnelfeedr id, org number, LinkedIn page or domain. The basics are free; can reveal the details (size, revenue, website, description, address and more) in the same call. | | `POST /organizations/contacts/match` | Free | The people at each organization who match your account's target contact roles, email and phone masked as in the app. | | `POST /organizations/switchboards/match` | Free, or credits if you reveal | Each organization's switchboard — its main phone line rather than a person. Can reveal its number and email in the same call. | | `POST /people/match` | Free, or credits if you reveal | Find a person by LinkedIn URL, email, or name + organization. Can reveal in the same call. | | `POST /contacts/reveal` | Credits | Reveal email and phone for contacts you already have ids for. | | `GET /credits` | Free | The account's credit balance. | Every path above is relative to the base URL. The [API reference](https://funnelfeedr.com/developers/api-reference.md) has every field and an example for every outcome. ## How it works - **One key per integration, owned by the account.** An admin creates keys in Funnelfeedr under **Settings → API keys**. A key keeps working when the person who made it leaves, and it can call every endpoint, spending ones included. See [Authentication](https://funnelfeedr.com/developers/authentication.md). - **Batches, not single calls.** Every lookup and reveal takes an array of 1–25 items and answers each one separately, in order. See [Batching](https://funnelfeedr.com/developers/batching.md). - **Only reveals cost credits.** Matching, contacts, switchboards and the balance are free. A reveal — an organization's details, a contact's email and phone, a switchboard's number or email — is quoted exactly with `dryRun`, capped with `maxCredits`, and charged all-or-nothing. See [Credits & reveal](https://funnelfeedr.com/developers/credits-and-reveal.md). - **Predictable errors.** Every error is `application/problem+json` with a stable `code` and a `retryable` flag. See [Errors](https://funnelfeedr.com/developers/errors.md). ## Getting access API access is switched on per account. Ask for access from **Settings → API keys** in the app, or email [support@funnelfeedr.com](mailto:support@funnelfeedr.com). Then follow the [Quickstart](https://funnelfeedr.com/developers/quickstart.md). ## Built for AI agents These docs are written to be read by agents as well as people: - [/developers/llms.txt](https://funnelfeedr.com/developers/llms.txt) indexes every page, with the credit-safety rules agents must follow; [/developers/llms-full.txt](https://funnelfeedr.com/developers/llms-full.txt) is all of it in one file. - Every page has a markdown version: add `.md` to its URL, or request it with `Accept: text/markdown`. - The [OpenAPI spec](https://funnelfeedr.com/developers/openapi.json) uses verb_noun `operationId`s such as `match_people`, so they work as tool names. - [Agent guidance](https://funnelfeedr.com/developers/agent-guidance.md) lists the rules for an agent that spends a customer's credits, and the [Quickstart](https://funnelfeedr.com/developers/quickstart.md#tell-your-agent) has a block to paste into the prompt you give your agent. > **API or MCP?** > > Funnelfeedr also has an [MCP server](https://funnelfeedr.com/support/mcp-overview) for Claude, ChatGPT and other assistants. It signs in as a person with OAuth and sees what that person sees. The API is for systems: it uses an API key, not a login, and API keys do not work with the MCP server. --- Source: https://funnelfeedr.com/developers/quickstart # Quickstart > Create an API key, check your credit balance, match organizations and a person, and reveal contact details safely — in a few curl calls. This takes about five minutes. You need a Funnelfeedr account with API access and an admin who can create keys. ## 1. Get a key 1. Make sure the API is enabled for your account. If **Settings → API keys** in [the app](https://app.funnelfeedr.com) shows a request-access screen, request it there; we enable it per account. 2. In **Settings → API keys**, choose **Create key**. Name it after the integration ("CRM sync", "Lead agent"). Every key can call every endpoint, including the ones that spend credits. 3. Copy the key. It is shown once; Funnelfeedr stores only a hash of it. 4. Put it in an environment variable on the machine that will call the API: ```bash export FUNNELFEEDR_API_KEY="ff_live_..." ``` Keep the key server-side. It must never reach a browser, a mobile app or a git repository — see [Authentication](https://funnelfeedr.com/developers/authentication.md). ## 2. Make your first call Check the account's credit balance. It is free. ```bash curl https://api.funnelfeedr.com/external/v1/credits \ -H "Authorization: Bearer $FUNNELFEEDR_API_KEY" ``` ```json { "included": 300, "purchased": 112.4, "total": 412.4 } ``` `included` is the plan's recurring allowance and is spent first; `purchased` credits do not reset; `total` is what a reveal can spend right now. The same total comes back in the `Funnelfeedr-Credits-Remaining` header. A `401` means the key is wrong; a `403` with `feature_not_enabled` means API access is not switched on yet. See [Errors](https://funnelfeedr.com/developers/errors.md). ## 3. Match organizations Look organizations up by domain, org number, LinkedIn page or Funnelfeedr id. Matching is free. `ref` is yours: it comes back unchanged so you can line results up with your own rows. ```bash curl https://api.funnelfeedr.com/external/v1/organizations/match -H "Authorization: Bearer $FUNNELFEEDR_API_KEY" -H "Content-Type: application/json" -d '{ "items": [ { "ref": "crm-1001", "domain": "nordviklogistik.se" }, { "ref": "crm-1002", "orgNumber": "5590123456", "countryCode": "SE" }, { "ref": "crm-1003", "domain": "unknown-startup.se" } ] }' ``` ```json { "results": [ { "ref": "crm-1001", "status": "matched", "matchedBy": "domain", "organization": { "id": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "name": "Nordvik Logistik AB", "detailsRevealed": false, "countryCode": "SE", "domain": "nordviklogistik.se", "city": "Göteborg", "legalForm": "Aktiebolag", "foundedYear": 2004, "url": "https://app.funnelfeedr.com/organizations/6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21" } }, { "ref": "crm-1002", "status": "matched", "matchedBy": "orgNumber", "organization": { "id": "2b7e1c55-0d3a-4f7e-9c1b-8a6d5e4f3a21", "name": "Fjällräven Frakt AB", "detailsRevealed": false, "organizationNumber": "559012-3456", "countryCode": "SE", "city": "Stockholm", "legalForm": "Aktiebolag", "foundedYear": 2017, "url": "https://app.funnelfeedr.com/organizations/2b7e1c55-0d3a-4f7e-9c1b-8a6d5e4f3a21" } }, { "ref": "crm-1003", "status": "not_found" } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 412.4, "dryRun": false } ``` A match returns the basics for free: name, country, city, legal form, founding year, the link to the organization in the app, and the identifier you matched by. The details — size, revenue, website, description, address and more — cost 0.2 credits per organization by default; add `"reveal": ["details"]` to reveal them in the same call (see [Matching](https://funnelfeedr.com/developers/matching.md#basics-and-details)). An organization Funnelfeedr does not know is `not_found`, for free. (A field with no value is left out of the response, as in these examples; only `nextCursor` and `creditsRemaining` are sent as `null`.) ## 4. Match a person, as a dry run Find a person by name at an organization and ask what revealing their email and phone would cost. `dryRun: true` spends nothing and needs no `Idempotency-Key`. ```bash curl https://api.funnelfeedr.com/external/v1/people/match -H "Authorization: Bearer $FUNNELFEEDR_API_KEY" -H "Content-Type: application/json" -d '{ "items": [ { "ref": "lead-42", "name": "Anna Svensson", "organization": { "domain": "nordviklogistik.se" } } ], "reveal": ["email", "phone"], "dryRun": true }' ``` ```json { "results": [ { "ref": "lead-42", "status": "matched", "matchedBy": "name", "contact": { "id": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "name": "Anna Svensson-Berg", "jobTitle": "Head of Procurement", "isExecutive": false, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "XXXXX@nordviklogistik.se", "emailIsMasked": true, "phone": "+46701XXXXXX", "phoneIsMasked": true, "linkedInUrl": "https://linkedin.com/in/anna-svensson-berg" }, "reveal": { "creditCost": 1.2, "newInfoTypes": ["email", "phone"], "alreadyRevealedInfoTypes": [], "unavailableInfoTypes": [] } } ], "creditsCharged": 0, "creditsQuoted": 1.2, "creditsRemaining": 412.4, "dryRun": true } ``` `Anna Svensson` matched `Anna Svensson-Berg`: a hyphenated surname matches either half. The quote is exact: the real call charges `creditsQuoted`, or less if someone in your account revealed the same details in the meantime. ## 5. Reveal Send the same request with `dryRun: false`, `maxCredits` set to the quote you accepted and a new `Idempotency-Key`. The key makes a retry safe: if the connection drops, send the identical request with the same key and you get the first answer back instead of a second charge. ```bash curl https://api.funnelfeedr.com/external/v1/people/match -H "Authorization: Bearer $FUNNELFEEDR_API_KEY" -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" -d '{ "items": [ { "ref": "lead-42", "name": "Anna Svensson", "organization": { "domain": "nordviklogistik.se" } } ], "reveal": ["email", "phone"], "maxCredits": 1.2, "dryRun": false }' ``` ```json { "results": [ { "ref": "lead-42", "status": "matched", "matchedBy": "name", "contact": { "id": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "name": "Anna Svensson-Berg", "jobTitle": "Head of Procurement", "isExecutive": false, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "anna.svensson-berg@nordviklogistik.se", "emailIsMasked": false, "phone": "+46701234567", "phoneIsMasked": false, "linkedInUrl": "https://linkedin.com/in/anna-svensson-berg" }, "reveal": { "creditCost": 1.2, "newInfoTypes": ["email", "phone"], "alreadyRevealedInfoTypes": [], "unavailableInfoTypes": [] } } ], "creditsCharged": 1.2, "creditsQuoted": 1.2, "creditsRemaining": 411.2, "dryRun": false } ``` The revealed details stay revealed for your whole account for a year; asking for them again costs nothing. No `uuidgen` on Windows? In PowerShell use `[guid]::NewGuid()`. ## Tell your agent If an AI agent — Claude Code, Cursor, Codex or your own — will call the API, paste this into the prompt you give it: its system prompt or instructions. It gives the agent the base URL, where the key is, the credit rules and where to read more on every run. ```markdown ## Funnelfeedr API - Base URL: https://api.funnelfeedr.com/external/v1 - Auth: `Authorization: Bearer $FUNNELFEEDR_API_KEY` (the key is in the environment; never print or commit it) - Docs for agents: https://funnelfeedr.com/developers/llms.txt (everything in one file: https://funnelfeedr.com/developers/llms-full.txt) - OpenAPI spec: https://funnelfeedr.com/developers/openapi.json - Every lookup takes `items`, an array of 1-25 objects, and answers per item in the same order. ### Credit rules 1. Only reveals spend credits: `POST /contacts/reveal`, and `POST /organizations/match`, `POST /people/match` or `POST /organizations/switchboards/match` with `reveal` set and `dryRun` false. Matching, contacts, switchboards and the balance are free. 2. Before any reveal, send the same request with `dryRun: true`, show the user the quoted cost (`creditsQuoted`), and reveal only after they approve it. 3. Always set `maxCredits` on a reveal to the amount the user approved. A request that would cost more fails with `credit_cap_exceeded` and spends nothing. 4. Always send an `Idempotency-Key` header (a new UUID per logical request) on a call that spends, and on every `POST /contacts/reveal`, dry runs included. Reuse the same key, with the same body, only to retry that same request. 5. Never loop reveals one item at a time and never reveal the same contact or organization twice. Batch up to 25 items in one request. 6. On `402 insufficient_credits`, stop and tell the user. Nothing was spent; do not retry until they have topped up. 7. On an `ambiguous` match, ask the user which candidate they mean, or pick one by `id` from your own data. Never reveal every candidate. 8. On `429 rate_limited`, wait the number of seconds in `Retry-After` before retrying. Retry other errors only when the body says `"retryable": true`. 9. Read the key from the `FUNNELFEEDR_API_KEY` environment variable. Never print, log or commit it, and never send it from a browser or mobile app. ``` ## Next steps - [Matching](https://funnelfeedr.com/developers/matching.md): every status `organizations/match`, `organizations/switchboards/match` and `people/match` can return, and how `organizations/contacts/match` picks contacts, with full examples. - [Credits & reveal](https://funnelfeedr.com/developers/credits-and-reveal.md): what costs what and how the cap works. - [Agent guidance](https://funnelfeedr.com/developers/agent-guidance.md): the rules behind the snippet above. - [API reference](https://funnelfeedr.com/developers/api-reference.md): every endpoint and field. --- Source: https://funnelfeedr.com/developers/authentication # Authentication > API keys belong to the account, can call every endpoint including the ones that spend credits, and are sent as a bearer token. How to create, roll and revoke them, and where they must never go. Every request carries an API key in the `Authorization` header: ```http GET /external/v1/credits HTTP/1.1 Host: api.funnelfeedr.com Authorization: Bearer ff_live_k2Xq9TbW4mNv7RcL1sYd8HpJ3fGa6ZeU0x4QwM ``` The key authenticates the API under `/external/v1` only. It does not log in to the app, and it does not work with the [MCP server](https://funnelfeedr.com/support/mcp-overview), which signs in per person with OAuth. ## Keys belong to the account An API key is a service credential for your **account**, not for a person. It keeps working when the admin who created it leaves, and it never acts as any user: - **What it can read.** Funnelfeedr's shared organization and contact data, as your account sees it in the app: your target contact roles decide which contacts `organizations/contacts/match` returns, and what your account has revealed comes back unmasked. It never sees another account's data. - **What it changes.** Nothing in your account except what a reveal unmasks. There is no endpoint that creates or edits records. - **What it spends.** Reveals are charged to the account's credits, like a reveal in the app, and unmask the details for everyone in the account. Every key can call every endpoint, the ones that spend credits included. There is no read-only key and no per-key spending limit: what limits a single request is the `maxCredits` it carries (see [Credits & reveal](https://funnelfeedr.com/developers/credits-and-reveal.md)), and what limits a key is the account's balance. Only admins can create, roll and revoke keys. ## Key format ```text ff_live_ + 32 random characters + 6-character checksum ``` A key is 46 characters, letters and digits after the prefix, and always starts with `ff_live_`. The last six characters are a checksum of the random part, so a mistyped key is rejected straight away and secret scanners can spot a leaked key without false positives. There is no test mode in v1: every key is a live key against your real data and credits. The app shows each key by its first characters (for example `ff_live_k2Xq`) so you can tell keys apart. The full key is shown once, when you create it. Funnelfeedr stores only a SHA-256 hash and cannot show it again; if you lose it, roll it. ## Create, roll and revoke All three are in **Settings → API keys**. - **Create.** Name the key after the integration that will use it. Copy the key from the confirmation; it is not shown again. - **Roll.** Issues a new key with the same name, and lets the old key keep working for a period you choose, from immediately up to 7 days. Deploy the new key within that window. When it ends, the old key answers `401 api_key_expired`. - **Revoke.** Stops the key immediately. It answers `401 api_key_revoked` from then on. Revoke straight away if a key may have leaked, then create a new one. Each key also has a request log in the app — method, path, status, duration, items and credits — for the last 30 days. It is the place to see what an integration has been doing and what it spent. ## Keep keys secret A key can do everything the API allows, including spending your account's credits. Treat it like a password. - **Keep it server-side.** Call the API from a backend, a job, a serverless function or an agent's runtime — never from browser JavaScript, a mobile app or a desktop app you ship. Anything sent to a client can be extracted. - **Read it from the environment** (`FUNNELFEEDR_API_KEY` by convention) or a secrets manager. Never hard-code it or commit it; keep `.env` files out of git. - **Never put it in a URL**, a log line, an error report or a prompt you share. - **One key per integration**, so you can revoke one without breaking the others, and the request log tells you which one did what. ## Authentication errors | Status | `code` | Meaning | |---|---|---| | 401 | `invalid_api_key` | Missing, malformed or unknown key. | | 401 | `api_key_revoked` | The key was revoked. | | 401 | `api_key_expired` | The key was rolled and its overlap ended. | | 403 | `feature_not_enabled` | API access is not enabled for the account. | None of them are retryable: fix the key, then try again. See [Errors](https://funnelfeedr.com/developers/errors.md) for the response format. --- Source: https://funnelfeedr.com/developers/agent-guidance # Guidance for AI agents > Rules for an AI agent that calls the Funnelfeedr API on someone's behalf — which calls spend credits, how to quote before revealing, and how to stop safely. Every API key can spend credits, so an agent with a key can turn a customer's credits into organization and contact details. Credits are money, and a reveal cannot be undone. This page is the contract an agent should follow; the same rules are in [/developers/llms.txt](https://funnelfeedr.com/developers/llms.txt) and in the [snippet for your agent's prompt](https://funnelfeedr.com/developers/quickstart.md#tell-your-agent). ## The rules 1. Only reveals spend credits: `POST /contacts/reveal`, and `POST /organizations/match`, `POST /people/match` or `POST /organizations/switchboards/match` with `reveal` set and `dryRun` false. Matching, contacts, switchboards and the balance are free. 2. Before any reveal, send the same request with `dryRun: true`, show the user the quoted cost (`creditsQuoted`), and reveal only after they approve it. 3. Always set `maxCredits` on a reveal to the amount the user approved. A request that would cost more fails with `credit_cap_exceeded` and spends nothing. 4. Always send an `Idempotency-Key` header (a new UUID per logical request) on a call that spends, and on every `POST /contacts/reveal`, dry runs included. Reuse the same key, with the same body, only to retry that same request. 5. Never loop reveals one item at a time and never reveal the same contact or organization twice. Batch up to 25 items in one request. 6. On `402 insufficient_credits`, stop and tell the user. Nothing was spent; do not retry until they have topped up. 7. On an `ambiguous` match, ask the user which candidate they mean, or pick one by `id` from your own data. Never reveal every candidate. 8. On `429 rate_limited`, wait the number of seconds in `Retry-After` before retrying. Retry other errors only when the body says `"retryable": true`. 9. Read the key from the `FUNNELFEEDR_API_KEY` environment variable. Never print, log or commit it, and never send it from a browser or mobile app. ## Which calls spend credits | Call | Spends? | |---|---| | `POST /organizations/match` without `reveal`, or with `dryRun: true` | Never. The basics come back; the details only if the account already revealed them. | | `POST /organizations/match` with `reveal: ["details"]` and without `dryRun` | Yes, for the organizations whose details it reveals | | `POST /organizations/contacts/match` | Never. Details come back masked unless the account already revealed them. | | `POST /organizations/switchboards/match` without `reveal`, or with `dryRun: true` | Never | | `POST /organizations/switchboards/match` with `reveal` and without `dryRun` | Yes, for the switchboard numbers and emails it unmasks | | `POST /people/match` without `reveal`, or with `dryRun: true` | Never | | `POST /people/match` with `reveal` and without `dryRun` | Yes, for the email and phone numbers it unmasks | | `POST /contacts/reveal` with `dryRun: true` | Never (the call still needs an `Idempotency-Key`) | | `POST /contacts/reveal` | Yes | | `GET /credits` | Never | Only an organization's details (0.2 credits per organization by default), a contact's email (0.2) and phone (1.0) cost anything; a contact's LinkedIn URL is never masked and never charged. Details the account has already revealed cost nothing again, and a request that finds nothing charges nothing. The [OpenAPI spec](https://funnelfeedr.com/developers/openapi.json) marks the operations that can spend with an `x-credit-cost` extension. ## A safe reveal, step by step 1. **Find the people for free.** Use `people/match` without `reveal`, or `organizations/contacts/match` (which lists only the people matching the account's target contact roles, for up to 25 organizations a call), to get contact ids. Keep only the people the user actually needs. To call an organization rather than a person, find its switchboard with `organizations/switchboards/match`. 2. **Quote.** Send the reveal with `dryRun: true`. The response's `creditsQuoted` is the exact price, and each result's `reveal.creditCost` that person's price. A person who appears twice is charged once but shows the price on both results, so show the user `creditsQuoted`, not a sum of the results. 3. **Ask.** Show the user the number of people, what will be revealed and the price, and wait for a yes. A user's standing instruction ("reveal emails for up to 50 credits a day") counts — keep a running total against it. 4. **Reveal once.** Send the same request with `dryRun: false`, `maxCredits` set to the approved amount and a new `Idempotency-Key`. 5. **Check the answer.** Read `creditsCharged` and `creditsRemaining` and report them. Do not reveal the same contacts again. The reveal in step 4, as a `POST /contacts/reveal` body: ```json { "items": [ { "ref": "anna", "contactId": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90" }, { "ref": "johan", "contactId": "d41d8cd9-8f00-4b20-9e98-0ecf8427e1b2" } ], "infoTypes": ["email", "phone"], "maxCredits": 1.4, "dryRun": false } ``` Organization details follow the same steps. Match for free first and check `detailsRevealed`: an organization the account already revealed returns its details without a reveal. Quote the rest with `organizations/match`, `reveal: ["details"]` and `dryRun: true` (`creditsQuoted` is the total; each result's `creditCost` is that organization's price, shown on every result for an organization that appears more than once but charged once), ask, then reveal once with `maxCredits` and an `Idempotency-Key`. ## When to stop | You get | Do this | |---|---| | `402 insufficient_credits` | Stop. Nothing was spent. Tell the user the balance is too low; they top up in the app. Do not retry in a loop. | | `422 credit_cap_exceeded` | Stop. Nothing was spent. The body's `creditsRequired` is the real cost; ask the user before raising `maxCredits`. | | `429 rate_limited` | Wait `Retry-After` seconds, then continue. | | `409 idempotency_key_in_use` | The same request is still running. Wait a moment, then retry with the same key to get its answer. | | `5xx` | Retry a few times with backoff. On a spend call, reuse the same `Idempotency-Key` so a retry cannot charge twice. | Branch on the `code` field, never on the `detail` text: `detail` is for humans and may change. ## Ambiguous and missing matches - `ambiguous` means more than one person fits. The response lists up to five `candidates` with their id, name, job title and organization. Ask the user which one they mean, or pick by `id` if your own data settles it (a matching title, say). Never reveal all of them to be safe. - `not_found` is free and final for that input. Funnelfeedr does not guess: nicknames ("Kalle" for "Karl") do not match in v1. Try another identifier you have — an email or a LinkedIn URL — rather than variations of the name. An email only matches an address the account has already revealed or owns, so a `not_found` email says nothing about whether the address exists; try the name and organization or the LinkedIn URL. See [Matching](https://funnelfeedr.com/developers/matching.md) for full examples of each. ## Batch, don't loop Every lookup takes up to 25 items. Send one request with 25 people or organizations rather than 25 requests with one, and use `ref` to line answers up with your rows. A reveal is all-or-nothing per request, so a batch is either fully charged or not charged at all. See [Batching](https://funnelfeedr.com/developers/batching.md). --- Source: https://funnelfeedr.com/developers/matching # Matching organizations and people > How organizations/match, organizations/switchboards/match and people/match find records, which organization fields are free and which are revealed for credits, which contacts organizations/contacts/match returns, every status they answer with, and full JSON for a hit, no match, an ambiguous match and running out of credits. Matching answers one question per item: *which Funnelfeedr record is this?* The match endpoints take 1–25 items, answer each one with a `status`, and return results in request order with your `ref` echoed back. Matching is free; only a reveal costs credits. | `status` | Meaning | Charged | |---|---|---| | `matched` | Exactly one record fits. `matchedBy` says which identifier hit. | Only for details revealed | | `ambiguous` | `people/match` only: more than one person fits. Up to 5 `candidates`; nothing revealed. | No | | `not_found` | Nothing fits. On `people/match`, `organizations/switchboards/match` and `organizations/contacts/match`, `reason` says what was missing. | No | | `invalid` | The item itself is unusable. `error` says why; the rest of the batch still runs. | No | A request that is malformed as a whole — no `items`, more than 25, a body that is not valid JSON — fails with a `422` instead. See [Errors](https://funnelfeedr.com/developers/errors.md). ## Organizations `POST /external/v1/organizations/match` — free for the basics; an organization's details cost credits when you reveal them. Counts against the `search` [rate limit](https://funnelfeedr.com/developers/rate-limits.md) bucket, and the `spend` bucket too when it reveals. Identify each organization by: | Field | Example | Notes | |---|---|---| | `id` | `"6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21"` | A Funnelfeedr organization id from an earlier response. | | `orgNumber` | `"556677-8899"` | National registration number, with or without the hyphen. | | `countryCode` | `"SE"` | Optional, and only together with `orgNumber`: `SE`, `NO`, `DK` or `FI`. Without it the digit count picks the registry (10 digits SE, 9 NO, 8 DK or FI). | | `linkedInUrl` | `"https://www.linkedin.com/company/nordvik-logistik"` | The organization's LinkedIn page. | | `domain` | `"nordviklogistik.se"` | The website domain. `www.` and a pasted URL such as `https://www.nordviklogistik.se/about` work too. | Send one identifier, or several to fall back: they are tried in the order `id`, `orgNumber`, `linkedInUrl`, `domain`, and the first hit wins (`matchedBy` tells you which). A malformed identifier — an org number with no digits, a LinkedIn URL that is not an organization's page, a `countryCode` without an `orgNumber` — makes the item `invalid` rather than silently falling back. Organization names are not accepted: a name is ambiguous, and a silently wrong organization is worse than a miss. Several identifiers for one organization, for example from a CRM row that has both. `orgNumber` is tried first, and `domain` only if it misses: ```json { "items": [ { "ref": "row-7", "domain": "nordviklogistik.se", "orgNumber": "556677-8899", "countryCode": "SE" } ], "dryRun": false } ``` ### Free match Three organizations from a CRM export, each with its own identifier: ```json { "items": [ { "ref": "crm-1001", "domain": "nordviklogistik.se" }, { "ref": "crm-1002", "orgNumber": "5590123456", "countryCode": "SE" }, { "ref": "crm-1003", "domain": "unknown-startup.se" } ], "dryRun": false } ``` One hit by domain, one by organization number, one miss. Nothing is charged: each organization comes back with its basics and the identifier it was matched by, and its details held back. ```json { "results": [ { "ref": "crm-1001", "status": "matched", "matchedBy": "domain", "organization": { "id": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "name": "Nordvik Logistik AB", "detailsRevealed": false, "countryCode": "SE", "domain": "nordviklogistik.se", "city": "Göteborg", "legalForm": "Aktiebolag", "foundedYear": 2004, "url": "https://app.funnelfeedr.com/organizations/6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21" } }, { "ref": "crm-1002", "status": "matched", "matchedBy": "orgNumber", "organization": { "id": "2b7e1c55-0d3a-4f7e-9c1b-8a6d5e4f3a21", "name": "Fjällräven Frakt AB", "detailsRevealed": false, "organizationNumber": "559012-3456", "countryCode": "SE", "city": "Stockholm", "legalForm": "Aktiebolag", "foundedYear": 2017, "url": "https://app.funnelfeedr.com/organizations/2b7e1c55-0d3a-4f7e-9c1b-8a6d5e4f3a21" } }, { "ref": "crm-1003", "status": "not_found" } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 412.4, "dryRun": false } ``` An organization match is `matched`, `not_found` or `invalid`; it is never `ambiguous`. A domain matches only the organization flagged as the site's owner, so a subsidiary sharing its parent's website is not returned for the parent's domain — use its organization number. A Swedish sole trader's number is a personal identity number and comes back with its last four digits masked (`850101-XXXX`), even when the details are revealed. Funnelfeedr does not research unknown organizations on demand: an organization it does not have is `not_found`, immediately and for free. ### Basics and details Every organization object has two parts: - **The basics, free on every response:** `id`, `url`, `name`, `countryCode`, `legalForm`, `city`, `foundedYear`, and the identifier the item was matched by. An `orgNumber` match returns `organizationNumber`, a `domain` match returns `domain` (the domain you sent, normalized), a `linkedInUrl` match returns `linkedInUrl`; an `id` match returns nothing extra. - **The details, revealed for credits:** the identifiers you did not match by (`organizationNumber`, `domain`, `linkedInUrl`), `website`, `employeesMin`/`employeesMax`, `revenueMin`/`revenueMax`/`revenueCurrency`, `oneSentenceDescription`, `description` (English), `technologies`, `keywords`, `tags`, and the rest of the address: `addressLine1`, `addressLine2`, `postalCode`, `stateOrProvince`. `detailsRevealed` says which you got. It is `true` when the object carries every detail Funnelfeedr has: your account revealed them within the last year, or the organization has none beyond the basics. An organization your account has revealed returns its details on every plain match, for free, for a year after the reveal. ### Reveal the details Options for the whole request: | Option | Meaning | |---|---| | `reveal` | `["details"]` to reveal the details of every `matched` organization. Leave it out to match only, for free. | | `dryRun` | `true` to get the price of the reveal without spending anything. | | `maxCredits` | The most the request may spend. **Required** when `reveal` is set and `dryRun` is not `true`. | Revealing costs 0.2 credits per organization by default, nothing for an organization your account revealed within the last year, and nothing for one with no details beyond the basics. A request that reveals also needs an `Idempotency-Key` header, fails as a whole with `402` or `422 credit_cap_exceeded` exactly like a [people reveal](#out-of-credits), and spends nothing when it fails. With `reveal` set, each matched result carries a `creditCost`: what that organization costs (on a dry run) or cost. Start with a [dry run](https://funnelfeedr.com/developers/credits-and-reveal.md#organization-details) when you are not sure of the price. After a dry run quoted 0.2 credits: ```http POST /external/v1/organizations/match HTTP/1.1 Host: api.funnelfeedr.com Authorization: Bearer ff_live_... Content-Type: application/json Idempotency-Key: 2c8f4b1a-6e3d-4a9f-b7c2-5d1e0f8a3b64 ``` ```json { "items": [ { "ref": "crm-1001", "domain": "nordviklogistik.se" } ], "reveal": ["details"], "maxCredits": 0.2, "dryRun": false } ``` ```json { "results": [ { "ref": "crm-1001", "status": "matched", "matchedBy": "domain", "organization": { "id": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "name": "Nordvik Logistik AB", "detailsRevealed": true, "organizationNumber": "556677-8899", "countryCode": "SE", "domain": "nordviklogistik.se", "website": "https://www.nordviklogistik.se", "linkedInUrl": "https://www.linkedin.com/company/nordvik-logistik", "city": "Göteborg", "addressLine1": "Hamngatan 12", "postalCode": "411 06", "employeesMin": 50, "employeesMax": 99, "revenueMin": 100000000, "revenueMax": 250000000, "revenueCurrency": "SEK", "oneSentenceDescription": "Nordvik Logistik runs third-party warehousing and road freight for retailers in western Sweden.", "description": "Nordvik Logistik AB is a Gothenburg logistics provider offering warehousing, order fulfilment and domestic road freight to retail and e-commerce customers across western Sweden.", "technologies": ["Google Analytics", "HubSpot", "WordPress"], "keywords": ["3PL", "road freight", "warehousing"], "tags": ["Logistics", "B2B"], "legalForm": "Aktiebolag", "foundedYear": 2004, "url": "https://app.funnelfeedr.com/organizations/6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21" }, "creditCost": 0.2 } ], "creditsCharged": 0.2, "creditsQuoted": 0.2, "creditsRemaining": 412.2, "dryRun": false } ``` The response also carries `Funnelfeedr-Credits-Remaining: 412.2`. From now on a plain match of Nordvik Logistik returns these details for free, with `detailsRevealed: true`, until a year after the reveal. ## Organizations' contacts `POST /external/v1/organizations/contacts/match` — free, `search` bucket. This lists the people you would want to reach at up to 25 organizations in one call: **only the contacts that match your account's target contact roles**, the roles set up in Funnelfeedr under **Settings → Target contact roles**. They are exactly the contacts the web app shows under the **Target roles** filter of the organization's contacts tab, in the same order. Contacts that come from your connected CRM carry no target-role verdict in Funnelfeedr, so they are left out while the filter applies. If your account has **no target contact roles set up**, the web app has no target-role filter and lists every contact at the organization — and so does this endpoint. `filteredByTargetRoles` on each result tells you which you got: `true` when the list is filtered to your target roles, `false` when it is every contact. Set up target roles in the app if you only want the people you sell to. Identify the organizations exactly as for `organizations/match` — the same fields, the same `id` → `orgNumber` → `linkedInUrl` → `domain` fallback. `limit` (1 to 50, default 20) is the page size for every organization in the request. | `status` | Meaning | |---|---| | `matched` | `organization` and one page of its `contacts`, with `totalCount` (across all pages), `filteredByTargetRoles` and `nextCursor` (`null` on the organization's last page). | | `not_found` | `reason` is `organization_not_found`: no organization matches the identifiers. | | `invalid` | The item is unusable — no identifier, a malformed one, or a `cursor` that was altered or belongs to another organization (`invalid_cursor`); see `error`. | Each result's `organization` is the same object `organizations/match` returns, masked the same way: the basics and the identifier it was matched by, plus the details only if your account has already revealed them. This endpoint never reveals anything. Email and phone are masked until your account reveals them; name, job title and LinkedIn never are. Pass the ids you want to `POST /contacts/reveal` — see [Credits & reveal](https://funnelfeedr.com/developers/credits-and-reveal.md). ```http POST /external/v1/organizations/contacts/match HTTP/1.1 Host: api.funnelfeedr.com Authorization: Bearer ff_live_... Content-Type: application/json { "items": [ { "ref": "crm-1001", "domain": "nordviklogistik.se" }, { "ref": "crm-1002", "orgNumber": "5590123456", "countryCode": "SE" } ], "limit": 2 } ``` ```json { "results": [ { "ref": "crm-1001", "status": "matched", "matchedBy": "domain", "organization": { "id": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "name": "Nordvik Logistik AB", "detailsRevealed": false, "countryCode": "SE", "domain": "nordviklogistik.se", "city": "Göteborg", "legalForm": "Aktiebolag", "foundedYear": 2004, "url": "https://app.funnelfeedr.com/organizations/6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21" }, "contacts": [ { "id": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "name": "Anna Svensson-Berg", "jobTitle": "Head of Procurement", "isExecutive": false, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "XXXXX@nordviklogistik.se", "emailIsMasked": true, "phone": "+46701XXXXXX", "phoneIsMasked": true, "linkedInUrl": "https://linkedin.com/in/anna-svensson-berg" }, { "id": "d41d8cd9-8f00-4b20-9e98-0ecf8427e1b2", "name": "Johan Lindqvist", "jobTitle": "CFO", "isExecutive": true, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "XXXXX@nordviklogistik.se", "emailIsMasked": true, "phoneIsMasked": false } ], "totalCount": 3, "filteredByTargetRoles": true, "nextCursor": "eyJzIjoib3JnYW5pemF0aW9uLWNvbnRhY3RzOjZmOWQyZjFlNmYzNzRhMmY5YTNlMWI1YzBmNGQ3ZTIxIiwicCI6MiwibiI6Mn0" }, { "ref": "crm-1002", "status": "matched", "matchedBy": "orgNumber", "organization": { "id": "2b7e1c55-0d3a-4f7e-9c1b-8a6d5e4f3a21", "name": "Fjällräven Frakt AB", "detailsRevealed": false, "organizationNumber": "559012-3456", "countryCode": "SE", "city": "Stockholm", "legalForm": "Aktiebolag", "foundedYear": 2017, "url": "https://app.funnelfeedr.com/organizations/2b7e1c55-0d3a-4f7e-9c1b-8a6d5e4f3a21" }, "contacts": [], "totalCount": 0, "filteredByTargetRoles": true, "nextCursor": null } ] } ``` Nordvik Logistik has a third contact. To read it, send the result's `nextCursor` back as an item's `cursor`. The cursor already names the organization, so the item needs nothing else; you can send next pages of several organizations, and first pages of others, in the same request: ```json { "items": [ { "ref": "crm-1001", "cursor": "eyJzIjoib3JnYW5pemF0aW9uLWNvbnRhY3RzOjZmOWQyZjFlNmYzNzRhMmY5YTNlMWI1YzBmNGQ3ZTIxIiwicCI6MiwibiI6Mn0" } ] } ``` That result carries no `matchedBy`, and its `organization` echoes no identifier, since the cursor rather than an identifier named the organization. A cursor keeps the page size it started with, so `limit` does not change it. See [Pagination](https://funnelfeedr.com/developers/pagination.md). ## Switchboards `POST /external/v1/organizations/switchboards/match` — free, or credits when it reveals a number or email. Counts against the `search` rate-limit bucket, and the `spend` bucket too when it reveals. Use it when you want to call an organization rather than a named person: its switchboard, reception or general information line. Identify up to 25 organizations exactly as for `organizations/match` — the same fields, the same `id` → `orgNumber` → `linkedInUrl` → `domain` fallback. For each one Funnelfeedr picks the switchboard the way its own dialer does when a rep is told to "call the switchboard": the organization's best-ranked contact that is not a person and has a phone number, preferring a number in the organization's own country. | `status` | Meaning | Charged | |---|---|---| | `matched` | `contact` is the switchboard. Its phone, and its email when it has one, are masked unless your account revealed them before, or this call reveals them. | Only if revealed | | `not_found` | `reason` is `organization_not_found` (no organization matches the identifiers) or `no_switchboard` (the organization is known, and returned in `organization`, but Funnelfeedr has no switchboard with a phone number for it). | No | | `invalid` | The item is unusable; see `error`. | No | Options for the whole request: | Option | Meaning | |---|---| | `reveal` | `["phone"]`, `["email"]` or `["phone", "email"]` to reveal them on every matched switchboard. Leave it out to find switchboards for free. | | `dryRun` | `true` to get the price of the reveal without spending anything. | | `maxCredits` | The most the request may spend. **Required** when `reveal` is set and `dryRun` is not `true`; checked before any lookup runs. | A switchboard costs what revealing it in the app does — the rates for a contact that is not a person: 1.0 credit for the phone and 0.2 for the email by default, the sum for both, nothing for what your account has revealed before. A request that reveals also needs an `Idempotency-Key` header, fails as a whole with `402` or `422 credit_cap_exceeded` exactly like a [people reveal](#out-of-credits), and spends nothing when it fails. See [Credits & reveal](https://funnelfeedr.com/developers/credits-and-reveal.md). Each result's `organization` is the same object `organizations/match` returns, masked the same way: the basics and the identifier it was matched by, plus the details only if your account has already revealed them. This endpoint never reveals organization details; use [`organizations/match`](#reveal-the-details) for that. ### Found ```json { "items": [ { "ref": "crm-1001", "domain": "nordviklogistik.se" }, { "ref": "crm-1002", "orgNumber": "5590123456", "countryCode": "SE" } ] } ``` ```json { "results": [ { "ref": "crm-1001", "status": "matched", "matchedBy": "domain", "organization": { "id": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "name": "Nordvik Logistik AB", "detailsRevealed": false, "countryCode": "SE", "domain": "nordviklogistik.se", "city": "Göteborg", "legalForm": "Aktiebolag", "foundedYear": 2004, "url": "https://app.funnelfeedr.com/organizations/6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21" }, "contact": { "id": "5a1c9e7f-3b2d-4c6e-8f0a-9d4b7e2c1f38", "name": "Nordvik Logistik AB", "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "emailIsMasked": false, "phone": "+46317XXXXXX", "phoneIsMasked": true } }, { "ref": "crm-1002", "status": "not_found", "reason": "no_switchboard", "matchedBy": "orgNumber", "organization": { "id": "2b7e1c55-0d3a-4f7e-9c1b-8a6d5e4f3a21", "name": "Fjällräven Frakt AB", "detailsRevealed": false, "organizationNumber": "559012-3456", "countryCode": "SE", "city": "Stockholm", "legalForm": "Aktiebolag", "foundedYear": 2017, "url": "https://app.funnelfeedr.com/organizations/2b7e1c55-0d3a-4f7e-9c1b-8a6d5e4f3a21" } } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 412.4, "dryRun": false } ``` A switchboard is a contact like any other, so its `id` also works with `POST /contacts/reveal`. Its `name` is whatever Funnelfeedr has for the line — often the organization's name. ### Found and revealed After a dry run quoted 1.0 credit: ```http POST /external/v1/organizations/switchboards/match HTTP/1.1 Host: api.funnelfeedr.com Authorization: Bearer ff_live_... Content-Type: application/json Idempotency-Key: 7d2e9a41-0c5b-4f8e-b3a6-91e4c2d7f058 ``` ```json { "items": [ { "ref": "crm-1001", "domain": "nordviklogistik.se" } ], "reveal": ["phone"], "maxCredits": 1, "dryRun": false } ``` ```json { "results": [ { "ref": "crm-1001", "status": "matched", "matchedBy": "domain", "organization": { "id": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "name": "Nordvik Logistik AB", "detailsRevealed": false, "countryCode": "SE", "domain": "nordviklogistik.se", "city": "Göteborg", "legalForm": "Aktiebolag", "foundedYear": 2004, "url": "https://app.funnelfeedr.com/organizations/6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21" }, "contact": { "id": "5a1c9e7f-3b2d-4c6e-8f0a-9d4b7e2c1f38", "name": "Nordvik Logistik AB", "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "emailIsMasked": false, "phone": "+46317001234", "phoneIsMasked": false }, "reveal": { "creditCost": 1, "newInfoTypes": ["phone"], "alreadyRevealedInfoTypes": [], "unavailableInfoTypes": [] } } ], "creditsCharged": 1, "creditsQuoted": 1, "creditsRemaining": 411.4, "dryRun": false } ``` Revealing the switchboard does not reveal the organization's details: `detailsRevealed` stays `false` until the account reveals them with `organizations/match`. ### Not found and invalid ```json { "results": [ { "ref": "crm-1003", "status": "not_found", "reason": "organization_not_found" }, { "ref": "crm-1004", "status": "invalid", "error": { "code": "missing_identifier", "message": "Give one of id, orgNumber, linkedInUrl or domain." } } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 412.4, "dryRun": false } ``` ## People `POST /external/v1/people/match` — free, or credits when it reveals. Counts against the `search` rate-limit bucket, and the `spend` bucket too when it reveals. Identify each person by one of these, strongest first. If an item has several, the strongest wins and `matchedBy` says which was used: | Field | Example | Notes | |---|---|---| | `linkedInUrl` | `"https://www.linkedin.com/in/johan-lindqvist-cfo"` | A LinkedIn profile (`/in/`). | | `email` | `"anna.svensson-berg@nordviklogistik.se"` | Matched exactly, ignoring case — but only against addresses your account already sees unmasked. See [Matching by email](#matching-by-email). | | `name` + `organization` | `"Anna Svensson"` and `{ "domain": "nordviklogistik.se" }` | A name needs an organization; alone it is `invalid` (`missing_organization`). | `organization` takes the same identifiers as an organization match — `id`, `orgNumber` (+ optional `countryCode`), `linkedInUrl`, `domain` — with the same fallback order. Options for the whole request: | Option | Meaning | |---|---| | `reveal` | What to reveal for each `matched` person: any of `"email"`, `"phone"`, `"linkedIn"`. Leave it out to match only, for free. LinkedIn URLs are never masked, so `"linkedIn"` is free. | | `dryRun` | `true` to get the price of the reveal without spending anything. | | `maxCredits` | The most the request may spend. **Required** when `reveal` is set and `dryRun` is not `true`; checked before any lookup runs. | A request that reveals also needs an `Idempotency-Key` header. See [Credits & reveal](https://funnelfeedr.com/developers/credits-and-reveal.md). ### How names match Names match deterministically, not fuzzily. Case, accents and punctuation are normalised, so `Åsa Öberg` and `Asa Oberg` are the same name, and a hyphen counts as a space. A hyphenated last name also matches either half: `Anna Svensson` and `Anna Berg` both match `Anna Svensson-Berg`. Nicknames, typos and dropped middle names do not match: "Kalle" does not find "Karl" in v1. If a name is `not_found`, try another identifier you have rather than variations of the name. A name is looked for only among the people the app lists at that organization. A LinkedIn URL is looked for everywhere, but a person the app lists under no organization is `person_not_found`. ### Matching by email An email only matches an address your account can already see in full: one it has revealed before, or one that belongs to your account. Any other address — including one Funnelfeedr has on record but your account has not revealed — is `not_found` with `person_not_found`, and costs nothing. Matching is not a way to check whether a guessed email exists. If you have only a suspected email, match by `linkedInUrl` or by `name` plus `organization` instead, then reveal the email if you need it. ### Hit A person found exactly once. Without `reveal`, email and phone come back masked unless your account already revealed them, and nothing is charged. ```json { "items": [ { "ref": "lead-42", "name": "Anna Svensson", "organization": { "domain": "nordviklogistik.se" } } ], "dryRun": false } ``` ```json { "results": [ { "ref": "lead-42", "status": "matched", "matchedBy": "name", "contact": { "id": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "name": "Anna Svensson-Berg", "jobTitle": "Head of Procurement", "isExecutive": false, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "XXXXX@nordviklogistik.se", "emailIsMasked": true, "phone": "+46701XXXXXX", "phoneIsMasked": true, "linkedInUrl": "https://linkedin.com/in/anna-svensson-berg" } } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 412.4, "dryRun": false } ``` ### Hit with reveal The same person, revealing email and phone after a [dry run](https://funnelfeedr.com/developers/credits-and-reveal.md#quote-with-dryrun) quoted 1.2 credits: ```http POST /external/v1/people/match HTTP/1.1 Host: api.funnelfeedr.com Authorization: Bearer ff_live_... Content-Type: application/json Idempotency-Key: 4f6a1c2e-9b7d-4e3a-8c51-2d0f7b9e6a14 ``` ```json { "items": [ { "ref": "lead-42", "name": "Anna Svensson", "organization": { "domain": "nordviklogistik.se" } } ], "reveal": ["email", "phone"], "maxCredits": 1.2, "dryRun": false } ``` ```json { "results": [ { "ref": "lead-42", "status": "matched", "matchedBy": "name", "contact": { "id": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "name": "Anna Svensson-Berg", "jobTitle": "Head of Procurement", "isExecutive": false, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "anna.svensson-berg@nordviklogistik.se", "emailIsMasked": false, "phone": "+46701234567", "phoneIsMasked": false, "linkedInUrl": "https://linkedin.com/in/anna-svensson-berg" }, "reveal": { "creditCost": 1.2, "newInfoTypes": ["email", "phone"], "alreadyRevealedInfoTypes": [], "unavailableInfoTypes": [] } } ], "creditsCharged": 1.2, "creditsQuoted": 1.2, "creditsRemaining": 411.2, "dryRun": false } ``` The response also carries `Funnelfeedr-Credits-Remaining: 411.2`. If the person has no phone number on record, `phone` is listed in `unavailableInfoTypes` and only the email is charged. A revealed or quoted contact keeps the organization (and the title there) it was matched at, even when the person also works somewhere else. ### No match Nothing is charged. `reason` tells you which half failed: the organization (`organization_not_found`) or the person at an organization that was found (`person_not_found`). ```json { "items": [ { "ref": "lead-44", "name": "Karl Berg", "organization": { "domain": "nordviklogistik.se" } }, { "ref": "lead-45", "name": "Eva Holm", "organization": { "domain": "unknown-startup.se" } } ] } ``` ```json { "results": [ { "ref": "lead-44", "status": "not_found", "reason": "person_not_found", "matchedBy": "name" }, { "ref": "lead-45", "status": "not_found", "reason": "organization_not_found", "matchedBy": "name" } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 412.4, "dryRun": false } ``` ### Ambiguous More than one person fits — two people at the organization with that name, or several contacts sharing a LinkedIn URL or email. Nothing is revealed or charged for them, even if the request asked for a reveal. Up to five candidates come back with their id, name, job title and organization (never contact details); choose one and reveal it by id with `POST /contacts/reveal`, or ask the user. ```json { "items": [ { "ref": "lead-46", "name": "Anna Svensson", "organization": { "domain": "nordviklogistik.se" } } ] } ``` ```json { "results": [ { "ref": "lead-46", "status": "ambiguous", "matchedBy": "name", "candidates": [ { "id": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "name": "Anna Svensson-Berg", "jobTitle": "Head of Procurement", "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB" }, { "id": "e3b0c442-98fc-4c14-9afb-f4c8996fb924", "name": "Anna Svensson", "jobTitle": "Warehouse Manager", "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB" } ] } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 412.4, "dryRun": false } ``` ### Invalid item ```json { "results": [ { "ref": "lead-47", "status": "invalid", "error": { "code": "missing_organization", "message": "A name needs an organization (id, domain, orgNumber or linkedInUrl): a name alone names nobody." } } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 412.4, "dryRun": false } ``` ### Out of credits If the account cannot pay for the whole reveal, the **whole request** fails with `402` before anything is revealed or charged — no partial results, and no matches either. Top up in the app, then send the same request again; only successful responses are stored against an `Idempotency-Key`, so retrying with the same key runs it. ```http HTTP/1.1 402 Payment Required Content-Type: application/problem+json ``` ```json { "type": "https://funnelfeedr.com/developers/errors/insufficient_credits", "title": "Not enough credits", "status": 402, "detail": "This request needs 3.4 credits; the account has 1.2. Nothing was revealed or charged. Buy credits in Funnelfeedr under Settings → Subscription, then retry.", "code": "insufficient_credits", "retryable": false, "creditsRequired": 3.4, "creditsRemaining": 1.2, "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" } ``` ### Over your cap If the reveal would cost more than `maxCredits`, the request fails with `422 credit_cap_exceeded`, again before anything is spent and without matches. `creditsRequired` is the real cost, so you can ask the user whether to go ahead — or run the request with `dryRun: true` first. ```json { "type": "https://funnelfeedr.com/developers/errors/credit_cap_exceeded", "title": "The request would cost more than maxCredits", "status": 422, "detail": "This reveal costs 3.4 credits, more than maxCredits (2). Nothing was revealed or charged. Raise maxCredits or reveal fewer contacts or info types.", "code": "credit_cap_exceeded", "retryable": false, "creditsRequired": 3.4, "maxCredits": 2, "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" } ``` --- Source: https://funnelfeedr.com/developers/credits-and-reveal # Credits & reveal > What spends credits, how to get an exact quote with dryRun, how maxCredits caps a request, how reveals are charged all-or-nothing and only for what they unmask, and what an organization's details cost. A contact's email address and phone number are masked until your account reveals them; name, job title and LinkedIn URL never are. An organization's basics are free, and its details are held back until your account reveals them. Revealing spends the account's credits, the same credits a reveal in the app spends. Everything else in the API is free. ## What spends credits Exactly four things: - `POST /contacts/reveal`, for contacts you already have ids for, unless `dryRun` is `true`. - `POST /people/match` with `reveal` set and `dryRun` not `true`. - `POST /organizations/switchboards/match` with `reveal` set and `dryRun` not `true`. - `POST /organizations/match` with `reveal: ["details"]` and `dryRun` not `true`. See [Organization details](#organization-details). Every API key can make all four calls. The `maxCredits` cap on each request is what keeps a reveal within what you approved. | Info type | Default price per contact | |---|---| | `email` | 0.2 credits | | `phone` | 1.0 credits | | `linkedIn` | Free. LinkedIn URLs are never masked; asking for one is accepted and reported as already revealed, or unavailable when the contact has none. | Email and phone together cost the sum. A switchboard — an organization's main line, a contact that is not a person — is priced at the app's rates for a contact that is not a person: by default 1.0 credits for its phone and 0.2 for its email, the same charge as revealing them in the app. An account group can have its own prices, and `GET /credits` shows the balance, not prices — so don't hard-code them: ask with `dryRun`. The [OpenAPI spec](https://funnelfeedr.com/developers/openapi.json) states the defaults and rules in an `x-credit-cost` extension on each of the four operations. A request is charged only for what it actually unmasks: - **Only for matched items.** A `not_found`, `ambiguous` or `invalid` item costs nothing. - **Only what exists.** A contact with no phone number on record is not charged for a phone, and a contact with none of the requested info types is skipped entirely. - **Never twice.** Info types your account has revealed within the last year cost nothing again, whoever revealed them — a user in the app or another key. A reveal by a key unmasks the contact for everyone in the account. ## Quote with dryRun Send the reveal with `"dryRun": true`. Nothing is revealed or spent and the contacts stay masked. Each result's `reveal` shows what that contact would cost, and `creditsQuoted` is the total: ```json { "items": [ { "ref": "anna", "contactId": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90" }, { "ref": "johan", "contactId": "d41d8cd9-8f00-4b20-9e98-0ecf8427e1b2" } ], "infoTypes": ["email", "phone"], "dryRun": true } ``` ```json { "results": [ { "ref": "anna", "status": "quoted", "contactId": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "reveal": { "creditCost": 1.2, "newInfoTypes": ["email", "phone"], "alreadyRevealedInfoTypes": [], "unavailableInfoTypes": [] }, "contact": { "id": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "name": "Anna Svensson-Berg", "jobTitle": "Head of Procurement", "isExecutive": false, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "XXXXX@nordviklogistik.se", "emailIsMasked": true, "phone": "+46701XXXXXX", "phoneIsMasked": true, "linkedInUrl": "https://linkedin.com/in/anna-svensson-berg" } }, { "ref": "johan", "status": "quoted", "contactId": "d41d8cd9-8f00-4b20-9e98-0ecf8427e1b2", "reveal": { "creditCost": 0.2, "newInfoTypes": ["email"], "alreadyRevealedInfoTypes": [], "unavailableInfoTypes": ["phone"] }, "contact": { "id": "d41d8cd9-8f00-4b20-9e98-0ecf8427e1b2", "name": "Johan Lindqvist", "jobTitle": "CFO", "isExecutive": true, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "XXXXX@nordviklogistik.se", "emailIsMasked": true, "phoneIsMasked": false } } ], "creditsCharged": 0, "creditsQuoted": 1.4, "creditsRemaining": 412.4, "dryRun": true } ``` Johan has no phone number on record, so only his email is priced. The quote and the charge come from the same calculation, so the real call charges exactly `creditsQuoted` — or less, if someone in your account revealed some of the same details in between. A contact that appears more than once in a request is charged once, but each of its results shows its `creditCost`; add up `creditsQuoted`, not the results. On `organizations/match`, `people/match` and `organizations/switchboards/match` a dry run needs no `Idempotency-Key` and counts against the `search` rate-limit bucket only. `POST /contacts/reveal` needs an `Idempotency-Key` even to quote, and always counts against the `spend` bucket. ## Cap with maxCredits A call that spends must say how much it may spend. `maxCredits` is required unless `dryRun` is `true`; without it the request fails with `422 validation_failed`. If the reveal would cost more, the request fails with `422 credit_cap_exceeded`, nothing is spent, and the body has the real cost in `creditsRequired`: ```json { "type": "https://funnelfeedr.com/developers/errors/credit_cap_exceeded", "title": "The request would cost more than maxCredits", "status": 422, "detail": "This reveal costs 3.4 credits, more than maxCredits (2). Nothing was revealed or charged. Raise maxCredits or reveal fewer contacts or info types.", "code": "credit_cap_exceeded", "retryable": false, "creditsRequired": 3.4, "maxCredits": 2, "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" } ``` Set `maxCredits` to the quote the user approved — not to a large number "to be safe". The cap is what stops a bug or a prompt-injected agent from emptying the account. A dry run is never capped. ## Reveal ```http POST /external/v1/contacts/reveal HTTP/1.1 Host: api.funnelfeedr.com Authorization: Bearer ff_live_... Content-Type: application/json Idempotency-Key: 9a3e7c1d-2b4f-4d8e-a6c0-5f1b3e9d7a28 ``` ```json { "items": [ { "ref": "anna", "contactId": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90" }, { "ref": "missing", "contactId": "e3b0c442-98fc-4c14-9afb-f4c8996fb924" } ], "infoTypes": ["email", "phone"], "maxCredits": 1.2, "dryRun": false } ``` Each found contact has the `status` `revealed`, with `reveal` saying what this request unmasked (`newInfoTypes`) and what was already revealed or unavailable, and `contact` holding the unmasked details. A contact id the key cannot see is `not_found`; an item without a `contactId` is `invalid`. Here Anna's details had been revealed before, so they come back unmasked and cost nothing again: ```json { "results": [ { "ref": "anna", "status": "revealed", "contactId": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "reveal": { "creditCost": 0, "newInfoTypes": [], "alreadyRevealedInfoTypes": ["email", "phone"], "unavailableInfoTypes": [] }, "contact": { "id": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "name": "Anna Svensson-Berg", "jobTitle": "Head of Procurement", "isExecutive": false, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "anna.svensson-berg@nordviklogistik.se", "emailIsMasked": false, "phone": "+46701234567", "phoneIsMasked": false, "linkedInUrl": "https://linkedin.com/in/anna-svensson-berg" } }, { "ref": "missing", "status": "not_found", "contactId": "e3b0c442-98fc-4c14-9afb-f4c8996fb924" } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 411.2, "dryRun": false } ``` `infoTypes` is required. A reveal needs an `Idempotency-Key` header, and counts against the `spend` [rate limit](https://funnelfeedr.com/developers/rate-limits.md) bucket. ## All or nothing A reveal request is charged as one unit. Either every reveal in it goes through and is charged, or — when the account cannot pay for all of it — none does: ```json { "type": "https://funnelfeedr.com/developers/errors/insufficient_credits", "title": "Not enough credits", "status": 402, "detail": "This request needs 3.4 credits; the account has 1.2. Nothing was revealed or charged. Buy credits in Funnelfeedr under Settings → Subscription, then retry.", "code": "insufficient_credits", "retryable": false, "creditsRequired": 3.4, "creditsRemaining": 1.2, "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" } ``` You never get half a batch revealed and have to work out which half. On `organizations/match`, `people/match` and `organizations/switchboards/match` a failed reveal fails the whole request, matches included. After a top-up, send the same request again; the same `Idempotency-Key` is fine because only successful responses are stored. ## Organization details `POST /organizations/match` is free and returns an organization's basics. Its details cost credits, like a contact's email or phone. | Free on every match | Details, revealed for credits | |---|---| | `id`, `url`, `name`, `countryCode`, `legalForm`, `city`, `foundedYear`, and the identifier you matched by: `organizationNumber` for an `orgNumber` match, `domain` (the one you sent, normalized) for a `domain` match, `linkedInUrl` for a `linkedInUrl` match. An `id` match returns nothing extra. | `organizationNumber`, `domain` and `linkedInUrl` when you did not match by them, `website`, `employeesMin` and `employeesMax`, `revenueMin`, `revenueMax` and `revenueCurrency`, `oneSentenceDescription`, `description` (English), `technologies`, `keywords`, `tags`, and the rest of the address: `addressLine1`, `addressLine2`, `postalCode`, `stateOrProvince`. | A Swedish sole trader's organization number is a personal identity number and stays masked (`850101-XXXX`), revealed or not. **Price.** 0.2 credits per organization by default, one price for all of its details. An account group can have its own price, so ask with `dryRun` rather than hard-coding it. The `x-credit-cost` extension on `match_organizations` in the [OpenAPI spec](https://funnelfeedr.com/developers/openapi.json) states the default. **`detailsRevealed`.** Every organization object carries it. It is `true` when the object holds every detail Funnelfeedr has: your account revealed them within the last year, or there are none beyond the basics. When it is `false` the details are held back. **Valid for a year.** Once your account has revealed an organization's details, every `organizations/match` returns them for a year, free and without `reveal`. A reveal by one key unlocks them for every key on the account. **Revealing.** Add `"reveal": ["details"]` to an `organizations/match` request. The rules are the same as for a people reveal: a dry run spends nothing; a real reveal needs `maxCredits` and an `Idempotency-Key` header, counts against the `spend` rate-limit bucket as well as `search`, and is charged all-or-nothing. If it would cost more than `maxCredits` it fails with `422 credit_cap_exceeded`, and if the account cannot pay with `402 insufficient_credits`; either way nothing is spent and no matches are returned. Only matched organizations whose details the account has not revealed are charged. `not_found` and `invalid` items, and an organization with no details beyond the basics, cost nothing. With `reveal` set, each matched result has a `creditCost`: what revealing that organization costs (on a dry run) or cost, `0` when the account already revealed it or there is nothing to reveal. An organization that appears more than once in a request is charged once, but every one of its results shows its price, so `creditsQuoted`, not the sum of `creditCost`, is what the request costs. A dry run: ```json { "items": [ { "ref": "crm-1001", "domain": "nordviklogistik.se" } ], "reveal": ["details"], "dryRun": true } ``` ```json { "results": [ { "ref": "crm-1001", "status": "matched", "matchedBy": "domain", "organization": { "id": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "name": "Nordvik Logistik AB", "detailsRevealed": false, "countryCode": "SE", "domain": "nordviklogistik.se", "city": "Göteborg", "legalForm": "Aktiebolag", "foundedYear": 2004, "url": "https://app.funnelfeedr.com/organizations/6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21" }, "creditCost": 0.2 } ], "creditsCharged": 0, "creditsQuoted": 0.2, "creditsRemaining": 412.4, "dryRun": true } ``` The real reveal and its response are on the [Matching](https://funnelfeedr.com/developers/matching.md#reveal-the-details) page. `organizations/switchboards/match` and `organizations/contacts/match` return the same organization object — the basics, plus the details only if your account has already revealed them — and never reveal organization details themselves. ## Knowing the balance - `GET /credits` returns `included` (the plan's recurring allowance, spent first), `purchased` (does not reset) and `total`. It is free. - Every response that can spend has `creditsCharged`, `creditsQuoted`, `creditsRemaining` and `dryRun` in the body, and the balance in the `Funnelfeedr-Credits-Remaining` header, so you rarely need to ask separately. `creditsRemaining` is null, and the header absent, when the account has no credit subscription. - Credits are bought in the app under **Settings → Subscription**; the API cannot buy credits. --- Source: https://funnelfeedr.com/developers/batching # Batching > Every lookup and reveal takes an array of 1 to 25 items and answers each one separately, in order, with your ref echoed back. There are no single-item endpoints. Matching, contacts, switchboards and reveals all take `items`, an array of 1 to 25 objects, and return one result per item in `results`. One item is just a batch of one. ```json { "items": [ { "ref": "crm-2001", "linkedInUrl": "https://www.linkedin.com/in/nordvik" }, { "ref": "crm-2002", "domain": "nordviklogistik.se" } ] } ``` ```json { "results": [ { "ref": "crm-2001", "status": "invalid", "error": { "code": "invalid_value", "message": "linkedInUrl must be an organization's LinkedIn page, https://www.linkedin.com/company/…" } }, { "ref": "crm-2002", "status": "matched", "matchedBy": "domain", "organization": { "id": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "name": "Nordvik Logistik AB", "detailsRevealed": false, "countryCode": "SE", "domain": "nordviklogistik.se", "city": "Göteborg", "legalForm": "Aktiebolag", "foundedYear": 2004, "url": "https://app.funnelfeedr.com/organizations/6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21" } } ], "creditsCharged": 0, "creditsQuoted": 0, "creditsRemaining": 412.4, "dryRun": false } ``` ## The rules - **1 to 25 items.** An empty or missing array fails the whole request with `422 validation_failed`; more than 25 with `422 too_many_items` (the body carries `maxItems: 25`). Split larger jobs into several requests. - **Same order.** `results[i]` answers `items[i]`, always. - **`ref` is yours.** Optional, at most 200 characters — a CRM id, a row number. It comes back unchanged so you never have to rely on order alone. Funnelfeedr does not store it. - **A status per item.** Each result has a `status`. A bad item gets `"status": "invalid"` with an `error`, and the rest of the batch still runs. A `200` response therefore does not mean every item worked: check each `status`. - **Malformed JSON fails the whole request.** A value of the wrong type — an `id` that is not a UUID, say — is caught before any item runs: `422 validation_failed`, with the field path in `errors` (`"items[3].organization.id": ["Must be a UUID."]`). - **One request, one charge.** A batch that reveals is quoted, capped and charged as a whole. See [Credits & reveal](https://funnelfeedr.com/developers/credits-and-reveal.md). ## Item errors An `invalid` item's `error` has a stable `code` and a human-readable `message` naming the field to fix: | `code` | Meaning | |---|---| | `missing_identifier` | The item names nothing to look up. An empty or blank field counts as not sent. | | `invalid_value` | A field is present but unusable: an email without `@`, a LinkedIn URL of the wrong kind, a name with no letters in it, a `ref` over 200 characters. | | `missing_organization` | `people/match`: a name was given without an `organization`. | | `missing_id` | No id on a reveal item. | | `invalid_cursor` | `organizations/contacts/match`: a `cursor` that was altered, or that belongs to another organization than the item's identifiers name. | Fix the item and send it again on its own or in your next batch; the other items already have their answers. ## Large jobs For a CRM of 10,000 organizations, send 400 requests of 25. They count against your [rate limits](https://funnelfeedr.com/developers/rate-limits.md) like any other request, so pace them: the matching endpoints, organizations' contacts included, share the `search` bucket of 20 requests a minute by default, which at 25 items per request is 500 items a minute. Honour `Retry-After` if you get a `429`. There are no asynchronous bulk jobs, file uploads or webhooks in v1. Every request answers synchronously. --- Source: https://funnelfeedr.com/developers/pagination # Pagination > Organizations' contacts page per organization inside a batch, with a limit of 1 to 50 and an opaque cursor per item; follow each result's nextCursor until it is null. The one endpoint that can return many records for an item — organizations' contacts, `POST /organizations/contacts/match` — pages each organization's contacts with a cursor. The cursor lives on the item, not on the request: one batch can read the first page of some organizations and the next page of others. Everything else answers a batch of at most 25 items in one response. ## Request | Field | Meaning | |---|---| | `limit` | Top level. Contacts per organization, 1 to 50. Default 20. An item with a `cursor` ignores it: the first page fixed the page size. | | `items[].cursor` | Leave it out for an organization's first page. For its next page, send the `nextCursor` of that organization's previous result. The item then needs no identifier. | ```bash curl https://api.funnelfeedr.com/external/v1/organizations/contacts/match \ -H "Authorization: Bearer $FUNNELFEEDR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "items": [ { "ref": "crm-1001", "domain": "nordviklogistik.se" }, { "ref": "crm-1002", "orgNumber": "5590123456", "countryCode": "SE" } ], "limit": 50 }' ``` ## Response Each `matched` result holds one page in `contacts`, next to `totalCount`, `filteredByTargetRoles` and `nextCursor`: ```json { "results": [ { "ref": "crm-1001", "status": "matched", "matchedBy": "domain", "organization": { "id": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "name": "Nordvik Logistik AB", "detailsRevealed": false, "countryCode": "SE", "domain": "nordviklogistik.se", "city": "Göteborg", "legalForm": "Aktiebolag", "foundedYear": 2004, "url": "https://app.funnelfeedr.com/organizations/6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21" }, "contacts": [ { "id": "c0a8012e-5b1f-4e3d-9a7c-2f6b8d4e1a90", "name": "Anna Svensson-Berg", "jobTitle": "Head of Procurement", "isExecutive": false, "isDecisionMaker": true, "organizationId": "6f9d2f1e-6f37-4a2f-9a3e-1b5c0f4d7e21", "organizationName": "Nordvik Logistik AB", "email": "XXXXX@nordviklogistik.se", "emailIsMasked": true, "phone": "+46701XXXXXX", "phoneIsMasked": true, "linkedInUrl": "https://linkedin.com/in/anna-svensson-berg" } ], "totalCount": 63, "filteredByTargetRoles": true, "nextCursor": "eyJzIjoib3JnYW5pemF0aW9uLWNvbnRhY3RzOjZmOWQyZjFlNmYzNzRhMmY5YTNlMWI1YzBmNGQ3ZTIxIiwicCI6MiwibiI6NTB9" } ] } ``` `totalCount` counts every contact the endpoint returns for the organization, across pages. `nextCursor` is on every `matched` result, and `null` on the organization's last page. Send the cursors that are not `null` back as items, and repeat until none are left: ```python import os, requests url = "https://api.funnelfeedr.com/external/v1/organizations/contacts/match" headers = {"Authorization": f"Bearer {os.environ['FUNNELFEEDR_API_KEY']}"} items = [ {"ref": "crm-1001", "domain": "nordviklogistik.se"}, {"ref": "crm-1002", "orgNumber": "5590123456", "countryCode": "SE"}, ] while items: body = requests.post(url, headers=headers, json={"items": items, "limit": 50}).json() items = [] for result in body["results"]: if result["status"] != "matched": continue for contact in result["contacts"]: print(result["ref"], contact["name"], contact.get("jobTitle")) if result["nextCursor"]: items.append({"ref": result["ref"], "cursor": result["nextCursor"]}) ``` Each round is one request against the `search` [rate limit](https://funnelfeedr.com/developers/rate-limits.md), however many organizations it pages. Keep a round to 25 items: add first pages of new organizations to the same request while there is room. ## Cursors are opaque A cursor is a token, not a page number. Don't decode, build or change it; pass it back exactly as you got it. It names its organization, so you can send it alone; if you send identifiers beside it, they must name the same organization. A cursor that has been altered, or whose item names another organization, makes that item `invalid` with the error `invalid_cursor` — the rest of the batch still runs. Cursors do not expire, but the contacts behind them can change between pages: a contact added, or one that no longer matches your target roles, can shift the rest of the list by a row. --- Source: https://funnelfeedr.com/developers/idempotency # Idempotency > Send an Idempotency-Key on every call that spends credits so a retry returns the first answer instead of charging twice. Networks fail. If a reveal times out you cannot know whether it was charged. An `Idempotency-Key` makes the retry safe: Funnelfeedr remembers the first successful answer for that key and returns it again instead of running the call twice. ```http POST /external/v1/contacts/reveal HTTP/1.1 Host: api.funnelfeedr.com Authorization: Bearer ff_live_... Content-Type: application/json Idempotency-Key: 9a3e7c1d-2b4f-4d8e-a6c0-5f1b3e9d7a28 ``` ## When to send one | Call | `Idempotency-Key` | |---|---| | `POST /contacts/reveal`, dry runs included | **Required.** Without it: `400 idempotency_key_required`. | | `POST /organizations/match`, `POST /people/match` or `POST /organizations/switchboards/match` that reveals (`reveal` set, `dryRun` false) | **Required.** Without it: `400 idempotency_key_required`. | | `POST /organizations/match`, `POST /people/match` or `POST /organizations/switchboards/match` without a reveal, or as a dry run | Optional. Honoured when sent. | | Every other call: the `GET`s | Ignored. | ## The key - Any 1 to 255 visible ASCII characters; anything else is `422 validation_failed`. Use a new UUID for each **logical** request. - Generate it once, before the first attempt, and reuse it for every retry of that request. - Idempotency keys are kept per API key: two integrations cannot collide. ## What happens | Situation | Result | |---|---| | First request with the key | Runs normally. A successful (`2xx`) answer is stored for 24 hours. | | Same key, same request, after it succeeded | The stored status and body, byte for byte, with the header `Idempotent-Replayed: true`. Nothing runs and nothing is charged again. | | Same key while the first request is still running | `409 idempotency_key_in_use` (`retryable: true`). Wait a moment and send it again to get the first request's answer. | | Same key, different request | `422 idempotency_key_reused`. Use a new key for a new request. | | Same key, after the first attempt **failed** | Runs again. Only successful (`2xx`) answers are stored, and a failed reveal charged nothing, so this is safe — and it is what you want after topping up following a `402`. | | After 24 hours | The key is forgotten; a repeat runs as a new request. | "Same request" means the same method, path, query string and **body bytes**, checked per API key. Re-serialising your JSON with a different key order or spacing makes it a different request. Keep the body you sent and resend exactly that. ## A retry loop ```python import os, time, uuid, requests def reveal(items, info_types, max_credits): key = str(uuid.uuid4()) # one key for every attempt of this logical request body = {"items": items, "infoTypes": info_types, "maxCredits": max_credits} for attempt in range(5): response = requests.post( "https://api.funnelfeedr.com/external/v1/contacts/reveal", headers={ "Authorization": f"Bearer {os.environ['FUNNELFEEDR_API_KEY']}", "Idempotency-Key": key, }, json=body, timeout=60, ) if response.ok: return response.json() problem = response.json() if not problem.get("retryable"): raise RuntimeError(f"{problem['code']}: {problem['detail']}") time.sleep(int(response.headers.get("Retry-After", 2 ** attempt))) raise RuntimeError("Gave up after 5 attempts") ``` `requests` serialises the same `body` dict identically on every attempt, so the retries match the first request. --- Source: https://funnelfeedr.com/developers/rate-limits # Rate limits > Per-key limits in one-minute windows across three buckets, reported in RateLimit-Policy and RateLimit headers, with Retry-After on a 429. Limits apply per API key, in fixed one-minute windows. Every request a valid key makes counts against the **overall** bucket — a refused one (`403`) too; some also count against a second one. | Bucket | Default limit | Counts | |---|---|---| | `overall` | 60 requests / minute | Every request | | `search` | 20 requests / minute | `POST /organizations/match`, `POST /organizations/contacts/match`, `POST /organizations/switchboards/match`, `POST /people/match` | | `spend` | 5 requests / minute | `POST /contacts/reveal` (dry runs included), and `POST /organizations/match`, `POST /people/match` or `POST /organizations/switchboards/match` when it reveals | `GET /credits` counts only against `overall`. An `organizations/match`, `people/match` or `organizations/switchboards/match` that reveals (`reveal` set, `dryRun` false) counts against `overall`, `search` and `spend`, also when it is refused (`400` without an `Idempotency-Key`), just as a refused `contacts/reveal` counts against `spend`. A request counts once however many items it carries, so batching 25 items per request is the way to more throughput. These are starting values; if your integration needs more, tell us at [support@funnelfeedr.com](mailto:support@funnelfeedr.com). ## Headers Every response to a valid key, errors included, reports the buckets that request counted against, following the IETF [RateLimit header fields draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/): ```http RateLimit-Policy: "overall";q=60;w=60, "search";q=20;w=60 RateLimit: "overall";r=41;t=17, "search";r=3;t=17 ``` - `RateLimit-Policy`: per bucket, the quota `q` per window of `w` seconds. - `RateLimit`: per bucket, the requests `r` remaining in this window and the seconds `t` until it resets. Slow down when `r` gets low rather than waiting for a `429`. ## When you hit a limit ```http HTTP/1.1 429 Too Many Requests Content-Type: application/problem+json Retry-After: 12 RateLimit-Policy: "overall";q=60;w=60, "spend";q=5;w=60 RateLimit: "overall";r=52;t=12, "spend";r=0;t=12 ``` ```json { "type": "https://funnelfeedr.com/developers/errors/rate_limited", "title": "Rate limit exceeded", "status": 429, "detail": "Rate limit exceeded for 'spend' (5/min). Retry after 12 seconds.", "code": "rate_limited", "retryable": true, "retryAfterSeconds": 12, "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" } ``` Wait `Retry-After` seconds, then retry. A rate-limited request did nothing: nothing was revealed or charged, so retrying it — with the same `Idempotency-Key` for a spend call — is safe. Windows are aligned to the clock minute, so a burst at the end of one minute and the start of the next can briefly see up to twice the limit. Don't build on that. --- Source: https://funnelfeedr.com/developers/errors # Errors > Every error is RFC 9457 problem+json with a stable code and a retryable flag. The full catalog, with what to do about each code. When a request fails as a whole, the response has an HTTP error status and a `Content-Type: application/problem+json` body ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)). That holds for every error under `/external/v1`, including a path that does not exist (`404`), the wrong HTTP method (`405`) and a body that is not JSON (`415`): ```json { "type": "https://funnelfeedr.com/developers/errors/insufficient_credits", "title": "Not enough credits", "status": 402, "detail": "This request needs 3.4 credits; the account has 1.2. Nothing was revealed or charged. Buy credits in Funnelfeedr under Settings → Subscription, then retry.", "code": "insufficient_credits", "retryable": false, "creditsRequired": 3.4, "creditsRemaining": 1.2, "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" } ``` | Field | Meaning | |---|---| | `code` | The stable, machine-readable error. **Branch on this.** | | `retryable` | `true` when the same request may succeed if you send it again later. | | `status` | The HTTP status, repeated. | | `title` | A short summary; always the same for a given `code`. | | `detail` | What went wrong this time, for humans. It may change; don't parse it. | | `type` | A link to this code's row in the table below. | | `traceId` | Quote it when you contact support. | | `errors` | `validation_failed` only: the messages for each invalid field, keyed by the field's JSON path (`maxCredits`, `items`, `items[3].contactId`), by `$` for the body as a whole, or by the header's name (`Idempotency-Key`). | | `creditsRequired` | `credit_cap_exceeded` and `insufficient_credits`: what the request would have cost. Nothing was spent. | | `maxCredits` | `credit_cap_exceeded` only: the `maxCredits` the request carried. | | `creditsRemaining` | `insufficient_credits` only: the account's balance when the request ran. | | `retryAfterSeconds` | `rate_limited` only: seconds until a retry can succeed, as in `Retry-After`. | | `maxItems` | `too_many_items` only: the most items a request may carry (25). | A single bad item in a batch is not an error response: it comes back inside a `200` as `"status": "invalid"` with an `error` object, and the rest of the batch runs. See [Batching](https://funnelfeedr.com/developers/batching.md). ## Catalog | `code` | Status | Retryable | Title | What to do | |---|---|---|---|---| | `invalid_api_key` | 401 | no | Invalid API key | The `Authorization` header is missing, malformed or holds a key that does not exist. Check the `FUNNELFEEDR_API_KEY` value; keys start with `ff_live_` and are 46 characters long. | | `api_key_revoked` | 401 | no | API key revoked | An admin revoked the key. Ask for a new key; do not retry. | | `api_key_expired` | 401 | no | API key expired | The key was rolled and its overlap period has ended. Switch to the replacement key. | | `feature_not_enabled` | 403 | no | The External API is not enabled for this account | API access has not been granted to the account, or has been withdrawn. Request access from Settings → API keys. | | `not_found` | 404 | no | Not found | No endpoint has this path. Check the path against the API reference. A record that does not exist is not this error: lookups answer `not_found` per item instead. | | `method_not_allowed` | 405 | no | Method not allowed | The path exists but not for this HTTP method (a `GET` on a `POST` endpoint). `detail` and the `Allow` header name the method to use. | | `unsupported_media_type` | 415 | no | The request body must be JSON | Send the body as JSON with `Content-Type: application/json`. | | `validation_failed` | 422 | no | The request is invalid | The body, a parameter or the `Idempotency-Key` header is invalid as a whole. `errors` maps each field's JSON path to its messages. Fix the request; an unusable single item comes back as `status: "invalid"` instead. | | `too_many_items` | 422 | no | Too many items in one request | Send at most 25 items per request (`maxItems` in the body). Split the batch. | | `credit_cap_exceeded` | 422 | no | The request would cost more than maxCredits | Nothing was spent. `creditsRequired` in the body is what the request would cost, `maxCredits` the cap it carried. Ask the user before raising `maxCredits`. | | `idempotency_key_required` | 400 | no | An Idempotency-Key header is required | The call spends credits (`contacts/reveal` always, dry runs included; `organizations/match`, `people/match` and `organizations/switchboards/match` when they reveal). Send an `Idempotency-Key` header with a new UUID for each logical request. | | `idempotency_key_reused` | 422 | no | Idempotency-Key was already used with a different request | The key was used in the last 24 hours for a different method, path or body. Generate a new key for a new request. | | `idempotency_key_in_use` | 409 | yes | A request with this Idempotency-Key is still in progress | A request with the same key is still running. Wait a moment and retry with the same key and body to get its response. | | `insufficient_credits` | 402 | no | Not enough credits | Nothing was spent. `creditsRequired` and `creditsRemaining` in the body say how far short the account is. Stop and tell the user; credits are bought in the app under Settings → Subscription. Do not retry until they have been. | | `rate_limited` | 429 | yes | Rate limit exceeded | Wait the number of seconds in `Retry-After` (also `retryAfterSeconds` in the body), then retry. | | `internal_error` | 500 | yes | Internal error | Retry with backoff. For a spend call, retry with the same `Idempotency-Key` so it cannot charge twice. Quote `traceId` if you contact support. | New codes may be added. Treat an unknown `code` by its `status` and `retryable`. ## Validation errors A request that is invalid as a whole is `422 validation_failed`, with each problem under `errors`, keyed by field. That includes a body that is not valid JSON (`"$": ["The body is not valid JSON (line 1, position 12)."]`), a value of the wrong type (`"items[0].contactId": ["Must be a UUID."]`, `"infoTypes[0]": ["Must be one of: email, phone, linkedIn."]`) and a malformed `Idempotency-Key` header: ```json { "type": "https://funnelfeedr.com/developers/errors/validation_failed", "title": "The request is invalid", "status": 422, "detail": "maxCredits is required when revealing. Set it to the most this request may spend, or send dryRun: true to get the price first.", "code": "validation_failed", "retryable": false, "errors": { "maxCredits": [ "maxCredits is required when revealing. Set it to the most this request may spend, or send dryRun: true to get the price first." ] }, "traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" } ``` ## Retrying - Retry only when `retryable` is `true`: `rate_limited` (after `Retry-After`), `idempotency_key_in_use` and `internal_error`. - Back off between attempts, and give up after a few. - On a call that spends, retry with the **same** `Idempotency-Key` and the same body, so a retry cannot charge twice. See [Idempotency](https://funnelfeedr.com/developers/idempotency.md). - Never retry `insufficient_credits` in a loop. Stop and tell the user. --- Source: https://funnelfeedr.com/developers/api-reference # API reference > Every endpoint, parameter, response and error of the Funnelfeedr API, rendered from the OpenAPI spec. The machine-readable contract is the OpenAPI 3 document at [/developers/openapi.json](https://funnelfeedr.com/developers/openapi.json). It is generated from the API's code, so it is always the current truth; load it into your HTTP client, code generator or agent framework. Every `operationId` is verb_noun (`match_people`, `reveal_contacts`, `get_credits`) so it can be used directly as a tool name, and operations that can spend credits carry an `x-credit-cost` extension. Base URL: `https://api.funnelfeedr.com/external/v1`. Authentication: `Authorization: Bearer $FUNNELFEEDR_API_KEY` on every request. ## Operations | Method and path | `operationId` | Spends credits | |---|---|---| | `POST /organizations/match` | `match_organizations` | When `reveal` is set and `dryRun` is not `true` | | `POST /organizations/contacts/match` | `match_organization_contacts` | No | | `POST /organizations/switchboards/match` | `match_organization_switchboards` | When `reveal` is set and `dryRun` is not `true` | | `POST /people/match` | `match_people` | When `reveal` is set and `dryRun` is not `true` | | `POST /contacts/reveal` | `reveal_contacts` | Yes, unless `dryRun` is `true` | | `GET /credits` | `get_credits` | No | Every API key can call every operation. For how these behave, read [Matching](https://funnelfeedr.com/developers/matching.md), [Credits & reveal](https://funnelfeedr.com/developers/credits-and-reveal.md), [Batching](https://funnelfeedr.com/developers/batching.md), [Pagination](https://funnelfeedr.com/developers/pagination.md), [Idempotency](https://funnelfeedr.com/developers/idempotency.md), [Rate limits](https://funnelfeedr.com/developers/rate-limits.md) and [Errors](https://funnelfeedr.com/developers/errors.md). --- Source: https://funnelfeedr.com/developers/changelog # Changelog > Changes to the Funnelfeedr API, newest first. The API is versioned in the URL (`/external/v1`). Within v1 we only make additive changes — new endpoints, new optional fields, new error codes — so build clients that ignore fields and codes they don't know. A breaking change would ship as `/external/v2`, announced here well in advance. ## 2026-10-06 — v1 The first version of the Funnelfeedr API. - Account-level API keys, created, rolled and revoked in **Settings → API keys**, each with a 30-day request log. Every key can call every endpoint, the ones that spend credits included. - Matching: `POST /organizations/match` (`matched`, `not_found`, `invalid` per item) and `POST /people/match` (also `ambiguous`). - Organization basics free on every match; the details (size, revenue, website, descriptions, technologies, keywords, tags, address) revealed with `reveal: ["details"]` on `organizations/match`, 0.2 credits per organization by default, valid for a year. - Contacts: `POST /organizations/contacts/match`, the contacts at up to 25 organizations that match the account's target contact roles (every contact when it has none), each organization paged with its own cursor. - Switchboards: `POST /organizations/switchboards/match`, an organization's main phone line, with an optional reveal of its number and email. - Reveal of email and phone: `POST /contacts/reveal` and reveal inside `people/match` and `organizations/switchboards/match`, with `dryRun` quotes, a required `maxCredits` cap and all-or-nothing charging (the organization details reveal works the same way). A contact's LinkedIn URL is never masked and never charged. - `GET /credits` for the balance, and `Funnelfeedr-Credits-Remaining` on it and on every response that can spend. - Batches of 1–25 items, RFC 9457 errors, `Idempotency-Key`, per-key rate limits with `RateLimit` headers. - Docs for agents: `llms.txt`, `llms-full.txt`, a markdown version of every page and an OpenAPI spec.