<!-- source: https://docs.redpine.ai/docs/preview-unlock -->
# Preview and unlock

> See what a search would return, and what it would cost, before you pay for it.

## Why preview

`POST /api/v1/search/query` charges for a search whether or not it turned out to be worth paying for. Preview removes that risk: it runs the same search pipeline, but returns teaser rows and an estimated price instead of billing you. You decide whether to pay only after seeing what's there.

* `POST /api/v1/search/preview`: free. Runs the search, returns teaser rows plus a `queryId` and an estimated cost to unlock.
* `POST /api/v1/search/unlock`: pays for and returns some or all of those results in full.

`POST /api/v1/search/query` is unchanged. Preview is additive: use it when you want to inspect relevance before spending, and keep calling `/api/v1/search/query` directly wherever that fits your flow better.

One identifier runs through both calls: the same `queryId` a search already returns. There is no second id to learn, and it works the same way `GET /api/v1/search/results/{queryId}` already does: a preview is available for 7 days.

Preview works even at a zero credit balance, since it costs nothing. It's the right call to make when deciding whether to top up. Unlock is the paywall: it's the only one of the two that spends a quota slot, and it counts as the query.

## Preview

`POST /api/v1/search/preview`

Takes the same request body as `POST /api/v1/search/query` (`collection`/`collections`, `query`, `limit`, `filters`, `include_metadata`). Every result comes back with `locked: true`, `text` holding a short teaser rather than the full chunk, and `tokens`/`cost` showing what unlocking that one row would cost. Locators (`doi`, `pmid`, `pmcid`, `url`) and full metadata are included in the free response, so you can judge relevance before paying for anything.

Preview consumes no quota and no trial query: it is unmetered on the billing side. It is rate limited per organization per hour; see [Rate limits](/docs/rate-limits) for the current default. Extra API keys on the same organization share one limit, they don't multiply it.

### Example request

<CodeBlockTabs defaultValue="cURL" groupId="lang">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="TypeScript">
      TypeScript
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Go">
      Go
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash  
    curl -X POST "https://api.redpine.ai/api/v1/search/preview" \
      -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "collection": "my-collection",
        "query": "What are the symptoms of diabetes?",
        "limit": 10
      }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python  
    from redpine import Redpine

    client = Redpine()  # reads REDPINE_API_KEY

    p = client.preview("What are the symptoms of diabetes?", collection="my-collection", limit=10)
    for row in p.results:
        print(row.id, row.locked, row.tokens, row.cost, row.text)   # teaser text while locked
    print("unlock all for", p.cost_to_unlock_remaining)
    ```
  </CodeBlockTab>

  <CodeBlockTab value="TypeScript">
    ```typescript  
    import { Redpine } from "@redpine-ai/sdk";

    const client = new Redpine(); // reads REDPINE_API_KEY

    const p = await client.preview("What are the symptoms of diabetes?", { collection: "my-collection", limit: 10 });
    for (const row of p.results) {
      console.log(row.id, row.locked, row.tokens, row.cost, row.text); // teaser text while locked
    }
    console.log("unlock all for", p.costToUnlockRemaining);
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Go">
    ```go  
    client, err := redpine.New() // reads REDPINE_API_KEY
    if err != nil {
        return err
    }

    p, err := client.Preview(ctx, "What are the symptoms of diabetes?",
        redpine.PreviewOptions{Collection: "my-collection", Limit: 10})
    if err != nil {
        return err
    }
    for _, row := range p.Results {
        fmt.Println(row.Id, row.Locked, row.Text) // teaser text while locked
    }
    fmt.Println("unlock all for", p.CostToUnlockRemaining)
    ```
  </CodeBlockTab>
</CodeBlockTabs>

`preview` takes the same `filters` as `search`, including the `F` builder from [Filtering](/docs/filtering).

### Example response

```json
{
  "queryId": "qry_a1b2c3d4e5f6",
  "results": [
    {
      "id": "abc123",
      "text": "Type 2 diabetes symptoms include increased...",
      "metadata": {
        "title": "Diabetes Overview",
        "doi": "10.1234/example"
      },
      "collection": "my-collection",
      "locked": true,
      "tokens": 214,
      "cost": "0.214000",
      "relevance": 0.812
    }
  ],
  "costToUnlockRemaining": "0.214000",
  "costCharged": null
}
```

