API

Reading and resolving are free and unauthenticated. Writing requires a Registration Agency credential.

Read endpoints

GET /id/{id}Canonical record page (HTML)
GET /id/{id}.jsonMachine-readable record
GET /id/{id}/history?since=0&limit=100Bounded append-only change history; maximum 500
GET /id/{id}/avatarAvatar image, served by APR
GET /p/{slug}Human-friendly alias — 302 to the canonical page
GET /changes?since={cursor}&limit=100Ordered change feed — the sync primitive; maximum 500
GET /healthzDatabase and current-schema readiness

Resolving a record

curl https://artificialpeoples.com/id/3F7K9Q2MW6TN.json

Input is normalised before validation: hyphens and whitespace are stripped, input is uppercased, and I/L map to 1 and O to 0. A valid non-canonical form redirects (301) to the canonical URL rather than rendering at two addresses.

Subjects and relations

Every record carries a subject_type: persona (the default), voice, visual_likeness, character, or work_set. Omit it on registration and you get persona.

Active records carry a relations array, in both directions — from a voice record you see what uses it, not only what it points at:

"relations": [
  {
    "direction": "incoming",
    "type": "uses_voice",
    "record_id": "TA765DBB9QFE",
    "subject_type": "persona",
    "display_name": "Night Choir",
    "basis": "license",
    "note": null,
    "asserted_by": "ra:camplifier",
    "created_at": "2026-08-02T17:00:00.000Z"
  }
]

Assert one — you must be the agency of record for the source, and both records must be active:

curl -X POST https://artificialpeoples.com/personas/{id}/relations \
  -H "Authorization: Bearer $APR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "9Q2MW6TN3F7K", "type": "uses_voice", "basis": "license" }'

Retract with DELETE /personas/{id}/relations/{relationId}. The retraction is soft — the row survives so the history stays legible.

uses_voice→ a voice record
uses_likeness→ a visual_likeness record
portrays_character→ a character record
includes_work_set→ a work_set record
derived_from→ any record
succeeds→ a record of the same subject type

A relation is an assertion by the registering agency. Recording that a persona uses a voice says an agency asserted it — not that a licence exists, is valid, or is in force.

Control verification

The one thing on a record that is not self-declared. A verification proves a specific narrow proposition — currently domain_control_verified: someone able to publish DNS records or web content for a named domain completed a challenge. It establishes nothing about identity, authority over any persona, or ownership of any right, and every result carries that limitation.

The flow is the ACME pattern:

« 1 » create a challenge (attach to a record with persona_id, or leave it
      free-standing for a later registration to consume)
curl -X POST https://artificialpeoples.com/verifications \
  -H "Authorization: Bearer $APR_KEY" -H "Content-Type: application/json" \
  -d '{ "proposition": "domain_control_verified", "method": "dns_txt",
        "subject": "performer.example.com", "persona_id": "3F7K9Q2MW6TN" }'

« 2 » publish what the instructions say — a TXT record at
      _apr-challenge.performer.example.com (dns_txt), or the token as the body
      of https://performer.example.com/.well-known/apr-challenge/{id}
      (https_well_known; redirects are not followed)

« 3 » run the check — retryable while DNS propagates
curl -X POST https://artificialpeoples.com/verifications/{id}/check \
  -H "Authorization: Bearer $APR_KEY"

verification_id is a decimal string, not a JSON number: the underlying value is a 64-bit integer, and one past 2⁵³ would round in any client that parses it as a number. Send it back as the string wherever it is used — the {id} above, and verification_id on POST /personas, which also still accepts the JSON number earlier releases returned.

Verified results appear in the record's verifications array and on its page, each naming its proposition, subject, method, stored verifier, dates, and limitations. Pending challenges are never public. Results expire after a year — control of a channel changes hands — and an expired verification is displayed as expired, never silently dropped.

External evidence

A record's own agency can declare that a third party's evidence bears on a narrow, named proposition about this record — never a bare badge, and never something APR has verified. Each item carries legal_effect: no_determination, the proposition it bears on, and fixed limitations text stating what it does not establish:

curl -X POST https://artificialpeoples.com/personas/{id}/evidence \
  -H "Authorization: Bearer $APR_KEY" -H "Content-Type: application/json" \
  -d '{ "evidence_kind": "registry_crosswalk",
        "proposition": "same_subject_as_external_id",
        "asserted_by": "ISNI Registry",
        "locator": "https://isni.org/isni/0000000123456789" }'

