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}/historyAppend-only change history
GET /id/{id}/avatarAvatar image, served by APR
GET /p/{slug}Human-friendly alias — 302 to the canonical page
GET /changes?since={cursor}Ordered change feed — the sync primitive
GET /healthzLiveness

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
  }
]

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"

Verified results appear in the record's verifications array and on its page, each naming its proposition, subject, method, 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.

Real-person subjects are born verified

Registering a natural_voice or natural_likeness record requires consuming a completed, unattached verification 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" } }'

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.

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 also 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"

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.

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

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

Stability

The contract is append-only: fields and error codes may be added, none is repurposed or removed. Treat unknown fields as forward compatibility. A machine-readable OpenAPI description is planned.