Physical ValidationHalmos Labs

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

LimitWhat the server enforces
Anonymous API60 a minute per client.
MCP600 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 search120 a minute. The public API does not use this window.
Assistant5 questions a minute and 30 an hour per client.
Quote requests5 sent from the form, a minute per client. Preparing a draft does not send one.
Search textq is at most 500 characters.
Page sizePublic API and MCP: limit from 1 to 50, default 10. Catalogue search: up to 100.
Request bodyPublic 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.

CodeStatusMeaning
EXPIRED410the 10-minute window has closed.
INVALID_CODE400the code is malformed, wrong, or the id is unknown.
INVALID_ID400the id is malformed.
INVALID_REQUEST400a parameter is invalid, such as limit over the maximum or a two-letter country code.
METHOD_NOT_ALLOWED405use POST.
NOT_FOUND404no resource has that id.
RATE_LIMITED429too many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window.
REQUEST_TOO_LARGE413the JSON body is larger than the 16 KiB cap.
STALE_CURSOR409the catalogue changed; search again without a cursor.
TOO_MANY_ATTEMPTS429too many verify calls from this IP, or more than five wrong codes.
UNAVAILABLE503intake is temporarily off (fails closed).

Response headers

Every response carries these, so you can cache and back off without parsing the body.

HeaderOnMeaning
ETagreadsA content hash. Send it back as If-None-Match to get 304 Not Modified when nothing changed.
Cache-Controlreadspublic, max-age=… — reads are cacheable at the edge and in the browser. The request draft is private, no-store.
RateLimit-Limit, RateLimit-Remaining, RateLimit-ResetallYour per-IP budget, what is left, and seconds until it resets.
Retry-After429Seconds to wait before retrying.
Access-Control-Allow-Originall* — 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.

Companies

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.

Path parameters
NameTypeRequiredDescription
idstringyesCompany 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:

Get a company response schema
FieldTypeDescription
idstring
namestring
uristring
urlstring
levelprofiled | listed
legalNamestring
providerTypestring
websitesarray<string>
summarystring
sitesarray<object>
claimsarray<object>Published claims, each with its scope, basis and source ids.
sourcesarray<object>
publishedobjectFacts read from the company's own website, each with the page it came from.
noticestring

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.

StatusCodeMeaning
400INVALID_IDthe id is malformed.
404NOT_FOUNDno resource has that id.
429RATE_LIMITEDtoo 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

List 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.

Path parameters
NameTypeRequiredDescription
idstringyesCompany 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:

List a company's sites response schema
FieldTypeDescription
idstring
namestring
uristring
urlstring
locationobject

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.

StatusCodeMeaning
400INVALID_IDthe id is malformed.
404NOT_FOUNDno resource has that id.
429RATE_LIMITEDtoo 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/sites

Sites

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.

Path parameters
NameTypeRequiredDescription
idstringyesSite 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:

Get a site response schema
FieldTypeDescription
idstring
namestring
uristring
urlstring
companyobject
providerTypestring
locationobject
summarystring
claimsarray<object>
unknownsarray<string>
sourcesarray<object>
checkedAtstring
noticestring

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.

StatusCodeMeaning
400INVALID_IDthe id is malformed.
404NOT_FOUNDno resource has that id.
429RATE_LIMITEDtoo many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window.

Try it

GET /api/v1/sites/qps-groningen

Vocabulary

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:

List services response schema
FieldTypeDescription
slugstring
namestring
labelsarray<string>
rolestring | 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.

StatusCodeMeaning
429RATE_LIMITEDtoo many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window.

Try it

GET /api/v1/services

List 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:

List techniques response schema
FieldTypeDescription
slugstring
namestring
labelsarray<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.

StatusCodeMeaning
429RATE_LIMITEDtoo many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window.

Try it

GET /api/v1/techniques

List 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:

List countries response schema
FieldTypeDescription
codestring
namestring
countinteger

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.

StatusCodeMeaning
429RATE_LIMITEDtoo many requests from your IP. Wait for Retry-After (in seconds); the RateLimit-* headers show the window.

Try it

GET /api/v1/countries

Requests

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

Prepare a request body parameters
NameTypeRequiredDescription
titlestringnoA short title for the work.
descriptionstringnoWhat the provider should do, in the person's words.
servicesarray<string>noServices, 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.
modalityarray<string>noModalities, such as small molecule or mRNA.
qualityarray<string>noQuality standards, such as GLP or GMP.
scaleresearch | discovery | lead-optimization | preclinical | clinical | commercialnoDevelopment stage.
deliverablesarray<string>noWhat the person expects back.
companyIdsarray<string>noOptional shortlist of company ids returned by search_companies. Unknown ids are dropped.
needBystringnoNeed-by date, YYYY-MM-DD.
quantityobjectnoQuantity of material or samples.
materialobjectnoThe material the provider will work with.
locationobjectnoWhere 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:

Prepare a request response schema
FieldTypeDescription
submittedboolean
reviewobject
draftobjectThe placed draft the link carries.
missingarray<object>Answers still needed. Contact details are collected on the page.
unplacedarray<string>Words the vocabulary could not place; they are not in the draft.
noticestring

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.

StatusCodeMeaning
400INVALID_REQUESTthe body is not a valid request draft.
429RATE_LIMITEDtoo 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/prepare

No-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

Start a no-login request body parameters
NameTypeRequiredDescription
textstringyesThe request, as plain UTF-8. Control characters are stripped; 1 to 4000 characters remain.
emailstringyesA 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):

Start a no-login request response schema
FieldTypeDescription
idstringThe request id to verify against.
statuscode_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.

StatusCodeMeaning
400INVALID_REQUESTthe text or email is missing or invalid.
405METHOD_NOT_ALLOWEDuse POST.
413REQUEST_TOO_LARGEthe JSON body is larger than the 16 KiB cap.
429RATE_LIMITEDtoo many starts for this IP, this email, or the service. Carries Retry-After.
503UNAVAILABLEintake 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-requests

Verify 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.

Path parameters
NameTypeRequiredDescription
idstringyesThe id from the start response (unv_…).

Request body

Verify a no-login request body parameters
NameTypeRequiredDescription
codestringyesThe 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):

Verify a no-login request response schema
FieldTypeDescription
statusreceived

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.

StatusCodeMeaning
400INVALID_CODEthe code is malformed, wrong, or the id is unknown.
405METHOD_NOT_ALLOWEDuse POST.
410EXPIREDthe 10-minute window has closed.
413REQUEST_TOO_LARGEthe JSON body is larger than the 16 KiB cap.
429RATE_LIMITED or TOO_MANY_ATTEMPTStoo many verify calls from this IP, or more than five wrong codes.
503UNAVAILABLEintake 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