# Voygr POI API

> Reference for every REST operation in the Voygr POI API, rendered from the OpenAPI document at https://api.voygr.tech/openapi.json. Base URL: https://api.voygr.tech. Authentication: send the header `X-API-Key: <your key>` on every request unless an operation below says it needs none.

Validate and enrich place data. Make business phone calls with PlaceCall.


PlaceCall is the API for outbound phone calls executed by an AI voice agent.
Submit a task, then retrieve the structured outcome and transcript.

## Authentication

Send `X-API-Key` with every request. Keys carry a credit balance and usage limits.

## Placing a call

`POST /calls` accepts two shapes. Freeform uses `target_phone` and `brief`:

```json
{"target_phone": "+15551234567", "brief": "Ask when the kitchen closes tonight.", "language": "en"}
```

Structured calls use `intent` and `slots`:

| Intent | Required slots |
|---|---|
| `inquiry` | `target_phone`, `question` |
| `info_gathering` | `target_phone`, `questions` |
| `issue_resolution` | `target_phone`, `issue_description` |
| `booking` | `target_phone`, `name`, `date`, `time`, `party_size` |
| `cancellation` | `booking_id` (uses the stored booking's phone) |

An incomplete submission returns `422` listing missing slots with a
`suggested_question`: collect answers and resubmit. Validation does not dial or
charge. A balance that cannot cover the hold returns `402` even for an incomplete
submission. `language` accepts `en`, `es`, `fr`, `de`, `hi`, `ru`, `pt`, `ja`, `it`,
`nl`, `sr`, `tr`, `pl`, or `auto` (default); other codes return
`422 unsupported_language`.

For `201`, freeform returns a `call` wrapper and structured returns the flat
`SkillRunResponse`. A call accepted for later dialing returns `202`: freeform
uses `QueuedCallResponse`, structured uses `SkillRunResponse` with `status=queued`.
Poll `GET /calls/{call_id}` for either shape.

## Call lifecycle

Calls are asynchronous. Poll until `status` is `completed`, `failed`, or
`cancelled`; then read `outcome_type`, `outcome_summary`, and `transcript_full`.
There is no completion webhook. Stream `GET /calls/{call_id}/events?after_event_id=0`
and process each SSE event as it arrives. Ignore heartbeat events and comments,
keeping the last event ID when a heartbeat has no `id`. On a
disconnect while the call is active, reconnect with `after_event_id` set to the
last `id:` received; use that query cursor rather than `Last-Event-ID`.
Choose `ask_user_mode: "stream"` to receive `ask_user` events and answer through
`POST /calls/{call_id}/answer`. Cancel through `POST /calls/{call_id}/cancel`.

`GET /calls/{call_id}/transcript-merged` provides a fuller post-call transcript
including phone menus and hold announcements; `transcript_full` remains the
current conversation transcript. `202 {"status": "merger_pending"}` means it is
not available yet, not a guarantee it will become available. Retry after the
`transcript_ready` event or poll the GET. For a persistent `202`, request one
`POST /calls/{call_id}/transcript-merged/rebuild`. Do not poll the rebuild endpoint.

## Billing

A call requires 30 credits available for a refundable hold. Successful outcomes cost 10 credits; unsuccessful outcomes cost 0. At completion the hold is released and only the settled amount is charged.

`credits_reserved` is 0 for calls through api.voygr.tech, whether they start
immediately or are queued; zero does not mean no balance is held. It is not the final charge.
Read `credits_charge_on_success` for the successful-outcome charge. Insufficient
available balance returns `402`. `GET /v1/usage` reports `available` and
`call_credit_hold`; `available // call_credit_hold` is the credit-limited call
count, subject to other limits.

## Limits

Limits depend on your key; handle 429 responses and retry guidance.
Concurrent-call limits return `409` with `active_call_ids`. Maintenance returns
`503` with `resume_at`.

## Acceptable use

Calls are transactional and user-initiated only: no telemarketing,
solicitation, bulk dialing, or harassment. The agent discloses that it is an
AI assistant and that the call is recorded; disclosure is not configurable.
United States destinations only. Recordings and transcripts are retained for
a maximum of 90 days.


Credit rates: validation and enrichment are 1 credit per request. A PlaceCall call is 10 credits, charged only when the call reaches a real conversation. Credits never expire.

## Guides

- [Validate a business](https://api.voygr.tech/docs/guides/validate-business/)
- [Enrich a business](https://api.voygr.tech/docs/guides/enrich-business/)
- [Enrich a small batch](https://api.voygr.tech/docs/guides/enrich-batch/)
- [Validate businesses in bulk](https://api.voygr.tech/docs/guides/validation-jobs/)
- [Enrich businesses in bulk](https://api.voygr.tech/docs/guides/enrichment-jobs/)
- [Research custom attributes](https://api.voygr.tech/docs/guides/research-custom-attributes/)
- [Place a voice call](https://api.voygr.tech/docs/guides/place-a-call/)

## Endpoints

### POST /v1/validate

**Validate a place**

Validate a place's existence and operating status using evidence about the place.

Request body (application/json, required): `POIValidateRequest`

Responses:
- 200: Successful Response. Returns `POIValidateResponse`.
- 400: Invalid request. Correct the fields before retrying.

### POST /v1/enrich

**Enrich a place**

Return requested place attributes: opening_hours, phone, website, facebook, instagram, tiktok, email. include_summary=true also returns a short factual place_summary. Requires a key permitted to use enrichment.

Request body (application/json, required): `POIEnrichRequest`

Responses:
- 200: Successful Response. Returns `POIEnrichResponse`.
- 422: Coordinates could not be resolved to a location. Correct the coordinates or send an address.
- 400: Invalid request. Correct the fields before retrying.

### POST /v1/deep-research

**Research custom attributes for places**

Research shared attributes for a batch of places, returning values, confidence tiers, and statuses. Unavailable values are null; individual failures return error rows without failing the batch. If research is unavailable, the response is still 200 with abstained rows and meta.stopped_reason=agent_unavailable; nothing is billed. Ordinary researched fields, including abstentions, use the standard per-field rate. Requires a key permitted to use research.

### Verifying the name and address you sent (`verify_identity`)

Set `verify_identity: true` when your records may be close but not exact - an
abbreviation, an initialism, a store number, or a slightly stale spelling. We
settle what the business is actually called and where it actually is BEFORE
researching anything else, and every attribute you asked for is then researched
against the verified values rather than the ones you sent.

Each row carries an `identity` block giving, for the name and the address, the
value we settled on, a verdict, and your original. The verdict is `confirmed`
(yours was already right), `corrected` (we replaced it) or `unresolved` (we could
not establish it, so your value comes back untouched). `value` is always what the
rest of the row was researched against, so branch on the verdict rather than
comparing strings.

Formatting is not a correction: punctuation, casing, "and" vs "&", "St" vs
"Street" and a dropped or added Inc/Ltd/LLC all come back `confirmed` with your
own string.

This is not a postal validation - a `confirmed` address matches what the business
publishes about itself, not a postal database - and it makes no claim about
whether the business is still open.

Costs a flat 1 extra credit per row, independent of how many attributes you
requested.


### Owner discovery (`owner_person`, `owner_org`)

Request these as ordinary field specs. `owner_person` is the individual owner,
co-owners or franchisee; multiple owners are comma-separated in one string.
`owner_org` is the parent organisation when no individual owner exists.
They are mutually exclusive per place: when one is established, the other
abstains. "no individual owner" is a legitimate answer.

Results may be confirmed (high or medium confidence), estimated (low confidence,
treat as a lead), or unconfirmed (value is null). Unavailable ownership is a
normal result; do not interpret an abstention as a claim that no owner exists.
Owner fields use the same billing rules as other fields on this endpoint.

Request body (application/json, required): `DeepResearchRequest`

Responses:
- 200: Successful Response. Returns `DeepResearchResponse`.
- 401: Missing or invalid X-API-Key.
- 402: Not enough credits, or the estimate exceeds the caller's ceiling.
- 403: This key is not permitted to use research.
- 422: An attribute asks for something the service does not research. Remove or rephrase the named attributes and resend; nothing is charged.
- 400: Invalid request. Correct the fields before retrying.

### GET /v1/usage

**View API usage**

Get current usage, quota, and reset date for the API key.

Responses:
- 200: Successful Response. Returns `UsageResponse`.
- 401: Missing API key header. Returns `ErrorResponse`.
- 403: Invalid, revoked, or unauthorized API key. Returns `ErrorResponse`.
- 422: Validation Error. Returns `HTTPValidationError`.

### POST /v1/enrich-batch

**Enrich up to 10 places**

Enrich up to 10 places in one request. Each item behaves like /v1/enrich and is billed when it returns a billable result. Requires a key permitted to use enrichment.

Request body (application/json, required): `POIEnrichBatchRequest`

Responses:
- 200: Successful Response. Returns `POIEnrichBatchResponse`.
- 400: Invalid request. Correct the fields before retrying.

### POST /v1/jobs/validate

**Validate places in bulk**

Submit 1 to 10,000 places for asynchronous validation. Each downloaded JSON Lines row follows the POST /v1/validate response contract. Save batch_id and poll GET /v1/jobs/{batch_id} after acceptance. For an uncertain submission, retry promptly with the same Idempotency-Key and identical body. Replay only applies while the idempotency record is retained and the job is not cancelled. Reusing a key after cancellation or retention expiry can create new work and charges. While replay applies, a different body returns 409 IDEMPOTENCY_CONFLICT. Use a new key for intentional new work.

Parameters:
- `Idempotency-Key` (header, optional, string | null)

Request body (application/json, required): `ValidateJobSubmit`

Responses:
- 200: Successful Response. Returns `ValidationJobSubmitResponse`.
- 400: Invalid request. Correct the fields before retrying.

### POST /v1/jobs/enrich

**Enrich places in bulk**

Enrich up to 10,000 places asynchronously using /v1/enrich request bodies. Poll GET /v1/jobs/{batch_id} until completed, then download result.jsonl.gz: one result per input row, in order. One credit per row is held at submission; only rows with a billable value or non-null status are charged, and unused credits are released. Failed rows are free. Save batch_id and poll GET /v1/jobs/{batch_id} after acceptance. For an uncertain submission, retry promptly with the same Idempotency-Key and identical body. Replay only applies while the idempotency record is retained and the job is not cancelled. Reusing a key after cancellation or retention expiry can create new work and charges. While replay applies, a different body returns 409 IDEMPOTENCY_CONFLICT. Use a new key for intentional new work.

Parameters:
- `Idempotency-Key` (header, optional, string | null)

Request body (application/json, required): `EnrichJobSubmit`

Responses:
- 200: Successful Response. Returns `ValidationJobSubmitResponse`.
- 400: Invalid request. Correct the fields before retrying.

### GET /v1/jobs/{batch_id}

**Get batch job status and results**

Status, progress and ETA for a submitted job. When `status` is `completed`, the response carries `results.files[]` with a presigned download URL (expires in ~15 minutes - poll again for a fresh one). The file is gzipped JSONL: one result line per input row, in input order.

Parameters:
- `batch_id` (path, required, string)

Responses:
- 200: Successful Response. Returns `ValidationJobStatus`.
- 422: Validation Error. Returns `HTTPValidationError`.

### DELETE /v1/jobs/{batch_id}

**Cancel a batch job**

Request cancellation of a queued or running job (any type). Rows already processed are kept, billed, and downloadable; unprocessed rows are not charged.

Parameters:
- `batch_id` (path, required, string)

Responses:
- 200: Successful Response. Returns `ValidationJobDeleteResponse`.
- 422: Validation Error. Returns `HTTPValidationError`.

### POST /calls

**Place a call**

Place an outbound AI phone call. Two request shapes - freeform (target_phone + brief) or structured (target_phone + intent + slots). See the guide above for the 422 slot-retry loop.

Request body (application/json, required): `CallCreateRequest`

Responses:
- 201: Call created. The envelope shape depends on which request path was used - freeform returns CallResponse (a `call` wrapper), structured returns the flat SkillRunResponse (top-level call_id, no wrapper). Either way, poll GET /calls/{call_id} afterward - that response is identical regardless of which path created the call. Returns `CallResponse | SkillRunResponse`.
- 202: Call accepted for later dialing. Freeform returns QueuedCallResponse; structured returns SkillRunResponse with status=queued. Poll GET /calls/{call_id} for progress. Returns `QueuedCallResponse | SkillRunResponse`.
- 401: Missing or invalid X-API-Key.
- 402: Insufficient credits for the 30-credit refundable hold.
- 403: Key not permitted to make this request.
- 409: Concurrent-call cap reached.
- 422: Bad request body - four flavors, distinguished by error_code, plus standard Pydantic validation.
- 429: Limits depend on your key; handle 429 responses and retry guidance.
- 503: Service in a maintenance window.

### GET /calls

**List calls**

List your own calls, most recent first. Each entry omits transcript_full; fetch a single call for the transcript.

Parameters:
- `limit` (query, optional, integer, 1 to 1000, default 100)

Responses:
- 200: Successful Response. Returns `array of CallDTO`.
- 400: limit outside 1..1000.
- 401: Missing or invalid X-API-Key.

### GET /calls/{call_id}

**Get a call**

Poll a single call. Returns the full detail fields (including transcript_full) that the list endpoint omits.

Parameters:
- `call_id` (path, required, string)

Responses:
- 200: Successful Response. Returns `CallDTO`.
- 401: Missing or invalid X-API-Key.
- 404: Call not found, or isn't yours.

### POST /calls/{call_id}/answer

**Answer a mid-call question**

Answer a question the agent asked mid-call. If no answer arrives before the wait window elapses, the call proceeds and wraps up gracefully rather than stalling indefinitely.

Parameters:
- `call_id` (path, required, string)

Request body (application/json, required): `AnswerRequest`

Responses:
- 200: Successful Response. Returns `AnswerResponse`.
- 401: Missing or invalid X-API-Key.

### GET /calls/{call_id}/events

**Poll call events (SSE-formatted)**

Stream live call events as text/event-stream, parsing each SSE event as it arrives rather than waiting for the response to finish. Use after_event_id=0 initially; on reconnect, pass the last received id as after_event_id. Last-Event-ID is not supported. Heartbeat events keep idle connections alive; ignore them and SSE comments, preserving the last event ID when no id is present. Reconnect after a disconnect while the call is active, and check GET /calls/{call_id} for its terminal status. Events include status_change, ask_user, outcome, recording_ready, transcript_ready. Set ask_user_mode=stream when creating a call and answer ask_user promptly via POST /calls/{call_id}/answer.

Parameters:
- `call_id` (path, required, string)
- `after_event_id` (query, optional, integer, min 0): Return only events with event_id greater than this cursor. Start at 0; advance to the last `id:` you received.

Responses:
- 200: SSE-formatted text; empty body when no new events.
- 401: Missing or invalid X-API-Key.
- 404: Call not found, or isn't yours.

### GET /calls/{call_id}/transcript-merged

**Get the post-call merged transcript**

The post-call merged transcript contains both sides of the call on one timeline. It can include speech absent from the current transcript_full, such as phone menus and hold announcements. Read it after the call ends.

Parameters:
- `call_id` (path, required, string)

Responses:
- 200: Successful Response. Returns `MergedTranscript`.
- 202: Merged transcript not yet available. Retry after transcript_ready, or poll this GET. A 202 is not a guarantee it will become available: if it persists after the call ends, request one rebuild with POST /calls/{call_id}/transcript-merged/rebuild.
- 401: Missing or invalid X-API-Key.
- 404: Call not found or isn't yours - also returned for calls where the callee declined recording (no verbatim transcript may be served; the summary + outcome on GET /calls/{call_id} remain available).

### POST /calls/{call_id}/transcript-merged/rebuild

**Rebuild the merged transcript now**

Rebuild and return a past call's merged transcript from its recording. Use for missing or stale transcripts, including a persistent 202 from GET. Idempotent: replaces the stored transcript. Do not poll with rebuild; poll the GET or wait for transcript_ready.

Parameters:
- `call_id` (path, required, string)

Responses:
- 200: The rebuilt transcript - same body as the GET. Returns `MergedTranscript`.
- 401: Missing or invalid X-API-Key.
- 404: Call not found or isn't yours - also returned for calls where the callee declined recording (no verbatim transcript may be served).
- 425: The recording isn't available yet - the call may still be finalizing. Nothing failed; retry shortly. On an old call, though, a persistent 425 means the recording no longer exists (retention purge) and the transcript cannot be rebuilt.
- 503: The rebuild hit a dependency failure. Safe to retry.
- 504: The request timed out, but the transcript may still become available. Recover it with GET /calls/{call_id}/transcript-merged before requesting another rebuild.

### POST /calls/{call_id}/cancel

**Cancel a queued or active call**

Best-effort cancel. Returns `{"cancelled": true}` when the call was still cancellable (queued/dialing), `{"cancelled": false}` when it already reached a terminal state - safe to call idempotently.

Parameters:
- `call_id` (path, required, string)

Responses:
- 200: Successful Response
- 401: Missing or invalid X-API-Key.
- 404: Call not found, or isn't yours.

### GET /skills

**List available skills**

Discovery for the structured path: every runnable skill with its manifest/run URLs. The concierge skill owns the three POST /calls structured intents.

Responses:
- 200: Successful Response
- 401: Missing or invalid X-API-Key.

### GET /skills/{skill_id}/manifest

**Get a skill's manifest (slot schemas)**

Machine-readable contract for a skill: `input_schema` (JSON Schema for its slots), `intent_types_supported`, `event_types`, `ask_user_modes_supported`, URL templates and limits. The 422 `missing_slots` hints on POST /calls point here - fetch it once and drive slot collection from it.

Parameters:
- `skill_id` (path, required, string)

Responses:
- 200: Successful Response
- 401: Missing or invalid X-API-Key.
- 404: Unknown skill_id.

### GET /users/me

**Who am I / quota snapshot**

Identity + quota for the presented API key. Lighter-weight companion to GET /v1/usage with key metadata included.

Responses:
- 200: Successful Response
- 401: Missing or invalid X-API-Key.

### PUT /users/me/limits

**Set the concurrent-call limit**

Raise (or lower) how many calls this key may have in flight at once. Use this to recover from a 409 concurrent_call_not_allowed: read your ceiling from max_concurrent_calls_ceiling on GET /users/me, then PUT a value up to it. A value above the ceiling is refused with 403 (nothing is changed). A new key starts at 1, so raise it here when you need parallel dialing.

Request body (application/json, required): `SetLimitsRequest`

Responses:
- 200: The new limit, echoed with the ceiling it was checked against. Returns `LimitsResponse`.
- 401: Missing or invalid X-API-Key.
- 403: Requested value exceeds the admin-set ceiling on this key - nothing was changed.
- 422: Body failed validation (e.g. max_concurrent_calls below 1, or above the service hard maximum).

### GET /v1/pricing

**Get the public credit rate card**

What a call, a place lookup and a single-record validation cost, in credits, read from the gateway's own pricing. No key needed: quote a job before you spend. `operator_owned: true` marks a rate the operator sets outside this API - show it as current, not as a promise. Packs are at `/checkout/packs`; rates that depend on your key are on `GET /v1/usage`.

No authentication required.

Responses:
- 200: The rate card. Cacheable for five minutes. Returns `PricingCard`.

## Schemas

### DeepResearchFieldSpec

One requested research field. The name identifies the field; description defines the requested information and is treated as untrusted data, never instructions.

- `name` (string, required, length 1 to 100): Field identifier, e.g. wheelchair_accessible. owner_person identifies individual owners or franchisees; owner_org identifies a parent organisation when there is no individual owner. These owner fields are mutually exclusive per place.
- `description` (string (max length 500) | null): Natural-language definition of the field (treated as untrusted data)
- `value_type` (string | null): Expected value type hint: bool | enum | string | number
- `allowed_values` (array of string | null): For enum fields, the permitted values

### DeepResearchMeta

Request outcome and estimated credit cost.

- `stopped_reason` (string | null): Why the run stopped: completed | cost_capped | agent_unavailable | estimate_only
- `estimated_credits` (integer | null): Cost estimate for all requested rows before sampling or capping. Present on every response, including estimate_only requests.

### DeepResearchPOI

One place to research. Provide structured address fields, an unstructured address (also accepted as freeform_address), or coordinates. The attributes to research are shared across the request.

- `id` (string (max length 100) | null): Client-provided identifier (keys the response row)
- `poi_name` (string, required, length 1 to 200): Place name
- `street` (string | null): Street address
- `city` (string | null): City
- `region` (string | null): State/province
- `postcode` (string | null): Postal/ZIP code
- `country` (string | null): 2-letter ISO country code (e.g. US) locating the place.
- `address` (string | null): Unstructured address string. May also be sent as `freeform_address` (input-only alias).
- `latitude` (number (-90.0 to 90.0) | null): Latitude in decimal degrees (WGS84).
- `longitude` (number (-180.0 to 180.0) | null): Longitude in decimal degrees (WGS84). Provide with latitude.

### DeepResearchRequest

Research a shared list of attributes for a batch of places. A single place is a list of one. Individual row failures do not fail the whole request.

Example:

```json
{
  "pois": [
    {
      "id": "bakery-001",
      "poi_name": "Example Bakery",
      "address": "123 Example Street",
      "country": "US",
      "latitude": 37.7749,
      "longitude": -122.4194
    },
    {
      "id": "cafe-002",
      "poi_name": "Example Cafe",
      "address": "456 Sample Avenue",
      "country": "US",
      "latitude": 37.7755,
      "longitude": -122.4183
    }
  ],
  "attributes": [
    {
      "name": "outdoor_seating",
      "description": "Does the place have outdoor seating?",
      "value_type": "bool"
    },
    {
      "name": "price_band",
      "description": "Typical price level",
      "value_type": "enum",
      "allowed_values": [
        "cheap",
        "mid",
        "expensive"
      ]
    }
  ]
}
```

- `pois` (array of DeepResearchPOI, required, 1 to 10 items): Places to enrich (batch). Single-POI = a list of one.
- `attributes` (array of DeepResearchFieldSpec, required, 1 to 20 items): Attributes to research for every place. Unsupported requests can return unavailable results.
- `validate_existence` (boolean, default False): Also validate whether each place exists and return an existence attribute per row. Default false.
- `verify_identity` (boolean, default False): If true, verify the name and address you supplied before researching anything else, and research every requested attribute against the verified values rather than the ones you sent. Each row returns an `identity` block giving, for name and address, the value we settled on, a verdict (confirmed | corrected | unresolved) and your original. Costs a flat 1 extra credit per row, independent of attribute count. Default false.
- `estimate_only` (boolean, default False): Estimate without research or charges. Returns meta.estimated_credits for all requested rows and an empty rows list. Default false.
- `sample` (integer (min 1.0) | null): Cost preview: process only the first N POIs (a cheap subset) and bill only those N rows. Omit to process the whole list. Precedence: estimate_only (no run) > sample (subset) > full.
- `max_credits` (integer (min 0.0) | null): Per-request credit ceiling. If the up-front estimate (for the rows that would run - the sampled subset when `sample` is set, else all rows) exceeds this, the request is REJECTED with 402 before running and nothing is charged. The estimate is still reported.

### DeepResearchResponse

Research results in request order. Each field returns a value, confidence tier, and status; unavailable values are null. A per-place failure is a row with status=error, allowing other rows to succeed.

- `success` (boolean, required): Whether the request ran (individual rows/attributes may still be null or errored)
- `rows` (array of DeepResearchRow): Per-POI results (matrix), in request order
- `meta` (DeepResearchMeta | null): Request-level aggregate bookkeeping (see DeepResearchMeta)
- `enrichment_timestamp` (string | null): UTC timestamp (ISO 8601)
- `error` (string | null): Error message if success=false
- `error_code` (string | null): Error category code
- `request_id` (string | null): Request ID for tracing

### DeepResearchRow

One POI's result in a batch: the researched attributes + a row-level status.

A row NEVER fails the request: on a per-POI error it carries
`status="error"` + `error`, so callers get partials for the rest.

- `id` (string | null): Client-provided POI id (pass-through)
- `poi_name` (string | null): Place name (echoed from the request)
- `identity` (ResolvedIdentity | null): Verified name and address - present only when verify_identity=true. Every other attribute on this row was researched against these values.
- `status` (string, required): Row outcome: enriched | abstain | error | cost_capped
- `outcome` (string | null): 'enriched' if any attribute was confirmed, else 'abstain' (null on error)
- `fields` (object | null): Attribute name -> result (null when the row errored)
- `error` (string | null): Safe error message; set ONLY when the row failed

### EnrichJobSubmit

Submit body for ``POST /v1/jobs/enrich``.

Rows are the exact ``/v1/enrich`` request model, so a batch row behaves
identically to a single call by construction - same validation, same
attribute set, same limits.

Example:

```json
{
  "pois": [
    {
      "id": "bakery-001",
      "poi_name": "Example Bakery",
      "address": "123 Example Street",
      "country": "US",
      "latitude": 37.7749,
      "longitude": -122.4194,
      "attributes": [
        "opening_hours",
        "phone",
        "website"
      ]
    },
    {
      "id": "cafe-002",
      "poi_name": "Example Cafe",
      "address": "456 Sample Avenue",
      "country": "US",
      "latitude": 37.7755,
      "longitude": -122.4183,
      "attributes": [
        "opening_hours",
        "phone",
        "website"
      ]
    }
  ]
}
```

- `pois` (array of POIEnrichRequest, required, 1 to 10000 items): Inline POIs to enrich

### EnrichStatus

Existence results returned alongside enriched attributes. Interpret closure only when existence is established; read operational_status.status for the decision. Unavailable validation can return null fields. An operational-status failure can return HTTP 503 with operational_status_unavailable; retry the request.

- `existence_confidence` (number | null): Confidence (0-1) that a matching place ever existed at this address. A closed place can still have high existence confidence. This does not describe whether it is operating today. Interpret closure only when existence is established; use operational_status.status for the decision. Example: `0.94`

### EnrichedAttribute

One attribute's value, confidence, and as_of date. Unavailable values are null.

- `value` (object | null): Attribute payload (shape depends on the attribute); null if not found
- `as_of` (string | null): Most recent date carried by the cited evidence (ISO); null when sources are undated (most are)
- `confidence` (string | null): Confidence tier: high | medium | low. Null when the value is null.

### ErrorResponse

Error response model.

- `success` (boolean, default False): Always false for errors
- `error` (string, required): Error message
- `error_code` (string | null): Error category code (VALIDATION_ERROR, MODEL_ERROR, EXTERNAL_API_ERROR, etc.)
- `request_id` (string | null): Request ID for tracing
- `validation_timestamp` (string | null): UTC timestamp when error response was produced (ISO 8601).
- `detail` (object | null): Additional error details

### HTTPValidationError

- `detail` (array of ValidationError)

### JobProgress

- `processed` (integer, required)
- `succeeded` (integer, required)
- `failed` (integer, required)

### JobResultFile

- `url` (string, required)
- `size_bytes` (integer, required)
- `sha256` (string, required)
- `expires_at` (string, required)

### JobResults

- `format` (string, default jsonl.gz)
- `total_items` (integer, required)
- `files` (array of JobResultFile, required)

### OperationalStatusBlock

Operational status for a place. Use operational_status.status for the decision. A closed verdict is evidence-backed, not a decision based on score alone. Confidence is not a cross-status decision threshold. 'unknown' means undetermined; null means no closure assessment, not zero confidence.

- `status` (string, required, one of [open, closed, unknown, not_exists]): Operational status: 'open' means assessed as operating; 'closed' means evidence supports closure; 'unknown' means undetermined and worth re-checking; 'not_exists' means the place was not judged to exist and no closure assessment was made. closure_confidence and closure_reason are null for 'not_exists'.
- `closure_confidence` (number | null, required): Confidence in a closed verdict (0-1). Use operational_status.status for the decision; do not use confidence as a cross-status threshold or rank non-closed places by it. Null for 'not_exists' means no closure assessment, not zero confidence.
- `closure_reason` (string | null): What kind of evidence closed the place: permanent, temporary, relocated, replaced, dissolved, or unspecified. Null whenever status is not 'closed' - an open or unknown verdict has no closure to attribute.

### POIEnrichBatchRequest

Enrich up to 10 places. Each item has the same request and response behavior as a single enrichment call.

Example:

```json
{
  "pois": [
    {
      "id": "bakery-001",
      "poi_name": "Example Bakery",
      "address": "123 Example Street",
      "country": "US",
      "latitude": 37.7749,
      "longitude": -122.4194,
      "attributes": [
        "opening_hours",
        "phone",
        "website"
      ]
    },
    {
      "id": "cafe-002",
      "poi_name": "Example Cafe",
      "address": "456 Sample Avenue",
      "country": "US",
      "latitude": 37.7755,
      "longitude": -122.4183,
      "attributes": [
        "opening_hours",
        "phone",
        "website"
      ]
    }
  ]
}
```

- `pois` (array of POIEnrichRequest, required, max items 10): Places to enrich (max 10); each item carries its own attributes list, exactly like a single /v1/enrich call

### POIEnrichBatchResponse

Response model for batch enrichment.

- `success` (boolean, required): Whether the batch request ran (individual items may still fail)
- `results` (array of POIEnrichResponse, required): Per-item results; results[i] corresponds to pois[i]
- `stats` (object, required): Batch statistics (total/successful/failed)
- `enrichment_timestamp` (string | null): UTC timestamp (ISO 8601)

### POIEnrichRequest

Request model for place attribute enrichment.

Example:

```json
{
  "id": "bakery-001",
  "poi_name": "Example Bakery",
  "address": "123 Example Street",
  "country": "US",
  "latitude": 37.7749,
  "longitude": -122.4194,
  "attributes": [
    "opening_hours",
    "phone",
    "website"
  ]
}
```

- `id` (string (max length 100) | null): Client-provided identifier
- `poi_name` (string, required, length 1 to 200): Place name
- `street` (string | null): Street address
- `city` (string | null): City
- `region` (string | null): State/province
- `postcode` (string | null): Postal/ZIP code
- `country` (string | null): 2-letter ISO country code (e.g. US) locating the place.
- `address` (string (max length 500) | null): Unstructured address string. May also be sent as `freeform_address` (input-only alias).
- `latitude` (number (-90.0 to 90.0) | null): Latitude in decimal degrees (WGS84).
- `longitude` (number (-180.0 to 180.0) | null): Longitude in decimal degrees (WGS84). Provide with latitude.
- `attributes` (array of string, 1 to 9 items, default ['opening_hours', 'phone', 'website', 'existence', 'open_closed']): Attributes to return: opening_hours, phone, website, facebook, instagram, tiktok, email. Existence and open_closed are returned under status. Defaults to opening_hours, phone, website, existence, open_closed.
- `include_summary` (boolean, default False): Also return a short factual place_summary. Default false. Does not change billing: still 1 credit.
- `response_format` (string, one of [attributes, cell], default attributes): Response shape: 'attributes' (default, EnrichedAttribute) or 'cell' (the unified ResultCell shape).

### POIEnrichResponse

Response model for place attribute enrichment.

- `success` (boolean, required): Whether enrichment ran (individual attributes may still be null)
- `id` (string | null): Client-provided ID (pass-through)
- `attributes` (object | null): Requested web-extracted attribute -> result (opening_hours, phone, website, facebook, instagram, tiktok, email). EnrichedAttribute by default; the unified ResultCell when the request sets response_format='cell'.
- `status` (EnrichStatus | null): Existence and closure information; see EnrichStatus. Present when requested; null if validation is unavailable.
- `operational_status` (OperationalStatusBlock | null): Operating-status result. Use operational_status.status for the decision.
- `place_summary` (string | null): Short factual description of the place. Present only with include_summary=true; null when unavailable.
- `enrichment_timestamp` (string | null): UTC timestamp (ISO 8601)
- `error` (string | null): Error message if success=false
- `error_code` (string | null): Error category code
- `request_id` (string | null): Request ID for tracing

### POIValidateRequest

Request model for single POI validation.

Example:

```json
{
  "id": "bakery-001",
  "poi_name": "Example Bakery",
  "address": "123 Example Street",
  "country": "US",
  "latitude": 37.7749,
  "longitude": -122.4194
}
```

- `id` (string (max length 100) | null): Client-provided identifier
- `poi_name` (string, required, length 1 to 200): POI name
- `street` (string (max length 300) | null): Street address
- `city` (string (max length 200) | null): City
- `region` (string (max length 200) | null): State/province
- `postcode` (string (max length 40) | null): Postal/ZIP code
- `country` (string | null): 2-letter ISO country code
- `address` (string (max length 500) | null): Unstructured address string. May also be sent as `freeform_address` (input-only alias).
- `latitude` (number (-90.0 to 90.0) | null): Latitude in decimal degrees (WGS84). Optional but recommended.
- `longitude` (number (-180.0 to 180.0) | null): Longitude in decimal degrees (WGS84). Optional but recommended.

### POIValidateResponse

Validation results describing existence and operating status separately.

- `success` (boolean, required): Whether validation succeeded
- `id` (string | null): Client-provided ID (pass-through)
- `existence_confidence` (number | null): Confidence (0-1) that a matching place ever existed at this address. A closed place can still have high existence confidence. This does not describe whether it is operating today. Interpret closure only when existence is established; use operational_status.status for the decision.
- `operational_status` (OperationalStatusBlock | null): Operating-status result. Use operational_status.status for the decision.
- `error` (string | null): Error message if success=false
- `error_code` (string | null): Error category code (VALIDATION_ERROR, MODEL_ERROR, EXTERNAL_API_ERROR, etc.)
- `request_id` (string | null): Request ID for tracing
- `validation_timestamp` (string | null): UTC timestamp when validation was produced (ISO 8601).

### ResolvedIdentity

Per-row result of `verify_identity`. Present only when it was requested.

- `name` (ResolvedIdentityField, required): The business name we settled on
- `address` (ResolvedIdentityField, required): The address we settled on

### ResolvedIdentityField

One half of a verified identity: the name, or the address.

`value` is what we settled on and what the rest of the research ran against
- the corrected string when we corrected it, and the caller's own string
when we confirmed it or could not establish it. `original` is always what
the caller sent, so a consumer can branch on the verdict instead of diffing
strings (is "St." -> "Street" a change worth persisting? they can't tell).

Deliberately NO confidence, evidence URLs, source excerpts, candidates or
method: those are computed to DECIDE the verdict and stay internal. The
verdict is the entire public surface.

- `value` (string | null): The value we settled on and researched against
- `verdict` (string, required, one of [confirmed, corrected, unresolved]): confirmed = yours was already right; corrected = we replaced it; unresolved = we could not establish it and left yours untouched
- `original` (string | null): The value you supplied, echoed back unchanged

### ResultCell

One returned value, confidence tier, and status, returned by enrichment in cell format. Unavailable values are null.

- `value` (any | null): The field value; null when unconfirmed
- `confidence` (string | null): Trust tier: high | medium | low; null when value is null
- `status` (string, required): confirmed | estimated | unconfirmed | cost_capped | rejected. Estimated values have low confidence; filter by status if you need confirmed values only.

### UsageResponse

Response model for usage endpoint.

- `tier` (string, required): Account plan identifier associated with the API key.
- `api_key_id` (string, required): API key ID
- `customer_id` (string, required): The account this key belongs to. Stable across key rotation; key per-customer client state on this, not on api_key_id.
- `key_hint` (string | null): Last 4 characters of the key's plaintext, so the holder can recognise it. null on keys minted before this was recorded.
- `key_created_at` (string | null): When this key was minted (ISO 8601). null if unknown.
- `customer_name` (string, required): Customer name
- `quota_limit` (integer, required): Credit allowance for the current period, not a number of requests.
- `current_usage` (integer, required): Credits charged plus credits reserved by requests still in progress. Unused reservations are released on settlement.
- `remaining` (integer, required): Credits available to spend, after charges and current holds. Same value as available.
- `available` (integer | null): Credits spendable right now - same value as `remaining`, named for the question callers ask. Already net of credits frozen by in-flight reservations.
- `call_credit_hold` (integer | null): Credits ONE outbound call freezes at dial time (a refundable hold, not a charge). available // call_credit_hold is the CREDIT-limited bound on calls in flight - entitlement and the per-customer concurrency cap gate independently, so it is not a guarantee that many will start.
- `max_places` (integer | null): Total places search and import may deliver to this key (self-serve trial cap). Null when the key is uncapped.
- `places_used` (integer | null): Places delivered so far, plus those reserved by in-flight requests, against max_places. Null when uncapped.
- `contact_email` (string | null): The address a support or sales reply would be sent to - the email on this key. Empty string when the key carries none, in which case POST /v1/contact requires `reply_to`. Null from a backend that predates the field.
- `credits_per_field` (integer | null): Credits one (place × field) fill costs in deep research.
- `credits_per_place` (integer | null): Credits one found/imported place costs (places search + import).
- `open_invoices` (array of object | null): Open (unpaid) invoices raised for this key, newest first.
- `percentage_used` (number, required): Percentage of quota used
- `reset_date` (string, required): Date when usage resets (YYYY-MM-DD)
- `period` (string, required): Quota period (monthly, weekly, daily)
- `status` (string, required): API key status (active, revoked, suspended)
- `validation_timestamp` (string | null): UTC timestamp when usage response was produced (ISO 8601).

### ValidateJobSubmit

Submit 1 to 10,000 places for validation.

Example:

```json
{
  "pois": [
    {
      "id": "bakery-001",
      "poi_name": "Example Bakery",
      "address": "123 Example Street",
      "country": "US",
      "latitude": 37.7749,
      "longitude": -122.4194
    },
    {
      "id": "cafe-002",
      "poi_name": "Example Cafe",
      "address": "456 Sample Avenue",
      "country": "US",
      "latitude": 37.7755,
      "longitude": -122.4183
    }
  ]
}
```

- `pois` (array of POIValidateRequest, required, 1 to 10000 items): Inline POIs to validate

### ValidationError

- `loc` (array of (string | integer), required)
- `msg` (string, required)
- `type` (string, required)
- `input` (any)
- `ctx` (object)

### ValidationJobDeleteResponse

- `batch_id` (string, required)
- `status` (string, required, one of [queued, running, completed, failed, cancelled])

### ValidationJobStatus

Status and results for a submitted job.

- `batch_id` (string, required)
- `type` (string | null)
- `status` (string, required, one of [queued, running, completed, failed, cancelled])
- `created_at` (string, required)
- `started_at` (string | null)
- `completed_at` (string | null)
- `total_items` (integer, required)
- `progress` (JobProgress | null)
- `eta_seconds` (integer | null)
- `eta_completion_at` (string | null)
- `results` (JobResults | null)
- `error` (string | null)
- `error_code` (string | null)

### ValidationJobSubmitResponse

Accepted validation or enrichment job submission.

- `batch_id` (string, required)
- `type` (string | null)
- `status` (string, required, one of [queued, running, completed, failed, cancelled])
- `created_at` (string, required)
- `total_items` (integer, required)
- `eta_seconds` (integer, required)
- `eta_completion_at` (string, required)

### TranscriptTurn

One line of a call transcript.

- `ts` (string (date-time), required)
- `role` (string, required, one of [operator, bot, supervisor_stt, system]): operator = the callee; bot = the agent; supervisor_stt = an additional callee transcript; system = lifecycle markers.
- `text` (string, required)

### MergedTranscriptTurn

One turn of the post-call merged transcript.

- `speaker` (string, required, one of [bot, hostess]): bot = the agent; hostess = the other party - staff, receptionist or IVR (the name is historical and covers every callee-side voice).
- `start_ms` (integer, required)
- `end_ms` (integer, required)
- `text` (string, required)
- `words` (array of object): Per-word timings, when the source provides them.
- `overlap_with_next` (boolean): This turn overlapped the next one in time (cross-talk). When true, end_ms has been trimmed to the next turn's start_ms; the untrimmed value is in original_end_ms.
- `original_end_ms` (integer): The pre-trim end_ms, present only when overlap_with_next is true.
- `timing_estimated` (boolean): The text is real but the milliseconds are a reading order, not a measurement - don't plot such turns on a timeline.

### MergedTranscript

The post-call merged transcript contains both sides of the call on one timeline. It can include speech absent from the current transcript_full, such as phone menus and hold announcements. Read it after the call ends.

- `version` (integer, required): Example: `1`
- `duration_ms` (integer, required)
- `turns` (array of MergedTranscriptTurn, required)

### CallDTO

A call. GET /calls list entries omit transcript_full; fetch GET /calls/{call_id} for the transcript.

- `call_id` (string, required): Example: `c_abc123`
- `customer_id` (string, required): Example: `cus_xyz`
- `target_phone` (string, required): Example: `+15551234567`
- `language` (string, required, one of [en, es, fr, de, hi, ru, pt, ja, it, nl, sr, tr, pl, auto])
- `status` (string, required, one of [queued, dialing, in_progress, completed, failed, cancelled])
- `started_at` (string (date-time) | null)
- `ended_at` (string (date-time) | null)
- `duration_sec` (integer | null)
- `outcome_type` (string | null): Set when the call is terminal. success_* outcomes cost 10 credits; failed_* cost 0. failed_no_agent_available means no human answered before the call's hold-time limit.
- `outcome_summary` (string | null)
- `outcome_charge_cents` (integer, default 0): Settled charge in credits, despite the field name: 10 for a successful call, 0 for an unsuccessful call. This is not the refundable hold.
- `created_at` (string (date-time), required)
- `has_recording` (boolean, default False)
- `recording_url` (string | null): Relative recording path /calls/{call_id}/recording when a recording exists; null when has_recording is false. Recording retrieval is not documented here.
- `reservation_signals` (array of string | null): Short free-text signals (<10 short strings) about the reservation outcome. Always included (not detail-gated like transcript_full).
- `transcript_full` (array of TranscriptTurn | null): Only populated on GET /calls/{call_id}, not on the GET /calls list.

### CallCreateRequest

Two shapes - pick one per call. Freeform: target_phone + brief (+ optional language). Structured: intent + slots (one of inquiry, info_gathering, issue_resolution, booking, cancellation). target_phone is required on the freeform path only - the structured path reads slots.target_phone, and cancellation needs no phone at all.

- `target_phone` (string): E.164 format. Example: `+15551234567`
- `brief` (string | null): Plain-language task for the call. Required for freeform requests.
- `language` (string, one of [en, es, fr, de, hi, ru, pt, ja, it, nl, sr, tr, pl, auto], default auto): ISO 639-1 code or `auto`. Unsupported codes are refused with 422 unsupported_language.
- `intent` (string | null): Structured path only.
- `slots` (object | null): Structured path only. Required slots per intent: inquiry -> target_phone, question; info_gathering -> target_phone, questions; issue_resolution -> target_phone, issue_description; booking -> target_phone, name, date, time, party_size; cancellation -> booking_id.
- `kind` (string, one of [restaurant_booking, restaurant_cancel, doctor_appointment, hotel_booking, concierge, other], default other): Optional freeform task category; brief defines the task. Ignored for structured requests, which use intent.
- `ask_user_mode` (string, one of [any, stream], default any): Use stream to receive ask_user events through GET /calls/{call_id}/events and answer via POST /calls/{call_id}/answer. With any (default), an API-only client may not receive the question.

One of:
- Freeform: requires `target_phone`, `brief`
- Structured: requires `slots`

### CallResponse

Freeform call success envelope. A 201 means dialing has started; accepted calls can return 202. Poll GET /calls/{call_id} for progress.

- `call` (CallDTO, required)
- `credits_reserved` (integer, required): Reported call-local reservation, not the final charge or available balance. It is 0 for calls through api.voygr.tech, including immediately started and queued calls; a separate refundable balance hold still applies. Read GET /v1/usage for available credits and call_credit_hold. Example: `0`
- `credits_charge_on_success` (integer): Successful-outcome charge: 10 credits. Unsuccessful outcomes cost 0 and the hold is released. Example: `10`

### SkillRunResponse

Structured call success envelope with top-level call_id and no call wrapper. A 201 means dialing started; a 202 has status=queued. Poll status_url (GET /calls/{call_id}) for progress.

- `call_id` (string, required): Example: `c_abc123`
- `status` (string, required, one of [queued, dialing, in_progress, completed, failed, cancelled])
- `credits_reserved` (integer, required): Reported call-local reservation, not the final charge or available balance. It is 0 for calls through api.voygr.tech, including immediately started and queued calls; a separate refundable balance hold still applies. Read GET /v1/usage for available credits and call_credit_hold. Example: `0`
- `credits_charge_on_success` (integer): Successful-outcome charge: 10 credits. Unsuccessful outcomes cost 0. Example: `10`
- `status_url` (string, required): Same as GET /calls/{call_id}. Example: `/calls/c_abc123`
- `answer_url` (string, required): Example: `/calls/c_abc123/answer`
- `recording_url` (string, required): Relative path /calls/{call_id}/recording. Present even before a recording exists, unlike CallDTO.recording_url. Example: `/calls/c_abc123/recording`
- `replayed` (boolean, default False)
- `expected_next_steps` (array of string, required)

### QueuedCallResponse

Freeform call accepted with 202 for later dialing. Poll GET /calls/{call_id} until status leaves queued. Structured requests use SkillRunResponse instead.

- `call_id` (string, required): Example: `c_abc123`
- `position` (integer, required): Queue position; 0 is the front. Another call may still be starting when this is 0. Example: `0`
- `status` (string, required, one of [queued], default queued)
- `credits_reserved` (integer): Reported call-local reservation, not the final charge or available balance. It is 0 for calls through api.voygr.tech, including immediately started and queued calls; a separate refundable balance hold still applies. Read GET /v1/usage for available credits and call_credit_hold. Example: `0`
- `credits_charge_on_success` (integer): Successful-outcome charge: 10 credits. Unsuccessful outcomes cost 0. Example: `10`

### AnswerRequest

Answer a pending question. Provide exactly one of answer or outcome. outcome also requires request_id. Invalid combinations return a validation error; request_id selects the pending question.

- `answer` (string (length 1 to 4000) | null): Free text the bot will dictate to the operator (mode=voice, the default) or act on silently (mode=context). Mutually exclusive with `outcome`.
- `outcome` (string | null): Closed client-side outcome when the observed ask_user request cannot receive an answer. Requires `request_id`. Mutually exclusive with `answer`.
- `request_id` (string (max length 64) | null): Disambiguates which pending ask_user this answers, when several are in flight on the same call. Required when `outcome` is set; optional with `answer` (falls back to FIFO - oldest pending - when omitted).
- `mode` (string, one of [voice, context], default voice): 'voice' (default): the bot speaks `answer` aloud to the operator. 'context': silent guidance - the bot acts on `answer` without voicing it (e.g. concierge IVR navigation hints). Only meaningful with `answer`, not `outcome`.

One of:
- Option 1: requires `answer`
- Option 2: requires `outcome`, `request_id`

### AnswerResponse

- `delivered` (boolean, required)
- `reason` (string, required, one of [delivered, no_pending_request]): 'delivered' - a transport took the answer. 'no_pending_request' - nothing was pending under this request_id (already answered, expired (~60s), or unknown) - the answer was silently dropped (not an error; a stale retry lands here safely).

### SetLimitsRequest

Body for PUT /users/me/limits. Raise (or lower) the number of calls this key may have in flight at once.

- `max_concurrent_calls` (integer, required, min 1): How many calls this key may have in flight simultaneously (scheduled / dialing / in_progress count as in-flight). Required, with no default. Must be at least 1, and no more than the admin-set ceiling on the key (max_concurrent_calls_ceiling on GET /users/me) - a higher value is refused with 403. Example: `1`

### LimitsResponse

PUT /users/me/limits success body: the new in-flight cap and the admin ceiling it was checked against.

- `max_concurrent_calls` (integer, required): The value now in effect for this key. Example: `1`
- `max_concurrent_calls_ceiling` (integer, required): Admin-set upper bound for this key. max_concurrent_calls can never exceed it. Example: `1`

### PricingRate

One metered thing and what it costs, in credits.

- `key` (string, required): Stable machine name for the rate; render by key, not by label.
- `label` (string, required)
- `credits` (integer | null)
- `credits_range` (array of integer | null)
- `per` (string, required): The unit: answered call, request.
- `hold` (integer | null)
- `operator_owned` (boolean, required): true: the settle price is set by the operator outside this API and this number sizes the hold. Show it as current, never as a promise.
- `routes` (array of string): Exactly which operations this rate covers, when it covers a subset.
- `note` (string | null)

### PricingCard

The public credit rate card. Anonymous; read it before you hold a key.

- `unit` (string, required)
- `rates` (array of PricingRate, required)
- `packs_url` (string, required)
- `docs_url` (string)
- `per_key_rates_url` (string): Rates that depend on the key (Workbench places, deep research) live here.

Machine-readable version of this document: https://api.voygr.tech/openapi.json
