---
title: "Contact Verifications"
description: "2 operations under /v1/consumers/{consumerId}/contact-verifications (POST): parameters, status codes, refusal bodies, and the same request in curl, HTTP, Python and TypeScript."
---

Base URL: `https://api.codespar.dev`

Every operation below requires a Bearer token. See [Authentication](/docs/concepts/authentication).

### POST `/v1/consumers/{consumerId}/contact-verifications`

<Endpoint method="POST" path="/v1/consumers/{consumerId}/contact-verifications" base="https://api.codespar.dev" />

Send a one-time code to a consumer's e-mail address or phone number

Proves possession of a contact, which is the pre-condition for registering an EMAIL or PHONE Pix key while that check is enforced on the deployment. Sends a code and returns the verification row. THE CODE IS NEVER IN THE RESPONSE, and never in a log line at info level: the target receives it, and nobody else.

`target` is accepted in its natural spelling and canonicalized before anything else: an e-mail address is lower-cased, a phone number keeps only the digits behind its `+` and must be E.164 with a country code. A spelling that does not canonicalize answers 400 `invalid_target`.

THE LIMIT IS PER CONTACT, NOT PER CONSUMER: the count is over (organization, contact) in the last hour, so rotating `consumerId` buys no extra sends. Exceeding it answers 429 `too_many_requests`, with the window and the ceiling in `details`.

A SEND SUPERSEDES THE LIVE ONE for the same contact: the previous verification is closed and only the new code counts. If that happens while this send is in flight, the 201 comes back with `expires_at` already in the past, and that is deliberate — two live codes would double the guessing budget.

A `consumerId` the calling organization does not hold answers 422 `no_provider_account`, the same answer as an own consumer that has not finished onboarding. The route does not separate the two, so it cannot be used to probe for ids.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `consumerId` | `string` | yes | — |

**Request body**

| Field | Type | Required | Description |
|---|---|---|---|
| `channel` | `"email"` \| `"phone"` | yes | — |
| `locale` | `string` | no | — |
| `target` | `string` | yes | — |

**Responses**

| Status | Body | Description |
|---|---|---|
| `201` | object | OK |
| `400` | object | `invalid_body`: the body did not match the schema, and `details.issues` carries the validation errors. `invalid_target`: the contact does not canonicalize for this channel. |
| `422` | object | `no_provider_account`: the consumer has no active pix-celcoin funding source in this organization — or is not this organization's consumer at all. `otp_sender_refused`: the sender refused to deliver to this number and repeating will not change that; `details.provider` names which sender said so. Nothing was sent, and no attempt was consumed, in either case. |
| `429` | object | `too_many_requests`: the per-contact hourly ceiling was reached. `details.window_seconds` and `details.max_sends` give the window and the ceiling. Nothing was sent. |
| `500` | object \| object | Two different bodies, because two different failures land here. `contact_verification_error` in the usual error envelope: an unclassified failure while starting. Nothing was sent that could be verified. THE OTHER SHAPE IS NOT THAT ENVELOPE. Resolving the consumer's account runs before the handler's own error handling, so a failure there is answered by the framework's default: `{ statusCode, error, message }`, plus `code` when the underlying failure carried one. A client that parses `error.code` will find `error` is a STRING here. Nothing was sent in this case either. |
| `503` | object | `otp_sender_unavailable`, and the three conditions behind it DO NOT agree on whether a retry is free. 1. No sender is configured for this channel on the deployment. Nothing was written and nothing was sent; retrying changes nothing until the deployment does. 2. The send itself did not complete. The verification row is removed, so the attempt does not count against the hourly ceiling and a retry is free. Removal is best effort: if it fails the row stands, unverifiable, and does consume one of the hour's sends. 3. THE CODE REACHED THE CONTACT and the row could not be completed. The row STANDS and DOES count against the hourly ceiling, and the consumer may receive a code that can never be verified. `details.verification_id` is present, and it is present ONLY in this case — that is how a caller tells the free retry from the expensive one. |

**Response `201`**

