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

# Enrich a citation database with source text

> Batch-resolve a column of citations, fall back to search for the rows that are only topics, and attach a canonical citation, excerpt, and official source URL

**What this gets you:** turn a messy spreadsheet or database of topics and half-formed citations into a clean table of verified law, each row backed by a real citation and an official source link, without doing it by hand.

Most rows in a spreadsheet like this already carry a citation, however roughly it is typed.
Those do not need searching, they need resolving, and `POST /us/statutes/resolve` does 50 of them per call.
The rows that carry only a topic are the ones that need `POST /us/statutes/search`.
Split your inputs that way and the job is a handful of calls instead of one per row.

**Endpoints used:** `POST /us/statutes/resolve` (up to 50 citations per call), `POST /us/statutes/search` for topic-only rows, and optionally `GET /us/statutes/section/{actId}/body` for full text.

<Warning>
  Batching is what keeps you inside your plan's rate limits: 500 citations is
  10 resolve calls, not 500 search calls. Honor the `Retry-After` header on a
  `429`. See [Errors](/docs/api-guide/errors).
</Warning>

## Start with the rows that carry a citation

Chunk the citation column into batches of 50 and resolve them.
Every input gets an entry back, in order, so an unresolved row is flagged rather than silently dropped.

```python Python theme={"theme":"github-dark"}
import requests

BASE_URL = "https://api.vaquill.ai/api/v1/us"
HEADERS = {
    "Authorization": "Bearer vq_key_...",
    "Content-Type": "application/json",
}

BATCH_SIZE = 50  # the API's per-call maximum


def resolve_citations(citations: list[str]) -> dict[str, dict | None]:
    """Map each input citation to its resolved section, or None."""
    out: dict[str, dict | None] = {}
    for i in range(0, len(citations), BATCH_SIZE):
        chunk = citations[i : i + BATCH_SIZE]
        resp = requests.post(
            f"{BASE_URL}/statutes/resolve", headers=HEADERS, json={"citations": chunk}
        )
        resp.raise_for_status()
        for item in resp.json()["results"]:
            out[item["inputCitation"]] = item["section"] if item["resolved"] else None
    return out
```

Scope the whole batch with `state` or `corpusType` when every row belongs to one jurisdiction or corpus.
A spreadsheet that spans jurisdictions should omit both and let each citation name its own.

<Note>
  Duplicates are collapsed before pricing, so a column with the same citation on
  forty rows costs one resolution. Read `creditsConsumed` on the response rather
  than multiplying 2 by your row count.
</Note>

<Note>
  A resolved row has no `excerpt`: an excerpt is windowed around a query match,
  and resolving a citation runs no query. Take the excerpt from the search path,
  or fetch the body when a resolved row needs quotable text.
</Note>

## Then search the rows that are only a topic

A row with no citation to resolve needs a search.
Scope with `corpusType` and `state` where you know them, and use `fields` to return only what you are writing back onto the record.

```python Python theme={"theme":"github-dark"}
INPUTS = [
    {"topic": "civil rights deprivation under color of law", "corpusType": "USC"},
    {"topic": "security deposit return deadline", "corpusType": "STATE", "state": "ca"},
    {"topic": "hazard communication standard", "corpusType": "CFR"},
]

# Ask for only the fields you store. A full result carries 40+ fields, most of
# them null on any given row. `actId` and `citation` always come back.
ENRICH_FIELDS = ["title", "excerpt", "state", "govInfoHtmlUrl", "stateHtmlUrl", "htmlUrl"]


def search_topic(item: dict) -> dict | None:
    payload = {"query": item["topic"], "limit": 3, "fields": ENRICH_FIELDS}
    for key in ("corpusType", "state"):
        if key in item:
            payload[key] = item[key]
    resp = requests.post(
        f"{BASE_URL}/statutes/search", headers=HEADERS, json=payload
    )
    resp.raise_for_status()
    results = resp.json()["results"]
    return results[0] if results else None
```

<Tip>
  Reach for `matchType: "phrase"` when the topic column holds a defined term or a
  statutory phrase rather than a question. It forces an exact-phrase match
  instead of hybrid semantic ranking, which is what you want when the wording is
  the identifier.
</Tip>

## Attach the source link and write the row back

Every match carries several optional source URL fields, and any of them may be null.
Use the canonical preference order in [Text Formats and Source URLs](/docs/api-guide/concepts/formats#picking-the-best-available-format) rather than writing your own, so a citation gets the same official link everywhere in your product.

```python Python theme={"theme":"github-dark"}
# best_source_url() is the shared helper from the page linked above.
# Copy it once and reuse it; do not re-derive the field order per recipe.


def enrich(rows: list[dict]) -> list[dict]:
    """rows: dicts with either a 'citation' or a 'topic' key."""
    cited = [r["citation"] for r in rows if r.get("citation")]
    resolved = resolve_citations(cited)

    enriched = []
    for row in rows:
        if row.get("citation"):
            match = resolved.get(row["citation"])
        else:
            match = search_topic(row)

        enriched.append(
            {
                **row,
                "actId": match.get("actId") if match else None,
                "canonicalCitation": match.get("citation") if match else None,
                "excerpt": match.get("excerpt") if match else None,
                "sourceUrl": best_source_url(match) if match else None,
            }
        )
    return enriched
```

Example output:

```text theme={"theme":"github-dark"}
42 USC 1983
  -> 42 U.S.C. § 1983  https://...

security deposit return deadline
  -> Cal. Civ. Code § 1950.5  https://...

42 U.S.C. 19833
  -> (unresolved, flag for review)
```

## Optionally attach full text

When you need the whole section rather than the excerpt, take the `actId` from a match and call the body endpoint.
It returns text only when it is available, and charges nothing when it is not.

<CodeGroup>
  ```bash cURL theme={"theme":"github-dark"}
  curl https://api.vaquill.ai/api/v1/us/statutes/section/USC_T42_C21_S1983/body \
    -H "Authorization: Bearer vq_key_..."
  ```

  ```python Python theme={"theme":"github-dark"}
  def fetch_body(act_id: str) -> str | None:
      resp = requests.get(
          f"{BASE_URL}/statutes/section/{act_id}/body",
          headers={"Authorization": HEADERS["Authorization"]},
      )
      resp.raise_for_status()
      data = resp.json()
      return data["plain"] if data.get("available") else None
  ```
</CodeGroup>

<Note>
  Bodies are the expensive half of the job, so fetch them
  only for the rows that need quotable text. If you are enriching a topic row and
  know up front you want the text, `"includeBody": true` on the search returns it
  inline for the same 4 + 6 per row, saving the round trip rather than the money.
</Note>

<Note>
  Store the returned `actId` on your record. It is the stable handle for that section, so a later job can re-fetch metadata or full text without searching or resolving again.
</Note>

## Related

<CardGroup cols={2}>
  <Card title="Resolve a citation" icon="quote-right" href="/docs/api-guide/recipes/citation-lookup">
    The resolve routes in full, including what an unresolved verdict means.
  </Card>

  <Card title="Compare a rule across states" icon="scale-balanced" href="/docs/api-guide/recipes/multi-state-comparison">
    Run one query across several states and build a comparison.
  </Card>

  <Card title="Add the corpus as an agent tool" icon="plug" href="/docs/api-guide/recipes/mcp-tool">
    Expose statute search to a tool-calling agent.
  </Card>

  <Card title="Monitor corpus coverage" icon="chart-line" href="/docs/api-guide/recipes/coverage-monitoring">
    Detect when new corpora appear so you can re-enrich.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.