The locator is published immediately — in the record's JSON and on its page, cached and crawled — and so is asserted_by, and so is content_hash. Put nothing personal in any of them: an identifier, a registry entry, a manifest URL, a platform's own record id; never a name, an email address, a document about a person, or anything that identifies someone. Hash only content that is itself public and high-entropy — a hash of a low-entropy identifier or of a personal document is a correlation value, not an anonymisation. APR never fetches or checks the locator and cannot detect personal data in it; the declaring agency is responsible for what it puts there. Both are nulled on erasure, but a cached copy is not.

Withdraw with DELETE /personas/{id}/evidence/{evidenceId} — soft, via a new withdrawn row, so the history stays legible. Declaring evidence with supersedes_id set to an existing active item's evidence_id replaces it, rather than adding a second independent claim.

evidence_kind → propositionmeaning
c2pa_manifest_reference → c2pa_manifest_references_recordthe asserting party declares that a signed C2PA manifest at the locator references this APR ID (docs/C2PA-BINDING-PROFILE.md §2.1)
platform_classification → platform_classification_declaredthe asserting party declares that a named platform classified this subject under its own AI-disclosure policy
registry_crosswalk → same_subject_as_external_idthe asserting party declares that this record and an external identifier (ISNI, MBID, QID, …) denote the same subject (docs/REGISTRY-CROSSWALK.md §7)

Never a badge. Each declared item shows its kind, who is said to assert it, its locator, and its limitations together — the same "never a bare badge" posture control verification uses. APR has not evaluated whether the underlying evidence is true.

Real-person subjects require a completed control proof and an attestation

Registering a natural_voice or natural_likeness record requires consuming a completed, unattached verification and carrying an attestation, both in the same request:

curl -X POST https://artificialpeoples.com/personas \
  -H "Authorization: Bearer $APR_KEY" -H "Content-Type: application/json" \
  -d '{ "slug": "a-performers-voice", "subject_type": "natural_voice",
        "display_name": "A Performer’s Voice", "persona_type": "voice",
        "ai_disclosure": "natural", "verification_id": "42",
        "bio": "No synthetic derivation without written consent.",
        "controller": { "type": "individual", "email": "person@example.com" },
        "attestation": { "authority_basis": "subject_consent",
                          "evidence_classes": ["signed_consent"],
                          "statement_version": "0.1" } }'

The attestation is the registering agency's own declaration of what it holds, not evidence APR reviews or stores: authority_basis names why the agency may register this subject, and evidence_classes names what class of supporting material the agency holds for that basis — a signed consent document, a licence, a letter of authority, and so on (and, for these two subject types, none_held cannot be one of them — the attestation must name the class it actually holds). APR stores the declaration; it never stores the evidence, an identity document, or a consent form itself. An optional evidence_reference and attester_reference are opaque internal pointers the agency alone can resolve in its own records — an internal ticket number or a hash for evidence_reference, an internal operator code for attester_reference — never a direct identifier: a name, an email address, a phone number, a government identifier, a document URL, or the evidence itself. An internal code that identifies someone only within the agency's own records is exactly what these fields are for; it identifies no one to APR or to anyone outside the agency. own_creation ("the controller created the persona; no identifiable natural person's voice or likeness is used or imitated") cannot be used here — it contradicts a record that is, by definition, about an identifiable natural person — and is refused with 422 invalid_attestation. own_creation is also refused, on any subject, wherever ai_disclosure is or becomes digital_replica (on POST /personas, or on a PATCH that would change ai_disclosure against an existing own_creation attestation) — the two directly contradict each other; human_fronted and hybrid remain permitted alongside own_creation. evidence_classes: ["none_held"] is refused the same way, on any subject, wherever ai_disclosure is or becomes digital_replica — a digital replica is the voice or likeness of an identifiable natural person, so an attestation naming no evidence class contradicts the basis it claims.

statement_version must equal the current version — an explicit acknowledgement that the submitting agency has read what it is citing. A missing or outdated value is refused with 422 invalid_attestation, naming the current version. The text itself is uncounseled draft legal text, so — until counsel has reviewed it — it is not published here; an authenticated agency reads it at GET /attestation-statement (send your Bearer credential, same as GET /personas/lookup).