### Retrying a preview safely

Send an `Idempotency-Key` header (1–255 characters, any value you choose) when your client might retry a preview, for example after a timeout. A repeat with the same key and the same body runs no new search: it returns the original `queryId`, and any rows you already unlocked come back in full with `locked: false`. Without a key, a retry creates a new `queryId`, and unlocking it would buy the same results again.

```bash
curl -X POST "https://api.redpine.ai/api/v1/search/preview" \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Idempotency-Key: preview-7f3a9c" \
  -H "Content-Type: application/json" \
  -d '{"collection": "my-collection", "query": "What are the symptoms of diabetes?"}'
```

* Keys are scoped to your organization and kept for 24 hours.
* The `Idempotency-Status` response header says `created` when a preview ran and `replayed` when the stored `queryId` was returned.
* The same key with a different body returns `422 IDEMPOTENCY_KEY_REUSED`. Use a new key for a new request.
* A repeat while the first request is still running returns `409 IDEMPOTENCY_KEY_IN_PROGRESS`. Retry it shortly.
* If the original preview has expired, the request runs a new preview and the key moves to it.

## Unlock

`POST /api/v1/search/unlock`

Pays for previewed results and returns them in full.

### Request body

| Parameter   | Type              | Required | Description                                                                           |
| ----------- | ----------------- | -------- | ------------------------------------------------------------------------------------- |
| `queryId`   | string            | Yes      | `queryId` from a previous `POST /api/v1/search/preview`                               |
| `resultIds` | string\[] \| null | No       | Results to unlock, by id. Omit (or pass null) to unlock every result from the preview |

### Response fields