| Field | Type | Required | Description |
|---|---|---|---|
| `channel` | `"email"` \| `"phone"` | yes | — |
| `expires_at` | `string (date-time)` | yes | MAY ALREADY BE IN THE PAST. A second start for the same contact supersedes the earlier one by closing its validity, and if that lands while this send is in flight the row keeps the closed expiry and this field reports it. A superseded code answers 410 on verify. |
| `id` | `string (uuid)` | yes | Send this back in the /verify step. |
| `provider` | `"dev"` \| `"prelude"` \| `"resend"` | yes | Which sender delivered, and it decides the /verify step: `prelude` checks the code against the contact itself, so `target` is REQUIRED there. The other two are checked locally and do not need it. |
| `status` | `"sent"` | yes | — |
| `target_masked` | `string` | yes | The masked contact. The full value never comes back: the row keeps a keyed hash. |

<Split min={380}>
<SplitPane label="Example request">

<Tabs items={["curl","HTTP","Python","TypeScript","SDK"]}>
<Tab value="curl">

```bash
curl -X POST https://api.codespar.dev/v1/consumers/{consumerId}/contact-verifications \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "channel": "email",
       "target": "string",
       "locale": "string"
     }'
```

</Tab>
<Tab value="HTTP">

```http
POST /v1/consumers/{consumerId}/contact-verifications HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "channel": "email",
  "target": "string",
  "locale": "string"
}
```

</Tab>
<Tab value="Python">

```python
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/consumers/{consumerId}/contact-verifications",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "channel": "email",
      "target": "string",
      "locale": "string"
    },
)
res.raise_for_status()
data = res.json()
```

</Tab>
<Tab value="TypeScript">

```ts
const res = await fetch("https://api.codespar.dev/v1/consumers/{consumerId}/contact-verifications", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "channel": "email",
    "target": "string",
    "locale": "string"
  }),
});

const data = await res.json();
```

</Tab>
<Tab value="SDK">

```ts
const r = await cs.api.response("post", "/v1/consumers/{consumerId}/contact-verifications", {
  path: {
    consumerId: "csm_0000000000000000"
  },
  body: {
    channel: "email",
    target: "string",
    locale: "string"
  }
});
// r.status is one of the documented statuses (200, 422),
// each with its own body shape in r.data; nothing here throws on 422.
if (r.ok) {
  console.log(r.data);
}
```

</Tab>
</Tabs>

</SplitPane>
<SplitPane label="Example response 201">

```json title="application/json"
{
  "id": "obj_0000000000000000",
  "channel": "email",
  "target_masked": "string",
  "provider": "dev",
  "expires_at": "2026-01-15T12:00:00.000Z",
  "status": "sent"
}
```

</SplitPane>
</Split>

<TryIt method="POST" path="/v1/consumers/{consumerId}/contact-verifications" body={"{\"channel\":\"email\",\"target\":\"string\",\"locale\":\"string\"}"} />

### POST `/v1/consumers/{consumerId}/contact-verifications/{id}/verify`

<Endpoint method="POST" path="/v1/consumers/{consumerId}/contact-verifications/{id}/verify" base="https://api.codespar.dev" />

Check the code the consumer received

Closes the proof of possession. `code` is the digits that arrived at the contact.

`target` is REQUIRED when the send went out through a sender that checks the code against the contact itself — the `provider` the start step returned is `prelude`. That sender is keyed by the contact, and we hold only a keyed hash of it. Omitting `target` answers 400 `invalid_target`; sending a contact that does not hash to the row's answers 404, not 403.

AN ATTEMPT IS RESERVED BEFORE THE CODE IS COMPARED, which is what makes the ceiling hold when guesses arrive concurrently — and it splits the failures in two.

COSTS NOTHING (raised before the reservation): a missing or malformed `target`; a `code` that is not 4 to 8 digits; and the 503 branch where no sender for this row's provider exists on the deployment.

COSTS AN ATTEMPT (from the reservation on): a wrong code, a `target` that does not hash to the row's, and the 503 branch where the provider did not answer. Wherever an attempt was spent the response carries `details.attempts_left`.

IDEMPOTENT ON SUCCESS: a row that is already verified answers 200 again, with the original `verified_at` and without spending an attempt.

An `id` that is not a UUID answers 404 before any query runs, and a row belonging to another consumer or another organization answers the same 404.

**Path parameters**