Three rules, enforced structurally: no biometric payload ever (these records may not carry an avatar_url at all); the verification must be the caller's own, completed, unexpired, and not already consumed; and the payload is consent posture, not identification. A synthetic voice built from the person's work links back with a derived_from relation, so the derivation chain is visible from the performer's own record. Domain control proves only that the registrant controls the challenged domain; it does not verify identity, consent, or authority over the person named.

Every response says what it is

Each JSON response carries a legal_effect code and the disclaimer text. Branch on status and legal_effect, not on the presence of a field — a non-active record returns a reduced representation.

registry_recordActive record: identifier plus submitter-supplied assertions
identifier_pointer_onlyMerged. Carries deprecated_in_favor_of; no profile
tombstone_onlyWithdrawn. Carries the date and a neutral reason category; no profile
identifier_reserved; personal fields removedRedacted at a person's request; no profile

An issued ID always resolves. The only case a well-formed ID returns 404 is one that was never issued.

Caching and conditional requests

Responses carry ETag and Last-Modified. Send If-None-Match and you will get a 304 when nothing has changed. Treat the ETag as opaque. It is a cache validator, not a version: it includes a deployment-scoped representation revision and covers state that is not an event — verifications lapse on the clock, so a representation can change while record_version stands still. For PATCH concurrency read the record_version field from the JSON body.

Staying current — this one is not optional

Poll the change feed rather than re-fetching records:

curl "https://artificialpeoples.com/changes?since=0&limit=100"
{
  "events": [{
    "cursor": "14",
    "event_id": "12",
    "persona_id": "3F7K9Q2MW6TN",
    "event_type": "updated",
    "actor": "ra:camplifier",
    "created_at": "2026-08-02T17:00:00.000Z"
  }],
  "next_cursor": "14"
}

event_id is the permanent event identity and the deduplication key. cursor is an opaque decimal delivery position; send the returned next_cursor back as the next exclusive since only after the page is durable. Do not convert either value through a JavaScript number. The per-record history endpoint uses the same cursor and also returns has_more.

During publication-cursor activation, every consumer must replay from 0 (or another independently proven-safe checkpoint), deduplicate by event_id, refetch affected records, and atomically persist the final next_cursor. Do not continue a legacy checkpoint unchanged in the new mode.

Records get corrected, merged, withdrawn, and redacted. A stale copy of a record that was withdrawn for impersonation, or redacted at someone's request, actively causes the harm the change was meant to stop. If you cache display fields, refresh within 24 hours and propagate deprecated, withdrawn, and redacted states promptly.

A badge is a pointer, not a mirror. Always link to the canonical resolver URL.

Write endpoints — Registration Agencies

Bearer credential, issued under a signed agreement.

Operating your own persona and have no Registration Agency? Direct applications are not open yet; when the operator opens them, this page will link to the application form. That path holds no evidence: a persona that involves a real person's voice, likeness or performance is registered through a Registration Agency that holds the authority or evidence for it; this path holds none.

POST /personasRegister a persona; returns a permanent ID
PATCH /personas/{id}Update mutable public fields of your own records
GET /personas/lookupFind your own record by external_ref or slug
POST /personas/{id}/relationsAssert a relation to another record
DELETE /personas/{id}/relations/{relationId}Retract one
POST /verificationsCreate a control-verification challenge
POST /verifications/{id}/checkRun the check (retryable while pending)
DELETE /verifications/{id}Revoke a challenge or verification
POST /personas/{id}/evidenceDeclare third-party evidence bearing on a record
DELETE /personas/{id}/evidence/{evidenceId}Withdraw one

Taxonomies

ai_disclosure and persona_type are both closed sets, validated on POST/PATCH (422 invalid_ai_disclosure / invalid_persona_type — the schema only checks shape, so an unknown value is a semantic error like invalid_slug, not a generic 400). The response body carries accepted, the value list, alongside meanings. Every ai_disclosure value is the submitter's own declaration — APR has not verified any of them.