| Field                     | Type           | Description                                                                                                                                                                                                                                                                                        |
| ------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `queryId`                 | string         | Pass this to a later `POST /api/v1/search/unlock` call for the same preview                                                                                                                                                                                                                        |
| `results`                 | array          | The full result set, filtered through the unlock ledger: rows this call (or an earlier one) paid for come back in full, the rest still teased. Every row, locked or not, carries `relevance`: how well the passage answers the query, 0 to 1. Unlocked rows also carry `chunk_index` in `metadata` |
| `costToUnlockRemaining`   | string         | Cost to unlock every result not yet unlocked                                                                                                                                                                                                                                                       |
| `costCharged`             | string \| null | What this call charged, not a running total. Null on a preview (always free) and on an unlock whose entire delta was already unlocked                                                                                                                                                              |
| `filterWarnings`          | array \| null  | Advisory warnings for filter fields with no payload index; omitted when there are none. Same diagnostics `POST /api/v1/search/query` returns. See [Search](/docs/search)                                                                                                                           |
| `journalMetricExpansions` | array \| null  | How each journal-metric filter condition resolved to ISSNs; omitted when no metric filter was used                                                                                                                                                                                                 |
| `filtersApplied`          | object \| null | The filters the preview was sent, after validation. Returned by preview only; omitted when no filters were sent                                                                                                                                                                                    |
| `discovery`               | object \| null | A catalogue answer, set only when the query asked about the catalogue rather than for literature; nothing is charged. Returned by preview only. See [Catalogue questions](/docs/collections#catalogue-questions)                                                                                   |

An unlock that buys something is the query: it spends one quota slot. A retried unlock (one whose `resultIds` were already paid for within the same `queryId`) charges nothing and burns no quota slot, so it's safe to retry.

### Example request

<CodeBlockTabs defaultValue="cURL" groupId="lang">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="TypeScript">
      TypeScript
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Go">
      Go
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash  
    curl -X POST "https://api.redpine.ai/api/v1/search/unlock" \
      -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "queryId": "qry_a1b2c3d4e5f6"
      }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python  
    u = client.unlock(p.query_id)            # every row from the preview
    for row in u.results:
        print(row.locked, row.text)           # locked is now False, text is the full chunk
    print("charged", u.cost_charged)
    ```
  </CodeBlockTab>

  <CodeBlockTab value="TypeScript">
    ```typescript  
    const u = await client.unlock(p.queryId); // every row from the preview
    for (const row of u.results) {
      console.log(row.locked, row.text);      // locked is now false, text is the full chunk
    }
    console.log("charged", u.costCharged);
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Go">
    ```go  
    u, err := client.Unlock(ctx, p.QueryId, nil) // nil: every row from the preview
    if err != nil {
        return err
    }
    for _, row := range u.Results {
        fmt.Println(row.Locked, row.Text)        // Locked is now false, Text is the full chunk
    }
    if u.CostCharged != nil {
        fmt.Println("charged", *u.CostCharged) // nil when nothing new was unlocked
    }
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Two errors are specific to unlock and have their own SDK classes: `402` when the organisation cannot pay for the delta (`InsufficientCredits`, Go `*InsufficientCreditsError`) and `410` when the preview is past its 7-day window (`Expired`, Go `*ExpiredError`). Neither is retried.

### Example response

```json
{
  "queryId": "qry_a1b2c3d4e5f6",
  "results": [
    {
      "id": "abc123",
      "text": "Type 2 diabetes symptoms include increased thirst, frequent urination, unexplained weight loss...",
      "metadata": {
        "title": "Diabetes Overview",
        "doi": "10.1234/example"
      },
      "collection": "my-collection",
      "locked": false,
      "tokens": 214,
      "cost": "0.214000"
    }
  ],
  "costToUnlockRemaining": "0.000000",
  "costCharged": "0.214000"
}
```

## Partial unlock

Pass `resultIds` to buy some rows from a preview and leave the rest locked:

<CodeBlockTabs defaultValue="cURL" groupId="lang">
  <CodeBlockTabsList>
    <CodeBlockTabsTrigger value="cURL">
      cURL
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Python">
      Python
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="TypeScript">
      TypeScript
    </CodeBlockTabsTrigger>

    <CodeBlockTabsTrigger value="Go">
      Go
    </CodeBlockTabsTrigger>
  </CodeBlockTabsList>

  <CodeBlockTab value="cURL">
    ```bash  
    curl -X POST "https://api.redpine.ai/api/v1/search/unlock" \
      -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "queryId": "qry_a1b2c3d4e5f6",
        "resultIds": ["abc123", "def456"]
      }'
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Python">
    ```python  
    # pick rows from the preview, e.g. the cheap ones
    cheap = [row.id for row in p.results if isinstance(row.tokens, int) and row.tokens < 300]
    u = client.unlock(p.query_id, result_ids=cheap)
    print("charged", u.cost_charged, "still locked for", u.cost_to_unlock_remaining)
    ```
  </CodeBlockTab>

  <CodeBlockTab value="TypeScript">
    ```typescript  
    // pick rows from the preview, e.g. the cheap ones
    const cheap = p.results.filter((row) => (row.tokens ?? Infinity) < 300).map((row) => row.id);
    const u = await client.unlock(p.queryId, cheap);
    console.log("charged", u.costCharged, "still locked for", u.costToUnlockRemaining);
    ```
  </CodeBlockTab>

  <CodeBlockTab value="Go">
    ```go  
    // pick rows from the preview, e.g. the cheap ones
    var cheap []string
    for _, row := range p.Results {
        if row.Tokens != nil && *row.Tokens < 300 {
            cheap = append(cheap, row.Id)
        }
    }
    u, err := client.Unlock(ctx, p.QueryId, cheap)
    if err != nil {
        return err
    }
    fmt.Println("still locked for", u.CostToUnlockRemaining)
    ```
  </CodeBlockTab>
</CodeBlockTabs>

Only the ids you pass are charged. Re-sending an id you already unlocked within the same `queryId` (in this call or an earlier one) costs nothing, since unlock charges the delta only: call it again later with more ids to unlock the rest of the same preview, or the same ids again with no risk of a duplicate charge within that preview. Unlocking the same result from a new preview, or receiving it again through a direct search, charges again.

`costToUnlockRemaining` is an estimate, summed per row. The amount actually charged by an unlock is computed per billing group, so the two can differ by a rounding fraction.

## Two-call example

```bash
# 1. Preview for free -- see teasers and the price
curl -X POST "https://api.redpine.ai/api/v1/search/preview" \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"collection": "my-collection", "query": "What are the symptoms of diabetes?", "limit": 10}'

# 2. Unlock the results worth paying for
curl -X POST "https://api.redpine.ai/api/v1/search/unlock" \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"queryId": "qry_a1b2c3d4e5f6", "resultIds": ["abc123"]}'
```