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