Authentication
Veridia uses bearer token authentication:
Authorization: Bearer qv_pub_FJJWXMA2RN2XPRDK6YJX4KTVD0XSQHW9
Two endpoints are the exception: PUT /v1/verify/upload/... and GET /v1/verify/challenge/.../next do not take an API key at all. They authenticate with the short-lived upload token that /init hands out. See Uploading the images.
Key families
| Family | Use from | Can do | Cannot do |
|---|---|---|---|
| Publishable | Browser, widget, mobile app | POST /v1/verify/init, POST /v1/verify/submit | Read verdicts |
| Secret | Your server only | Everything a publishable key can, plus GET /v1/verify/:id | — |
The publishable key is safe to ship in your page. It is designed for that. It can start verifications against your balance, and it can submit them — but it can never read a result.
The secret key is never safe to ship. It can fetch the verdict, extracted identity fields and scores for any verification in your tenant. Treat it like a database password.
Why the split exists
A publishable key is, by definition, readable by anyone who opens your page source. If that key could call GET /v1/verify/:id, then every one of your customers' KYC outcomes — verdict, confidence, document number, date of birth — would be one fetch away for anyone with developer tools open.
So the results endpoint refuses publishable keys with a dedicated error code, secret_key_required, rather than a generic auth failure. The code exists specifically so that you are not sent hunting for a typo in a key that is perfectly valid.
Environments
Every tenant gets two parallel sets of keys. Note the test prefixes carefully — they are qv_pubt_ and qv_sect_, with the t before the underscore. Not qv_pub_test_.
| Environment | Publishable | Secret |
|---|---|---|
| Test | qv_pubt_... | qv_sect_... |
| Live | qv_pub_... | qv_sec_... |
A test key runs the entire wire, none of the work. Every request takes the same journey as production — real upload authentication, a real verification row, a real webhook signed with your real secret and retried on the same ladder, readable with your test secret key — but no OCR runs, no ML pipeline runs, no credit is spent, and the verdict is chosen by you, deterministically:
userRef contains | Verdict | Webhook event |
|---|---|---|
+reject | rejected | verification.rejected |
+review | review | verification.review |
| anything else | approved | verification.approved |
{ "userRef": "qa-user-17+reject", "documentType": "passport", "country": "PY" }
Deterministic outcomes mean your CI can assert on each webhook branch instead of hoping the pipeline feels the same way twice. The images you upload still have to be real JPEGs within the size limits — the byte path is exercised on purpose — but their content is ignored: extracted fields come back as unmistakable placeholders (TEST PERSONA, TEST-000000).
Test and live keys deliver to the same per-tenant webhook URL. Every event envelope therefore carries an env field — "test" or "live" — and your handler must branch on it. A synthetic verification.approved that activates a real account is the exact accident this field exists to prevent.
Recommended workflow: build against test keys, assert on all three outcomes in CI, then switch to live keys in production. Never use a live key on staging.
Domain whitelist (publishable keys)
A publishable key can carry an allowed origins list. When that list is non-empty, a browser request whose Origin is not on it is rejected with origin_not_allowed (403).
Entries are matched against the bare hostname — no scheme, no port:
yourapp.com
staging.yourapp.com
localhost
http://localhost:3000 is not a valid entry. It will never match anything, because the check compares against the hostname localhost.
Wildcards work: *.yourapp.com matches app.yourapp.com but deliberately does not match yourapp.com itself. List both if you need both.
The guarantee runs the opposite way to most people's expectations.
- Every key is created with an empty list, and an empty list allows every origin. The check is skipped entirely when the list is empty. It is opt-in, not opt-out. The dashboard does not currently expose a field to populate it.
- It only applies to browser requests. curl, a server, a script, or any of our server-side SDKs sends no
Originheader, so there is nothing to compare and the request proceeds. It can never restrict non-browser use. - Adding your first entry flips the key from "any origin" to "this list only." If you add
https://yourapp.com— with the scheme, which will not match — you go from everything allowed to everything blocked in one step. Add the bare hostname.
What this list actually does is scope where your widget may run. It is not an access control. What protects a publishable key is that it cannot read results, plus your rate limits and credit balance.
Treat a publishable key as public, because it is.
Using the secret key
Server-side only. Every example below fetches a verdict.
curl
curl -X GET https://api.xxuxe.online/v1/verify/vf_AG07CDWRRFQV4T05ZXG2 \
-H "Authorization: Bearer qv_sec_YOUR_SECRET"
In test mode the key starts with qv_sect_.
JavaScript / Node.js
const response = await fetch(`https://api.xxuxe.online/v1/verify/${id}`, {
headers: {
'Authorization': `Bearer ${process.env.VERIDIA_SECRET_KEY}`,
},
});
const data = await response.json();
Python
import os, requests
response = requests.get(
f"https://api.xxuxe.online/v1/verify/{verification_id}",
headers={"Authorization": f"Bearer {os.environ['VERIDIA_SECRET_KEY']}"},
)
data = response.json()
PHP
<?php
$ch = curl_init("https://api.xxuxe.online/v1/verify/$verificationId");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . $_ENV['VERIDIA_SECRET_KEY'],
]);
$data = json_decode(curl_exec($ch), true);
curl_close($ch);
Replacing a key
The dashboard supports two operations: create and revoke. There is no rotate, and there is no grace period.
Revoking a key deletes it from the edge store at once. Every in-flight request using it starts failing with invalid_api_key in the same instant — there is no overlap window during which the old key keeps working.
So the safe order is: create the new key first, deploy it everywhere, confirm traffic is flowing on it, and only then revoke the old one. Doing it the other way round is an outage.
If you have a suspected leak, that is exactly when you want the immediate cut — revoke first and accept the interruption. Just do it knowingly.
Best practices
- Never commit secret keys. Environment variables or a secrets manager.
- Separate keys per environment. Never a live key on staging.
- Create-then-revoke when replacing a key, for the reason above.
- Log the
requestIdfrom responses. It turns a vague support ticket into a traceable one. - For any server-side call, use the secret key. The publishable key is for clients.
Authentication errors
| HTTP | Error code | What it means |
|---|---|---|
401 | missing_api_key | No Authorization header, or not the Bearer scheme |
401 | invalid_api_key | Key doesn't exist, was revoked, or the prefix/format doesn't parse |
401 | secret_key_required | A publishable key was used on GET /v1/verify/:id |
402 | insufficient_credits | The tenant's credit balance is zero |
403 | origin_not_allowed | Browser request from an origin not on a non-empty allowed list |
429 | rate_limited | See Rate limits — may be the per-IP layer, checked before your key |
503 | backend_unavailable | Pipeline unreachable — transient, retry with backoff |
Note that insufficient_credits is 402, not 403. It is also raised during authentication, which means it can surface on /init before you have done anything else — a common cause of "the widget just says something went wrong" on an exhausted trial account.
See Errors for the full catalog.
What's next
- POST /v1/verify/init — start a verification
- Errors — full error code reference
- Rate limits — both limits, and why 429s can predate authentication