Skip to main content

Contact Verifications

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

7 min read
View MarkdownEdit on GitHub

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

Every operation below requires a Bearer token. See Authentication.

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

POSThttps://api.codespar.dev/v1/consumers/{consumerId}/contact-verifications

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

NameTypeRequiredDescription
consumerIdstringyes—

Request body

FieldTypeRequiredDescription
channel"email" | "phone"yes—
localestringno—
targetstringyes—

Responses

StatusBodyDescription
201objectOK
400objectinvalid_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.
422objectno_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.
429objecttoo_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.
500object | objectTwo 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.
503objectotp_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

FieldTypeRequiredDescription
channel"email" | "phone"yes—
expires_atstring (date-time)yesMAY 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.
idstring (uuid)yesSend this back in the /verify step.
provider"dev" | "prelude" | "resend"yesWhich 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_maskedstringyesThe masked contact. The full value never comes back: the row keeps a keyed hash.
Example request
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"
     }'
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"
}
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()
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();
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);
}
Example response 201
application/json
{
  "id": "obj_0000000000000000",
  "channel": "email",
  "target_masked": "string",
  "provider": "dev",
  "expires_at": "2026-01-15T12:00:00.000Z",
  "status": "sent"
}

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

POSThttps://api.codespar.dev/v1/consumers/{consumerId}/contact-verifications/{id}/verify

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

NameTypeRequiredDescription
consumerIdstringyes—
idstring (uuid)yes—

Request body

FieldTypeRequiredDescription
codestringyes—
targetstringno—

Responses

StatusBodyDescription
200objectOK
400objectinvalid_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.
404objectnot_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.
410objectverification_expired: the code expired, or a later send superseded it, or the sender itself reports it expired. Start a new verification.
429objecttoo_many_attempts: this verification has spent every attempt. There is no reset; start a new one.
500objectcontact_verification_error: an unclassified failure while checking. This operation reads nothing outside its own error handling, so this is its only 500 body.
503objectotp_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

FieldTypeRequiredDescription
idstring (uuid)yes—
status"verified"yes—
verified_atstring (date-time)yes—
Example request
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"
     }'
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"
}
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()
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();
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"
  }
});
Example response 200
application/json
{
  "id": "obj_0000000000000000",
  "status": "verified",
  "verified_at": "2026-01-15T12:00:00.000Z"
}
Contact Verifications | CodeSpar