> ## Documentation Index
> Fetch the complete documentation index at: https://unif.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandbox

> Building and testing without spending credits.

Every workspace has a sandbox. Use a test key and the API returns fixture data, charges nothing,
and behaves identically in every other respect.

```bash theme={null}
curl -X POST https://api.unif.dev/v1/products/search \
  -H "Authorization: Bearer unif_sk_test_8f3c1a9e4b2d8065f1a3c7e9b5d2408f" \
  -H "Content-Type: application/json" \
  -d '{ "market": "US", "period": { "preset": "last_30d" } }'
```

There is no separate base URL. The key prefix — `unif_sk_test_` — decides the environment, so
switching is a matter of changing one environment variable.

## What is the same

<CardGroup cols={2}>
  <Card title="The full schema" icon="code">
    Same fields, same types, same envelope. A client written against the sandbox works unchanged
    against live data.
  </Card>

  <Card title="Validation and errors" icon="triangle-exclamation">
    Invalid parameters fail with the same codes. Coverage rules and filterability are enforced
    identically.
  </Card>

  <Card title="Pagination and jobs" icon="layer-group">
    Cursors work, jobs run through their statuses, and webhooks deliver with real signatures.
  </Card>

  <Card title="Rate limits" icon="gauge-high">
    Enforced at the same thresholds, so you can exercise your backoff path.
  </Card>
</CardGroup>

## What is different

|           | Sandbox                                                                   |
| --------- | ------------------------------------------------------------------------- |
| Data      | A fixed fixture set of about 500 shops, 5,000 products and 2,000 creators |
| Credits   | Never charged; `GET /usage` reports the live balance unchanged            |
| Freshness | `meta.refreshed_at` is fixed to the fixture's build date                  |
| Jobs      | Complete in seconds regardless of size                                    |
| Coverage  | Mirrors `tiktok_shop` / `US` for every requested market                   |

<Warning>
  Fixture data is **stable, not realistic**. It is built so that assertions do not break, which
  means it will not reproduce data-shaped problems — a market with weekly-only granularity, a
  creator with a null `agency`, an ambiguous product. Test those against live data with a small
  budget before you ship.
</Warning>

## Deterministic fixtures

The same request returns the same rows every time, so you can assert on values:

```python theme={null}
def test_search_returns_expected_shape():
    resp = client.post("/v1/products/search", json={
        "market": "US",
        "period": {"preset": "last_30d"},
        "filters": {"revenue": {"gte": 50000}},
        "sort": [{"field": "revenue", "direction": "desc"}],
        "limit": 10,
    })

    assert resp.status_code == 200
    body = resp.json()
    assert body["object"] == "list"
    assert len(body["data"]) == 10
    assert body["data"][0]["id"].startswith("prd_")
    assert body["data"][0]["metrics"]["revenue"] >= 50000
```

Known fixture IDs are listed in the dashboard under **Settings → Sandbox**, so you can write tests
against a specific shop or product rather than whatever happens to rank first.

## Exercising the paths that are hard to reach live

The fixture set includes entities that deliberately trigger each edge case.

| To test              | Use                                                  |
| -------------------- | ---------------------------------------------------- |
| `not_found`          | `https://www.tiktok.com/shop/unif-fixture-missing`   |
| `ambiguous`          | `https://www.tiktok.com/shop/unif-fixture-ambiguous` |
| `partial` enrichment | `shp_00000000000000000000partial`                    |
| `job.failed`         | A job with `"input": { "force_failure": true }`      |
| `429`                | Exceed the rate limit; it is enforced normally       |

```python theme={null}
def test_handles_not_found():
    resp = client.post("/v1/resolve", json={"inputs": [
        {"type": "shop", "url": "https://www.tiktok.com/shop/unif-fixture-missing", "ref": "x"}
    ]})

    result = resp.json()["data"][0]
    assert result["status"] == "not_found"
    assert result["id"] is None
```

<Tip>
  Point CI at the sandbox and keep live keys out of it entirely. A test suite that spends credits
  will eventually spend a lot of them on a branch nobody merged.
</Tip>

## Webhooks in the sandbox

Sandbox webhooks deliver real signed requests with their own secret, so your verification code is
exercised properly. Register an endpoint with a test key and it fires on sandbox jobs and trackers
only — sandbox events never reach a live endpoint, and vice versa.