| Name | Type | Required | Description |
|---|---|---|---|
| `consumerId` | `string` | yes | — |
| `id` | `string (uuid)` | yes | — |

**Request body**

| Field | Type | Required | Description |
|---|---|---|---|
| `code` | `string` | yes | — |
| `target` | `string` | no | — |

**Responses**

| Status | Body | Description |
|---|---|---|
| `200` | object | OK |
| `400` | object | `invalid_body`: the body did not match the schema (`details.issues`). `invalid_code`: the code is not 4-8 digits, or it does not match; `details.attempts_left` says what is left, and only the second of those spent an attempt. `invalid_target`: this verification is checked against the contact and `target` did not come with the code. |
| `404` | object | `not_found`: there is no verification with this id for this consumer. It covers a malformed id, a row of another consumer, a row of another organization, a row whose send never completed, and a `target` that does not hash to the row's — that last one having spent an attempt. |
| `410` | object | `verification_expired`: the code expired, or a later send superseded it, or the sender itself reports it expired. Start a new verification. |
| `429` | object | `too_many_attempts`: this verification has spent every attempt. There is no reset; start a new one. |
| `500` | object | `contact_verification_error`: an unclassified failure while checking. This operation reads nothing outside its own error handling, so this is its only 500 body. |
| `503` | object | `otp_sender_unavailable` covers two conditions that cost different things. THE PROVIDER DID NOT ANSWER the check. The attempt was reserved before the call, so it IS spent and is not given back — returning it would reopen the race the reservation closes. `details.attempts_left` says how many remain. NO SENDER FOR THIS ROW'S PROVIDER exists on this deployment. The verify goes back to whichever sender delivered, whatever the deployment is configured for today, and this check runs BEFORE the reservation: NO attempt was spent and the response carries no `details` at all. Nothing the caller does resolves it — start a new verification, which will be sent through a sender that exists. |

**Response `200`**

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | `string (uuid)` | yes | — |
| `status` | `"verified"` | yes | — |
| `verified_at` | `string (date-time)` | yes | — |

<Split min={380}>
<SplitPane label="Example request">

<Tabs items={["curl","HTTP","Python","TypeScript","SDK"]}>
<Tab value="curl">

```bash
curl -X POST https://api.codespar.dev/v1/consumers/{consumerId}/contact-verifications/{id}/verify \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
       "code": "string",
       "target": "string"
     }'
```

</Tab>
<Tab value="HTTP">

```http
POST /v1/consumers/{consumerId}/contact-verifications/{id}/verify HTTP/1.1
Host: api.codespar.dev
Authorization: Bearer $CODESPAR_API_KEY
Content-Type: application/json

{
  "code": "string",
  "target": "string"
}
```

</Tab>
<Tab value="Python">

```python
import os
import requests

res = requests.post(
    "https://api.codespar.dev/v1/consumers/{consumerId}/contact-verifications/{id}/verify",
    headers={"Authorization": f"Bearer {os.environ['CODESPAR_API_KEY']}"},
    json={
      "code": "string",
      "target": "string"
    },
)
res.raise_for_status()
data = res.json()
```

</Tab>
<Tab value="TypeScript">

```ts
const res = await fetch("https://api.codespar.dev/v1/consumers/{consumerId}/contact-verifications/{id}/verify", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.CODESPAR_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "code": "string",
    "target": "string"
  }),
});

const data = await res.json();
```

</Tab>
<Tab value="SDK">

```ts
const result = await cs.api.post("/v1/consumers/{consumerId}/contact-verifications/{id}/verify", {
  path: {
    consumerId: "csm_0000000000000000",
    id: "contactverification_0000000000000000"
  },
  body: {
    code: "string",
    target: "string"
  }
});
```

</Tab>
</Tabs>

</SplitPane>
<SplitPane label="Example response 200">

```json title="application/json"
{
  "id": "obj_0000000000000000",
  "status": "verified",
  "verified_at": "2026-01-15T12:00:00.000Z"
}
```

</SplitPane>
</Split>

<TryIt method="POST" path="/v1/consumers/{consumerId}/contact-verifications/{id}/verify" body={"{\"code\":\"string\",\"target\":\"string\"}"} />

