API reference
A stable, public REST API for the 10-resource /api/v1 surface. No account, no key: anyone can curl it. Read responses share one envelope, and prices and internal scores never appear.
Introduction
The base URL is https://physicalvalidation.com/api/v1. Reads return JSON wrapped in one envelope:
{
"data": …, // the resource, or an array of them
"next_cursor": "…" | null, // pass back as ?cursor= for the next page
"meta": {
"source_urls": ["…"], // the evidence the payload cites
"generated_at": "2026-10-16T00:00:00.000Z"
}
}Read responses carry an ETag and a public Cache-Control, so a conditional request (If-None-Match) returns 304. Cross-origin GET is allowed (Access-Control-Allow-Origin: *). The full machine-readable contract is the OpenAPI 3.1 document.
Authentication
None. The API needs no token, no cookie and no account, and a presented bearer token is ignored. The catalogue is read-only; request endpoints only prepare a draft or accept a confirmed no-login intake. Bulk scraping, mirroring or redistributing the catalogue is not allowed; see the terms.
Rate limits
Requests are limited per client IP. Each response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. Past the limit the response is 429 with Retry-After, in seconds.
Limits
| Limit | What the server enforces |
|---|---|
| Anonymous API | 60 a minute per client. |
| MCP | 600 a minute for one IP and one user-agent family (ChatGPT, Claude, or other), and 6000 a minute across all clients. A 429 includes Retry-After. |
| Authenticated catalogue search | 120 a minute. The public API does not use this window. |
| Assistant | 5 questions a minute and 30 an hour per client. |
| Quote requests | 5 sent from the form, a minute per client. Preparing a draft does not send one. |
| Search text | q is at most 500 characters. |
| Page size | Public API and MCP: limit from 1 to 50, default 10. Catalogue search: up to 100. |
| Request body | Public API JSON: 65536 bytes. MCP message: 100000 bytes. The shared JSON reader, used by site search POST: 16384 bytes. |
Past a limit the response is HTTP 429 and includes Retry-After, in seconds. Wait that long, then retry. For the API, MCP, catalogue search, and quote requests the value is 60. For the assistant it is 60 on the per-minute cap and 3600 on the hourly cap.
Errors
An error is the HTTP status plus a stable body. retryable says whether the same request may be retried after a pause.
{
"error": {
"code": "INVALID_REQUEST",
"message": "…",
"retryable": false
}
}Error codes
Every code the API can return. The per-endpoint tables below list which apply to each call.
| Code | Status | Meaning |
|---|---|---|
EXPIRED | 410 | the 10-minute window has closed. |
INVALID_CODE | 400 | the code is malformed, wrong, or the id is unknown. |
INVALID_ID | 400 | the id is malformed. |
INVALID_REQUEST | 400 | a parameter is invalid, such as limit over the maximum or a two-letter country code. |
METHOD_NOT_ALLOWED | 405 | use POST. |
NOT_FOUND | 404 | no resource has that id. |
RATE_LIMITED | 429 | too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window. |
REQUEST_TOO_LARGE | 413 | the JSON body is larger than the 16 KiB cap. |
STALE_CURSOR | 409 | the catalogue changed; search again without a cursor. |
TOO_MANY_ATTEMPTS | 429 | too many verify calls from this IP, or more than five wrong codes. |
UNAVAILABLE | 503 | intake is temporarily off (fails closed). |
Response headers
Every response carries these, so you can cache and back off without parsing the body.
| Header | On | Meaning |
|---|---|---|
ETag | reads | A content hash. Send it back as If-None-Match to get 304 Not Modified when nothing changed. |
Cache-Control | reads | public, max-age=… — reads are cacheable at the edge and in the browser. The request draft is private, no-store. |
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset | all | Your per-IP budget, what is left, and seconds until it resets. |
Retry-After | 429 | Seconds to wait before retrying. |
Access-Control-Allow-Origin | all | * — call it cross-origin from the browser. |
Pagination
A list response carries next_cursor. Send it back as ?cursor= with the same query for the next page; it is null on the last page. The page size is limit, bounded to the published maximum. A STALE_CURSOR error means the data changed — start again without a cursor.
Idempotency & retries
Every read (GET) is idempotent and cacheable: the same request returns the same result and carries an ETag, so you can retry it freely and revalidate with If-None-Match. The request draft, POST /api/v1/requests/prepare, only builds and returns a draft — it stores nothing and sends nothing — so it is safe to call again.
The no-login intake endpoints change state, so do not blindly retry them. POST /api/v1/unvetted-requests emails a code and is rate-limited per IP, per email, and globally; POST /api/v1/unvetted-requests/{id}/verify counts attempts and locks after five wrong codes. Retry these only after a network error — never after a 4xx — and on a 429 wait for Retry-After.
Versioning
The version is in the path: this is v1. Within a version the contract only grows — new optional parameters and new response fields may appear — so parse responses leniently and ignore fields you do not use. A breaking change ships under a new path prefix, never in place. The exact shapes are the OpenAPI 3.1 document, whose info.version tracks the contract.
Search
Search companies
GET /api/v1/search · Catalogue
Operation ID searchCompanies — the stable name a generated client uses for this call.
Full-text search over CROs and CDMOs by what they do, where, and by name, with the same vocabulary as the site. Each result carries the claims that matched and their source ids. Results are paged with a cursor.
| Name | Type | Required | Description |
|---|---|---|---|
q | string | no | Free text: a material, a modality, a service, or a company name. |
service | string[] | no | Service values from /api/v1/services. Repeat for several; any match. |
type | string[] | no | Provider type: CRO, CDMO or Research institute. |
country | string[] | no | ISO 3166-1 alpha-2 codes, such as US or GB. |
region | string[] | no | World regions, e.g. europe, north-america, asia-pacific. |
modality | string[] | no | Modality values from the facets. |
quality | string[] | no | Quality standards, such as GLP or GMP. |
equipment | string[] | no | Equipment values from the facets. |
sort | string | no | relevance (default), name, or completeness. |
limit | integer | no | Page size, 1 to the published maximum. Default is the published default. |
cursor | string | no | next_cursor from the previous page of the same search. |
facets | boolean | no | Set true to also return filter values with counts in meta.facets. |
Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/search?q=bioanalysis&country=NL&limit=2'Response schema
Every response is the { data, next_cursor, meta } envelope. data is an array; each item has:
| Field | Type | Description |
|---|---|---|
id | string | |
name | string | |
uri | string | |
url | string | |
level | profiled | listed | listed: a directory listing without verified site claims. |
match | exact | partial | exact: this result answered every part of the query. partial: it answered all but one part, and is shown below the ones that answered it all. |
matchedOn | claims | name | place | What put this company on the page. claims: something it publishes answers the query, and matched carries it. name: the query's words are in the company's own name, which is not a claim about what it does. place: only the country or region narrowed to it. |
providerType | string | |
websites | array<string> | |
countries | array<string> | |
summary | string | |
sites | integer | |
matched | array<object> | Claims that answer the query, with their evidence. |
Example response
{
"data": [
{
"id": "provider:qps",
"name": "QPS",
"uri": "physical-validation://company/provider:qps",
"url": "https://physicalvalidation.com/company/provider:qps",
"level": "profiled",
"match": "exact",
"providerType": "CRO",
"websites": [
"https://www.qps.com"
],
"countries": [
"NL"
],
"sites": 1,
"matched": [
{
"kind": "service",
"label": "Bioanalysis",
"scope": "facility",
"basis": "published",
"facilityId": "qps-groningen",
"sourceIds": [
"s1"
]
}
]
}
],
"next_cursor": "eyJ2IjoxLCJhZnRlciI6InByb3ZpZGVyOnFwcyJ9",
"meta": {
"source_urls": [],
"generated_at": "2026-10-16T00:00:00.000Z",
"total": 1,
"match": "exact"
}
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
400 | INVALID_REQUEST | a parameter is invalid, such as limit over the maximum or a two-letter country code. |
409 | STALE_CURSOR | the catalogue changed; search again without a cursor. |
429 | RATE_LIMITED | too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window. |
Try it
GET /api/v1/search?q=bioanalysis&country=NL&limit=2Companies
Get a company
GET /api/v1/companies/{id} · Catalogue
Operation ID getCompany — the stable name a generated client uses for this call.
One company: its sites, published claims with scope, basis and sources, and facts read from its own website, each with the page it came from.
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Company id from search — provider:<id>, archive:<id> or listed:<id>. Encode the colon as %3A. |
Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/companies/provider%3Aqps'Response schema
Every response is the { data, next_cursor, meta } envelope. data has:
| Field | Type | Description |
|---|---|---|
id | string | |
name | string | |
uri | string | |
url | string | |
level | profiled | listed | |
legalName | string | |
providerType | string | |
websites | array<string> | |
summary | string | |
sites | array<object> | |
claims | array<object> | Published claims, each with its scope, basis and source ids. |
sources | array<object> | |
published | object | Facts read from the company's own website, each with the page it came from. |
notice | string |
Example response
{
"data": {
"id": "provider:qps",
"name": "QPS",
"uri": "physical-validation://company/provider:qps",
"url": "https://physicalvalidation.com/company/provider:qps",
"level": "profiled",
"providerType": "CRO",
"websites": [
"https://www.qps.com"
],
"sites": [
{
"id": "qps-groningen",
"name": "QPS Groningen",
"uri": "physical-validation://facility/qps-groningen",
"url": "https://physicalvalidation.com/facility/qps-groningen",
"location": {
"city": "Groningen",
"country": "Netherlands",
"countryCode": "NL"
}
}
],
"claims": [
{
"kind": "service",
"label": "Bioanalysis",
"scope": "facility",
"basis": "published",
"facilityId": "qps-groningen",
"sourceIds": [
"s1"
]
}
],
"sources": [
{
"id": "s1",
"title": "QPS — Bioanalysis",
"url": "https://www.qps.com/bioanalysis",
"checkedAt": "2026-09-30"
}
],
"notice": "Claims come from published sources and keep their scope."
},
"next_cursor": null,
"meta": {
"source_urls": [
"https://www.qps.com/bioanalysis"
],
"generated_at": "2026-10-16T00:00:00.000Z"
}
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
400 | INVALID_ID | the id is malformed. |
404 | NOT_FOUND | no resource has that id. |
429 | RATE_LIMITED | too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window. |
Try it
GET /api/v1/companies/provider%3AqpsList a company's sites
GET /api/v1/companies/{id}/sites · Catalogue
Operation ID listCompanySites — the stable name a generated client uses for this call.
The lab sites of one company, each with its location and resource uri.
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Company id. Encode the colon as %3A. |
Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/companies/provider%3Aqps/sites'Response schema
Every response is the { data, next_cursor, meta } envelope. data is an array; each item has:
| Field | Type | Description |
|---|---|---|
id | string | |
name | string | |
uri | string | |
url | string | |
location | object |
Example response
{
"data": [
{
"id": "qps-groningen",
"name": "QPS Groningen",
"uri": "physical-validation://facility/qps-groningen",
"url": "https://physicalvalidation.com/facility/qps-groningen",
"location": {
"city": "Groningen",
"country": "Netherlands",
"countryCode": "NL"
}
}
],
"next_cursor": null,
"meta": {
"source_urls": [],
"generated_at": "2026-10-16T00:00:00.000Z"
}
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
400 | INVALID_ID | the id is malformed. |
404 | NOT_FOUND | no resource has that id. |
429 | RATE_LIMITED | too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window. |
Try it
GET /api/v1/companies/provider%3Aqps/sitesSites
Get a site
GET /api/v1/sites/{id} · Catalogue
Operation ID getSite — the stable name a generated client uses for this call.
One lab site: location, company, published claims with their sources, and what is unknown.
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | Site id from search or from a company's sites. |
Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/sites/qps-groningen'Response schema
Every response is the { data, next_cursor, meta } envelope. data has:
| Field | Type | Description |
|---|---|---|
id | string | |
name | string | |
uri | string | |
url | string | |
company | object | |
providerType | string | |
location | object | |
summary | string | |
claims | array<object> | |
unknowns | array<string> | |
sources | array<object> | |
checkedAt | string | |
notice | string |
Example response
{
"data": {
"id": "qps-groningen",
"name": "QPS Groningen",
"uri": "physical-validation://facility/qps-groningen",
"url": "https://physicalvalidation.com/facility/qps-groningen",
"company": {
"id": "provider:qps",
"name": "QPS",
"uri": "physical-validation://company/provider:qps",
"website": "https://www.qps.com"
},
"providerType": "CRO",
"location": {
"city": "Groningen",
"country": "Netherlands",
"countryCode": "NL"
},
"claims": [
{
"kind": "service",
"label": "Bioanalysis",
"scope": "facility",
"basis": "published",
"sourceIds": [
"s1"
]
}
],
"unknowns": [],
"sources": [
{
"id": "s1",
"title": "QPS — Bioanalysis",
"url": "https://www.qps.com/bioanalysis",
"checkedAt": "2026-09-30"
}
],
"checkedAt": "2026-09-30",
"notice": "Claims come from published sources and keep their scope."
},
"next_cursor": null,
"meta": {
"source_urls": [
"https://www.qps.com/bioanalysis"
],
"generated_at": "2026-10-16T00:00:00.000Z"
}
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
400 | INVALID_ID | the id is malformed. |
404 | NOT_FOUND | no resource has that id. |
429 | RATE_LIMITED | too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window. |
Try it
GET /api/v1/sites/qps-groningenVocabulary
List services
GET /api/v1/services · Vocabulary
Operation ID listServices — the stable name a generated client uses for this call.
The service vocabulary — the capability terms the catalogue uses. Use a slug or label as a service filter on search.
Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/services'Response schema
Every response is the { data, next_cursor, meta } envelope. data is an array; each item has:
| Field | Type | Description |
|---|---|---|
slug | string | |
name | string | |
labels | array<string> | |
role | string | null |
Example response
{
"data": [
{
"slug": "bioanalysis",
"name": "bioanalysis",
"labels": [
"Bioanalysis"
],
"role": "research"
}
],
"next_cursor": null,
"meta": {
"source_urls": [],
"generated_at": "2026-10-16T00:00:00.000Z"
}
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
429 | RATE_LIMITED | too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window. |
Try it
GET /api/v1/servicesList techniques
GET /api/v1/techniques · Vocabulary
Operation ID listTechniques — the stable name a generated client uses for this call.
The technique vocabulary.
Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/techniques'Response schema
Every response is the { data, next_cursor, meta } envelope. data is an array; each item has:
| Field | Type | Description |
|---|---|---|
slug | string | |
name | string | |
labels | array<string> |
Example response
{
"data": [
{
"slug": "lipid-nanoparticle",
"name": "lipid nanoparticle",
"labels": []
}
],
"next_cursor": null,
"meta": {
"source_urls": [],
"generated_at": "2026-10-16T00:00:00.000Z"
}
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
429 | RATE_LIMITED | too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window. |
Try it
GET /api/v1/techniquesList countries
GET /api/v1/countries · Vocabulary
Operation ID listCountries — the stable name a generated client uses for this call.
Countries that have listed sites, with the number of sites in each.
Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/countries'Response schema
Every response is the { data, next_cursor, meta } envelope. data is an array; each item has:
| Field | Type | Description |
|---|---|---|
code | string | |
name | string | |
count | integer |
Example response
{
"data": [
{
"code": "NL",
"name": "Netherlands",
"count": 12
}
],
"next_cursor": null,
"meta": {
"source_urls": [],
"generated_at": "2026-10-16T00:00:00.000Z"
}
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
429 | RATE_LIMITED | too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window. |
Try it
GET /api/v1/countriesRequests
Prepare a request
POST /api/v1/requests/prepare · Requests
Operation ID prepareRequest — the stable name a generated client uses for this call.
Places a sourcing request draft with the site's vocabulary and returns a review link on physicalvalidation.com where a person adds contact details and sends it. It only drafts: nothing is stored or sent, and no request reaches a provider — so Try it is safe to run.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
title | string | no | A short title for the work. |
description | string | no | What the provider should do, in the person's words. |
services | array<string> | no | Services, in the person's words, such as "LC-MS/MS bioanalysis". Placed with the site's vocabulary; what cannot be placed comes back in unplaced. |
modality | array<string> | no | Modalities, such as small molecule or mRNA. |
quality | array<string> | no | Quality standards, such as GLP or GMP. |
scale | research | discovery | lead-optimization | preclinical | clinical | commercial | no | Development stage. |
deliverables | array<string> | no | What the person expects back. |
companyIds | array<string> | no | Optional shortlist of company ids returned by search_companies. Unknown ids are dropped. |
needBy | string | no | Need-by date, YYYY-MM-DD. |
quantity | object | no | Quantity of material or samples. |
material | object | no | The material the provider will work with. |
location | object | no | Where the work may happen. |
{
"title": "GMP peptide, 50 g",
"services": [
"peptide synthesis"
],
"quality": [
"GMP"
],
"quantity": {
"value": 50,
"unit": "g"
}
}Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/requests/prepare' \
-H 'Content-Type: application/json' \
-d '{"title":"GMP peptide, 50 g","services":["peptide synthesis"],"quality":["GMP"],"quantity":{"value":50,"unit":"g"}}'Response schema
Every response is the { data, next_cursor, meta } envelope. data has:
| Field | Type | Description |
|---|---|---|
submitted | boolean | |
review | object | |
draft | object | The placed draft the link carries. |
missing | array<object> | Answers still needed. Contact details are collected on the page. |
unplaced | array<string> | Words the vocabulary could not place; they are not in the draft. |
notice | string |
Example response
{
"data": {
"submitted": false,
"review": {
"url": "https://physicalvalidation.com/request#…",
"label": "Review and send on physicalvalidation.com"
},
"draft": {
"title": "GMP peptide, 50 g",
"services": [
"Peptide synthesis"
],
"quality": [
"GMP"
]
},
"missing": [
{
"path": "description",
"ask": "What should the provider do?"
}
],
"notice": "Nothing was stored or sent."
},
"next_cursor": null,
"meta": {
"source_urls": [],
"generated_at": "2026-10-16T00:00:00.000Z"
}
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
400 | INVALID_REQUEST | the body is not a valid request draft. |
429 | RATE_LIMITED | too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window. |
Try it
This drafts a request and returns a review link. It sends nothing and stores nothing.
POST /api/v1/requests/prepareNo-login requests
Start a no-login request
POST /api/v1/unvetted-requests · Intake
Operation ID startUnvettedRequest — the stable name a generated client uses for this call.
Stores a request and emails a 6-digit code to confirm the address — no account needed. The response is identical for any email, so nothing is enumerable, and nothing else runs: no model, crawl or provider contact. These two endpoints return a bare object, not the read envelope.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
text | string | yes | The request, as plain UTF-8. Control characters are stripped; 1 to 4000 characters remain. |
email | string | yes | A valid address the code is emailed to. |
{
"text": "We need GLP bioanalysis for a 12-subject PK study.",
"email": "you@example.com"
}Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/unvetted-requests' \
-H 'Content-Type: application/json' \
-d '{"text":"We need GLP bioanalysis for a 12-subject PK study.","email":"you@example.com"}'Response schema
This endpoint returns a bare object (not the read envelope):
| Field | Type | Description |
|---|---|---|
id | string | The request id to verify against. |
status | code_sent |
Example response
{
"id": "unv_0123456789abcdef0123456789abcdef",
"status": "code_sent"
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
400 | INVALID_REQUEST | the text or email is missing or invalid. |
405 | METHOD_NOT_ALLOWED | use POST. |
413 | REQUEST_TOO_LARGE | the JSON body is larger than the 16 KiB cap. |
429 | RATE_LIMITED | too many starts for this IP, this email, or the service. Carries Retry-After. |
503 | UNAVAILABLE | intake is temporarily off (fails closed). |
Try it
Live: sending this emails a real 6-digit code to the address below — use your own email. The limiter caps attempts per IP, per email and overall.
POST /api/v1/unvetted-requestsVerify a no-login request
POST /api/v1/unvetted-requests/{id}/verify · Intake
Operation ID verifyUnvettedRequest — the stable name a generated client uses for this call.
Confirms a request by entering the emailed code, within a 10-minute window and an attempt cap. Idempotent.
| Name | Type | Required | Description |
|---|---|---|---|
id | string | yes | The id from the start response (unv_…). |
Request body
| Name | Type | Required | Description |
|---|---|---|---|
code | string | yes | The 6-digit code from the email. |
{
"code": "123456"
}Example request
curl --fail-with-body 'https://physicalvalidation.com/api/v1/unvetted-requests/unv_0123456789abcdef0123456789abcdef/verify' \
-H 'Content-Type: application/json' \
-d '{"code":"123456"}'Response schema
This endpoint returns a bare object (not the read envelope):
| Field | Type | Description |
|---|---|---|
status | received |
Example response
{
"status": "received"
}Errors
On an error the body is { error: { code, message, retryable } }. Match on the stable code below, not on the message. A status that can carry either of two codes lists both.
| Status | Code | Meaning |
|---|---|---|
400 | INVALID_CODE | the code is malformed, wrong, or the id is unknown. |
405 | METHOD_NOT_ALLOWED | use POST. |
410 | EXPIRED | the 10-minute window has closed. |
413 | REQUEST_TOO_LARGE | the JSON body is larger than the 16 KiB cap. |
429 | RATE_LIMITED or TOO_MANY_ATTEMPTS | too many verify calls from this IP, or more than five wrong codes. |
503 | UNAVAILABLE | intake is temporarily off. |
Try it
Live: enter the id from a start and the code from its email. A wrong or unknown id returns 400.
POST /api/v1/unvetted-requests/unv_0123456789abcdef0123456789abcdef/verify