ai_disclosuremeaning
fully_syntheticThe submitter declares that voice, likeness and performances are generated, and no identifiable natural person's voice or likeness is used or imitated.
digital_replicaThe submitter declares that the voice or likeness is a synthetic replica of an identifiable natural person, used on a basis the submitter records in its attestation (the person may appear as a natural_voice/natural_likeness subject).
human_frontedThe submitter declares that an identifiable natural person performs or presents as the persona, with generated material behind it.
hybridThe submitter declares that this record is a mix not captured by the other values, described in the bio.
naturalThe submitter declares that the record describes an identifiable natural person's own voice or likeness and nothing about it is synthetic — the value for natural_voice / natural_likeness subject records, whose existence is what lets a digital_replica point at its source.
persona_typemeaning
musicianA musician.
vocalistA vocalist or singer.
band_or_groupA band or performing group.
dj_or_producerA DJ or music producer.
visual_artistA visual artist.
writerA writer or author.
actor_or_performerAn actor or performer.
presenter_or_hostA presenter or host.
characterA named character — the kind of thing registered for `character` subject records.
companion_or_assistantA conversational companion or assistant.
creator_or_influencerA content creator or influencer.
brand_mascotA brand mascot or spokesperson character.
voiceA voice — the kind of thing registered for `voice` and `natural_voice` subject records.
visual_likenessA visual likeness — the kind of thing registered for `visual_likeness` and `natural_likeness` subject records.
work_setAn identified body of works — the kind of thing registered for `work_set` subject records.
otherA persona type not covered by the other values.

Registering

curl -X POST https://artificialpeoples.com/personas \
  -H "Authorization: Bearer $APR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "night-choir",
    "subject_type": "persona",
    "display_name": "Night Choir",
    "persona_type": "musician",
    "ai_disclosure": "fully_synthetic",
    "bio": "A synthetic choral ensemble.",
    "links": [{ "type": "official_site", "url": "https://example.com" }],
    "external_ref": "your-own-stable-id",
    "controller": {
      "type": "organization",
      "display_name": "Sable Labs",
      "email": "ops@example.com"
    }
  }'

controller.email and controller.legal_name are private: they are stored separately from the public record and never appear in any response.

external_ref is your own retry key for the registration — private to you, returned only on authenticated responses. It must be opaque: a random or surrogate key, never an email address, a name, a username, or anything else about the subject.

This is the one submitted field an erasure deliberately keeps. Clearing it would make /personas/lookup miss on an erased record and invite your own retry to register that person a second time — so retention protects the subject, but only while the value says nothing about them. A UUID is the right shape.

Retrying safely

Registration issues a permanent identifier, so a blind retry is dangerous. Send external_ref on every registration. If a retry comes back 409 slug_taken or 409 external_ref_taken, call GET /personas/lookup?external_ref=… — it answers only for your own records — and confirm the existing record is your registration before adopting it.

Updating without clobbering

Send If-Match: <record_version> as a plain integer to make an update a compare-and-set. On mismatch nothing is applied and you get 409 version_conflict with current_version. Without the header the update applies unconditionally.

Read record_version from the response body — it is returned on the 201, on /personas/lookup, and on /id/{id}.json. Never derive it from an ETag.

The two are deliberately different things. If-Match on PATCH takes a bare integer; an ETag is a quoted, opaque validator that also covers state which is not an event — verifications lapse with the clock, so the representation changes while record_version stands still. Stripping the quotes gives you something like 7.0.deployment, which If-Match rejects with 400 bad_if_match.

Lifecycle is not writable

Merges, withdrawals, and redactions are registry operations, not agency ones, and verification state is never agency-writable. Request them through the correction and challenge process.

Errors

401 unauthorizedMissing or unknown credential
403 not_agency_of_recordThe record was registered by another agency
409 slug_taken / external_ref_takenRetry conflict — use lookup
409 version_conflictIf-Match did not match
409 record_not_active / record_redactedNot updatable in its current state
422 invalid_slug / invalid_urlFailed a semantic rule; detail names it
422 invalid_relation / invalid_targetRelation points at the wrong subject type, or at itself
409 relation_exists / target_not_activeEdge already asserted, or the other end is not active
422 invalid_subject / verification_required / verification_not_usable / avatar_not_permittedVerification rules; detail names the specific problem
409 too_many_pending / challenge_expiredComplete or revoke existing challenges; expired ones cannot be revived
429Rate limited — back off
400 bad_cursor / bad_limit / bad_if_matchExpected a bounded decimal value; limits are 1–500
500 internal_errorUnexpected failure; implementation and database details are never returned

Stability

The contract is append-only: fields and error codes may be added, none is repurposed or removed. Treat unknown fields as forward compatibility. Every route, field, header, and error code on this page is described machine-readably at GET /openapi.json (OpenAPI 3.1) — generated from, and tested against, the live route table.