# Royall Connect — Partner Integration Guide

> ## Before you can call anything: you need a key
>
> **Royall Connect is live in production.** The core of this guide — sections 1
> to 8 — is serving today. What you need from us is a **partner account and a
> live `rc_live_` key** — without one, calls answer `401`, which is the same
> answer a bad key gets.
>
> Three things need arranging with us before you can use them, and they work
> differently from each other:
>
> * the **detections feed** (§8) — a setting on *your* partner account, off by
>   default, and each person must also agree separately;
> * **sending us recordings on an artist's behalf** (§11) — requires a signed
>   agreement. This one is a **platform-wide capability, not an account setting**,
>   so ask us for its current status rather than assuming it tracks your contract.
> * **the song lane and everything built on it** (§12, §13 and §14) — registering
>   the songs you administer, the review queue, the letter, and filing. A third
>   **platform-wide capability, not an account setting**. While it is off, every
>   endpoint in those three sections answers `404 not_found` no matter what your
>   account holds, so ask us for its current status before you build against them.
>
> Ask us to set up your account before you start.
>
> ## ⛔ What Royall clears, and what it does not
>
> Royall clears **a person's likeness — their voice, face and body.**
>
> **Royall does not clear sound recordings, compositions, or any music right.** A
> Royall ALLOW is not a master licence, not a publishing licence, and not a PRO
> clearance. If you generate a vocal in an artist's voice over a recording you do
> not own, Royall's grant covers the *voice* leg and nothing else. Those rights are
> yours to clear separately.


**Connect a person's Royall vault to your product, and ask permission before you use their
likeness.**

Royall Connect is a hosted handoff: you send your user to Royall, they consent and record
their likeness there, and you get a durable connection you can check on every use. Royall
holds the consent record; you hold a connection id.

> **How this document was written:** every claim was taken from the running code, and most
> were then confirmed against a development server. A few are code-derived only — the production
> signing key id, your production quota, and behaviour under PostgreSQL at scale. Those are
> marked where they appear. Nothing here is written from memory.

---

## Before you start: the three things that surprise people

Read these first. They are the mistakes integrations actually make.

**1. The scope you ask for is silently narrowed.** Royall caps what each partner may request.
If you ask for a use case outside your cap, you get **HTTP 200** and that use case is simply
**missing from the echoed `requested_scope`** — no warning field, no partial-success status.
**Diff the echo against your ask on every session create.** Otherwise you will show your user
a consent screen that cannot grant what your product needs.

**2. The webhook is a convenience, not the source of truth.** Delivery is at-least-once,
**unordered**, and gives up permanently after 5 attempts (~2.5 hours). The truth is
`GET /v1/connect/connections` and `POST /v1/authorize`. Build the polling path first; treat
webhooks as a latency optimisation.

**3. Permission is checked per use, not once at connect time.** A person can revoke at any
moment, and Royall's authorization answer flips immediately. **Call `/v1/authorize` before
each use** — a cached ALLOW is how a revocation fails to reach you. Grants live 15 minutes.

---

## The flow

```
  your app                 Royall                        your user
     │                       │                               │
     │ 1. POST /v1/connect/sessions                           │
     │──────────────────────►│                               │
     │  { url, session_id }  │                               │
     │◄──────────────────────│                               │
     │                       │                               │
     │ 2. open `url` in a NEW WINDOW ───────────────────────► │
     │                       │  consent + capture happen HERE │
     │                       │◄──────────────────────────────│
     │                       │                               │
     │ 3. webhooks: session.completed, grant.created,         │
     │              connection.ready                          │
     │◄──────────────────────│                               │
     │                       │                               │
     │ 4. before EVERY use: POST /v1/authorize                │
     │──────────────────────►│                               │
     │   ALLOW + signed grant│                               │
     │◄──────────────────────│                               │
```

**Redirect, never embed.** Royall's consent screen must run on `vault.royall.ai` in a window
you do not control. Two reasons: the capture studio needs camera and microphone permission
that a cross-origin frame cannot get, and — the real reason — if you render the consent
screen, you control what the person saw, and the record stops being evidence. At a consent
company, the consent record is the product.

---

## Authentication

```
Authorization: Bearer rc_live_EXAMPLE_ONLY_NOT_A_REAL_KEY
```

Two key namespaces: **`rc_live_`** and **`rc_test_`**. Your key identifies your partner
account; you never send a partner id.

⛔ **You are nevertheless issued one — your partner public id, `prt_…`.** You never put it in
a request; you need it to *verify* a signed grant is addressed to you (§6). It comes with your
key (`partner_public_id`, alongside the secret) and is visible to Royall staff on your partner
record, so ask if you have lost it. **Store it in your configuration.** It is not a secret —
it is inside every grant we sign for you — but it is the value your audience check compares
against, so it must come from your config and never be read off the token being checked.

**Every `/v1/connect/*` endpoint requires a live key** — `POST /sessions`,
`GET /sessions/{handle}`, `GET /connections`, `GET /detections`, and the two media
endpoints in §11. A `rc_test_` key gets
`400 {"detail":"sandbox_key_not_allowed"}` — sandbox keys cannot create real sessions,
because a session emails a real person.

Failure responses differ **per door**, which matters when you are debugging:

| | `/v1/connect/*` | `/v1/authorize` |
|---|---|---|
| no `Authorization` header | `401 invalid_connect_key` | `401 missing_bearer_token` |
| bad, unknown or revoked key | `401 invalid_connect_key` | `401 invalid_or_revoked_key` |
| your account is suspended | `401 invalid_connect_key` | `401 invalid_or_revoked_key` |
| a `rc_test_` sandbox key | `400 sandbox_key_not_allowed` | `200 BLOCK block:partner_sandbox_unavailable` |

A suspended partner is indistinguishable from a bad key, by design.

**Rate limits.** Every lane is metered, and **your allowances are set with you** — we agree
them per partner rather than publishing one number for everyone, so **ask us for yours before
you size anything.** Over your allowance you get `429 {"detail":"rate_limit_exceeded"}` with a
**`Retry-After` header**. Treat that header as a **guaranteed-safe upper bound, not a
countdown**: it is a fixed value (the length of the whole window), not the time remaining, so
honouring it literally is always correct but often waits longer than necessary. The windows
slide, so retrying earlier with backoff is fine.

⛔ **Two things worth knowing before you plan capacity.** `/v1/authorize` is metered **per
key**, so separate keys have separate allowances. The `/v1/connect/*` endpoints are metered
**per partner** — that budget is shared across every key you hold, so minting more keys does
**not** buy more room there. Neither is charged to an IP address, so a second address buys you
nothing either.

⛔ **There is also a short burst limit, separate from your allowance.** It exists so one caller
cannot crowd everyone else out, it is measured over seconds rather than a day, and you will
only meet it by sending a large spike at once. It answers `429` as well — pause briefly and
carry on. Ordinary steady traffic never reaches it, and it is not something we meter you on.

---

## 1. Create a session

```http
POST /v1/connect/sessions
Authorization: Bearer rc_live_EXAMPLE_ONLY_NOT_A_REAL_KEY
Content-Type: application/json

{
  "partner_user_ref": "usr_00042",
  "subject_email_sha256": "<sha256(lower(trim(email)))>",
  "return_url": "https://app.example.com/royall/done",
  "requested_scope": [
    { "tier": "commercial_inference", "use_cases": ["ads", "audiobook"] }
  ]
}
```

```json
{
  "session_id": "<session_handle>",
  "url": "https://vault.royall.ai/connect/<session_handle>",
  "expires_at": "2026-08-01T12:00:00.000000",
  "partner_user_ref": "usr_00042",
  "requested_scope": [
    { "tier": "commercial_inference", "use_cases": ["ads"] }
  ]
}
```

⚠ **Note the echo above: `audiobook` is gone.** That partner's cap did not include it. This
is the silent narrowing from the top of this page — always compare.

**Fields**

| Field | Rules |
|---|---|
| `partner_user_ref` | Required. `^[A-Za-z0-9._:-]{1,128}$`. Your own id for the user — Royall stores it opaquely and hands it back on `session.completed`, `grant.created`, `connection.ready` and every `/connections` row. **It is deliberately absent from `grant.revoked`** (see §3) — resolve those by your stored `connection_id`. Do not put an email here; it would be an email you gave us. |
| `subject_email_sha256` | Required. `sha256(lower(trim(email)))`, 64 hex (upper-case accepted). **You never send us the address**, and the HMAC we store of your hash cannot be reversed into one. The person proves the address by typing it into our page — at which point we do hold it, because we have to mail them. What is guaranteed is that the address never travels from you to us, and that nothing you send us can be turned back into it. |
| `return_url` | Required. Scheme + host **and port** must exactly match an origin you registered with us — so `https://app.example.com:443` is *not* treated as the same origin as `https://app.example.com`. Register the exact form you will send. **No registered origins means every session is refused** — fail-closed. |
| `requested_scope` | Declared optional, **effectively required and non-empty**. `[{ "tier": …, "use_cases": [ … ] }]`. |

**Session lifetime: 24 hours.** Sessions are single-use, and the email-confirmation step
has a limited attempt budget — a session can be exhausted, so create a fresh one rather than
re-sending a stale link.

⚠ **If your account is suspended, every session already in flight starts answering
`404 session_not_found`** — including a person mid-consent.

---

## 2. Send the person to `url`

Open it in a **new window**. What happens there:

1. The person confirms the email address you invited (they type it; we compare HMACs). The
   number of attempts is limited.
2. They receive a one-time code — the same sign-in path as any Royall user. New users create
   an account and converge on the same code screen.
3. They see your **display name**, the scope as capped (see the narrowing warning above —
   this is the echoed scope, not necessarily your original ask), and the kinds they may
   grant. Nothing is pre-ticked.
4. They allow or decline. If they allow a kind they have not yet recorded, Royall walks them
   into capture.
5. They are returned to your `return_url`.

**They can decline**, and a decline is a completed session, not an error.

**What Royall shows them about you:** your display name and the scope. What Royall shows
*you* about them **during this handoff**: nothing beyond your own `partner_user_ref` — no
email, not even masked.

⚠ That sentence is about the handoff, and it is worth being precise because the rest of
this document is not a smaller version of it. **§8's detection feed is a later, separate
disclosure about the same person** — it carries a link, a platform, a date and a confidence
band — and it exists only if that person *separately* agreed to it on the consent screen. If
they did not, you never see it and cannot tell that apart from there being nothing to see.

---

### Checking a session without waiting for the redirect

```
GET /v1/connect/sessions/{handle}
Authorization: Bearer rc_live_...
```

Answers the state of a session **you** started. Useful when the person closed the tab before
the redirect, or when you want to reconcile before the webhook lands.

⛔ **Live keys only, and scoped to you.** A handle belonging to another partner answers
`404 session_not_found` — byte-identical to one that does not exist, because a
distinguishable refusal would confirm the handle is real. A `rc_test_` key is refused
outright: a sandbox credential must never read a real person's session, and "it is only a
read" is not an exception when the read *is* the live data.

```json
{
  "session_id": "cs_9f3c1a7b2e40",
  "status": "verified",
  "partner_user_ref": "usr_00042",
  "created_at": "2026-08-01T02:58:00.000000",
  "expires_at": "2026-08-02T02:58:00.000000",
  "completed_at": null
}
```

`status` is one of **`pending`** (started, the person has not finished),
**`verified`** (they proved the invited address and we know whose vault this is),
**`completed`** (they made their choice and we sent them back to you), or
**`expired`** (the 24-hour window ran out). `completed_at` is `null` until the
status is `completed`.

⚠ It tells you about the session, never about the person: no email, masked or otherwise.

## 3. Receive webhooks

Five event types, one endpoint per partner. Four are the connection lifecycle and are
documented here; the fifth, **`detection.available`**, belongs to the detection feed and is
specified in §8 — it carries **one field, `connection_id`**, and nothing else:

| Event | `data` |
|---|---|
| `session.completed` | `session_id`, `partner_user_ref`, `granted` (**fires on decline too**) |
| `grant.created` | `connection_id`, `partner_user_ref`, `kind`, `tiers` — one event **per kind** |
| `connection.ready` | `connection_id`, `partner_user_ref`, `kind`, `asset_codes` (exactly one) |
| `grant.revoked` | `connection_id`, `kind` — **only these two fields** |

`grant.revoked` is deliberately uniform: a superseded grant, a user revocation and an account
erasure are byte-identical. The reason is never disclosed.

⛔ **`partner_user_ref` is therefore NOT on every event.** It rides `session.completed`,
`grant.created`, `connection.ready` and every `/connections` row — and **not** `grant.revoked`,
because including it is one more field that could differ between revocation causes. A handler
that requires `partner_user_ref` before dispatching will reject the one event it most needs to
act on. **Key your own records by `connection_id`** and resolve the user from that mapping.

**Envelope and headers, exactly:**

```http
POST /your/webhook
Content-Type: application/json
User-Agent: royall-connect/1
X-Royall-Event-Id: evt_EXAMPLEEVENT
X-Royall-Signature: t=1753970000,v1=<64-hex-hmac-sha256>

{
  "id": "evt_EXAMPLEEVENT",
  "type": "connection.ready",
  "created_at": "2026-08-01T02:59:12.345678",
  "data": {
    "connection_id": "cg_EXAMPLECONNECTION",
    "partner_user_ref": "usr_00042",
    "kind": "voice",
    "asset_codes": ["RYL-V-2026-DEM0V01C-8"]
  }
}
```

### Verifying the signature

1. Take the **raw request body bytes** — before any JSON parse. Re-serializing changes the
   bytes and the MAC will not match.
2. Split `X-Royall-Signature` on `,` into `t=<unix seconds>` and `v1=<hex>`.
3. Compute `HMAC-SHA256(key = your_webhook_secret, message = str(t) + "." + raw_body)`, hex,
   lowercase.
4. Compare in constant time.

```python
import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

`t` is stamped **at each delivery attempt**, so every retry carries a fresh timestamp and
signature. Royall does not impose a freshness window — choose your own tolerance.
**Deduplicate on `id`**: delivery is at-least-once.

**Your signing secret** is shown **once**, when the endpoint is configured. It is rotated
whenever the webhook URL changes and cleared when the URL is removed.

### Retries, and when we stop

Each delay is measured **from the attempt that just failed**, not from when the event was
created:

| If this attempt fails | the next one is tried | elapsed since the first attempt |
|---|---|---|
| 1st | 1 minute later | ~1 min |
| 2nd | 5 minutes later | ~6 min |
| 3rd | 25 minutes later | ~31 min |
| 4th | 2 hours later | ~2 h 31 min |
| 5th | never — the event is **permanently dead** | — |

Five delivery attempts in total, spanning about 2 hours 31 minutes. **This is why polling is
the recovery path.** Also: any HTTP redirect is refused and consumes an attempt — point us at
a final URL. While a partner account is suspended, ordinary events are **held** (no attempts
consumed) — but `grant.revoked` is delivered anyway, because a withdrawal must reach you.

⛔ **Some events are re-checked at the moment we send them, and may never be sent.**
Retries can span hours, and a connection can end during that window. We will not tell you a
connection exists after it has been revoked or the person erased, so before sending we
re-read the connection named by:

| Event | Re-checked before sending? |
|---|---|
| `connection.ready` | **Yes.** Gone → never sent. Temporarily not disclosable → held and retried. |
| `grant.created` | **Yes** — same rule. |
| `detection.available` | **Yes** — the detection must still be disclosable to you. |
| `session.completed` | No. It names a finished hosted session, not a live connection. |
| `grant.revoked` | **Never** re-checked — it must reach you precisely *because* the connection ended. |

**What this means for you:** a `grant.created` you never receive is not a delivery failure to
chase. It means the connection did not survive to the moment we tried. `GET /v1/connect/connections`
remains the truth, and a connection that is gone simply is not in it.

---

## 4. Poll — the source of truth

```http
GET /v1/connect/connections?updated_since=2026-08-01T00:00:00Z&limit=50
Authorization: Bearer rc_live_EXAMPLE_ONLY_NOT_A_REAL_KEY
```

```json
{
  "data": [
    {
      "connection_id": "cg_EXAMPLECONNECTION",
      "partner_user_ref": "usr_00042",
      "kind": "voice",
      "status": "active",
      "scope": {
        "default_tier": "protected",
        "offered": [
          { "tier": "commercial_inference", "use_cases": ["ads"],
            "exclusive": false, "price_cents": 25000, "term_days": 365 }
        ],
        "exclusions": []
      },
      "granted_at": "2026-08-01T02:58:00.000000",
      "revoked_at": null,
      "asset_codes": ["RYL-V-2026-DEM0V01C-8"],
      "capture_pending": false
    }
  ],
  "has_more": false
}
```

One row per connection (you × person × kind). Revoked rows are **retained as history** with
`status: "revoked"`.

⚠ **Do not reject unknown fields inside `offered`.** `price_cents` and `term_days` are optional
and are frequently absent; `exclusive` appears only on commercial tiers, never on the free ones.
A strict decoder configured to fail on unexpected or missing keys will break on ordinary rows
here — decode leniently, and read only the fields you need.

⛔ **Incremental polling is not complete on its own. Run a periodic FULL poll.** Three
layers, and you need all three:

1. **`updated_since`** catches the grant lifecycle (granted, revoked) and the state changes
   we explicitly signal. It is efficient and it is what you poll often.
2. **A periodic full poll** (no `updated_since`) is the reconciliation path. Some state
   changes on our side do not advance the cursor, so a connection can change without ever
   appearing in an incremental page. Without a full sweep, your mirror can stay wrong
   indefinitely. Daily is a reasonable starting cadence.
3. **`/v1/authorize`** remains the per-use truth regardless of either poll.

| Parameter | Behaviour |
|---|---|
| `updated_since` | ISO-8601. Offsets are converted to UTC; naive is treated as UTC. Matches grant, revocation **and** basis changes. Unparseable → `422 invalid_updated_since`. |
| `partner_user_ref` | Exact match, no wildcards. |
| `starting_after` | A `connection_id` cursor. Unknown — or another partner's — → `404 unknown_cursor`. |
| `limit` | Default 50, maximum 200. |

**`asset_codes` is empty until the connection is fully usable.** An empty list with
`status: "active"` is normal and means "not ready yet" — the person may still need to record,
or something on Royall's side is not yet in place. **`capture_pending: true` means
specifically that they still need to record.** When it is `false` and `asset_codes` is empty,
there is nothing for the person to do and no action for you to prompt. Royall never tells you
*why* — that would disclose facts about the person you have no relationship to.

---

## 5. Authorize — before every use

```http
POST /v1/authorize
Authorization: Bearer rc_live_EXAMPLE_ONLY_NOT_A_REAL_KEY

{ "asset_code": "RYL-V-2026-DEM0V01C-8", "use_case": "ads", "depth": "inference" }
```

```json
{
  "status": "ALLOW",
  "reason": "allow:commercial_inference",
  "tier": "commercial_inference",
  "grant_id": "grant_EXAMPLEGRANT",
  "expires_at": "2026-08-01T03:21:34.512088Z",
  "resolver": "https://vault.royall.ai/id/RYL-V-2026-DEM0V01C-8",
  "signed_token": "<base64url signed grant — see below>"
}
```

**A policy decision is always HTTP 200** — BLOCK is an answer, not an error. (The endpoint
itself can still return `401` for a bad key, `429` over quota, or `422` for a malformed
body; those are transport failures, not decisions.) Asset IDs are canonicalized on
input, so lower-case or space-separated forms are accepted.

**Grants live 15 minutes** (1 hour maximum). That short TTL is how revocation reaches you:
**re-ask.** A partner who caches an ALLOW is a partner for whom withdrawal never lands.

### Refusal reasons you should handle

⛔ **Treat any `block:` reason not listed here as a refusal you cannot act on.** This
table is the common set, not the complete one. Reasons you will also see include
`block:managed_not_cleared`, `block:creator_exclusion:<category>`,
`block:protected_default`, `block:tier_gated:commercial_training`,
`block:asset_unminted` and `block:signing_unavailable`.

⚠ **About `block:identity_unverified`.** Royall's own onboarding defers identity
verification — a creator verifies when they want to file a takedown or get paid, not
at signup. **By default a partner does not require it**, so a person who consents can
authorize immediately, and the signed grant states `identity_verified: false` honestly
so you can decide what to do with that.

If your use needs a verified identity — anything involving payment, or a legal act
taken in the person's name — say so in your contract and we set it on your account.
Then this refusal becomes the expected answer for anyone who has not verified, and you
should expect a meaningful share of otherwise-consenting people to land there.


| `reason` | Meaning |
|---|---|
| `block:no_grant` | No grant for this person + kind — never granted, or revoked |
| `block:no_offer:<tier>/<use_case>` | They granted, but not this use. Check tier **and** use case |
| `block:invalid_use:unknown_depth` | `depth` must be `inference`, `synthesis` or `training` |
| `block:universal_floor:<category>` | A prohibited category. **Overrides any grant** |
| `block:master_consent_revoked` | They withdrew consent platform-wide |
| `block:identity_unverified` | Your account requires verified ID; theirs is not |
| `block:invalid_asset_code` | Malformed or checksum-failing |
| `block:asset_not_found` | Well-formed, not registered |
| `block:asset_not_live` | Registered but withdrawn or suspended |
| `block:creator_unavailable` | Deliberately uniform — do not infer anything from it |
| `block:partner_sandbox_unavailable` | You used a `rc_test_` key |

**There is no cross-tier subsumption.** A `commercial_synthesis` grant does **not** cover an
`inference` call. Ask for what you will actually do.

### Tiers and use cases

Requestable tiers: **`fan_noncommercial`**, **`commercial_inference`**,
**`commercial_synthesis`**. Training a persistent model on someone's likeness
(`commercial_training`) is not self-serve and is refused unconditionally here.

`depth` maps to tier: `inference` → `commercial_inference` (or `fan_noncommercial` when
`commercial: false`), `synthesis` → `commercial_synthesis`, `training` →
`commercial_training`.

⚠ **These are the CURRENT values, not a frozen protocol enum.** The taxonomy is maintained
by Royall and can gain or lose tags without an API version change — so **do not hardcode
this list**. Treat an unexpected tag as data, and rely on the scope echo (above) to see what
your cap actually permits. **Use cases are per-kind, and two look confusingly similar:**

- **voice**: `ads`, `ai_voicebot`, `audiobook`, `children`, `documentary`, `elearning`,
  `games`, `narration`, `podcast`, `research`
- **face** and **body**: `advertising`, `avatar`, `children`, `corporate`, `editorial`,
  `film`, `publishing`, `research`

**`ads` is voice-only; `advertising` is face/body-only.** Sending a voice-only use case in a
face election is refused.

**The universal floor** — always refused, whatever anyone granted:
`defamation_harassment`, `fraud_impersonation`, `illegal`, `ncii`, `political_manipulation`.

---

## 6. The signed grant

`signed_token` is base64url of a JSON object you can verify **offline** with Royall's public
key. Use it to prove, later, that you were authorized at the time.

**Verify:**

1. base64url-decode `signed_token` (no padding).
2. Remove `royall_signature` and `royall_signature_kid`.
3. Canonical bytes: `json.dumps(rest, sort_keys=True, separators=(",", ":"),
   ensure_ascii=False).encode("utf-8")`.
4. Ed25519-verify `base64decode(royall_signature)` over those bytes with the key from
   `GET /api/royall-pubkey`.
5. Check: `env == "production"`, `version == 4` (exact), `status == "ALLOW"`, and
   `expires_at` is in the future.
6. ⛔ **Then compare the signed claims to what YOU expected — this step is not optional.**

⛔ **A signature proves Royall issued the token. It does NOT prove the token was issued to
you, for this asset, or for this operation.** Royall signs with one production key, so a
valid token belonging to *another partner*, *another Asset ID* or *another use case* will
pass every signature and envelope check above. Our SDKs do not do this comparison for you —
they cannot know your expectations. You must assert:

| Claim | Compare against |
|---|---|
| `principal` | **your own** `partner:<your public id>` — this is the audience check. The public id (`prt_…`) is issued with your API key and belongs in your **configuration**; ⛔ deriving `me` from the token you are checking makes this `token.principal == token.principal` and asserts nothing |
| `asset_code` | the canonical Asset ID of the job you are about to run |
| `tier` and `use_case` | the operation you are actually performing |
| `identity_verified` | your own policy, if you require verified identity |

```python
import os

# ⛔ From YOUR configuration — the partner public id issued with your key.
#    Never `me = grant["principal"]`.
ME = "partner:" + os.environ["PARTNER_PUBLIC_ID"]   # e.g. "partner:prt_…"

def accept(grant: dict, *, asset: str, tier: str, use_case: str, me: str = ME) -> bool:
    # ... signature, env, version, status and expiry checks first ...
    return (grant["principal"] == me
            and grant["asset_code"] == asset
            and grant["tier"] == tier
            and grant["use_case"] == use_case)
```

Skipping this makes the token a proof that *somebody* was authorized for *something*.

**Acceptance test for your integration:** take a valid, unexpired, correctly-signed grant
issued to a *different* partner and feed it to `accept()` with your own configured `me`. It
must return `False`. If it returns `True`, your `me` is coming from the token.

The token carries `asset_code`, `tier`, `use_case`, `principal`, `identity_verified`,
`issued_at`, `expires_at`, `resolver`, `claim`, `env` and `grant_id` — plus `version`,
`status`, `company_id` and `royall_signature_alg`. It also carries
`royall_signature_kid`, which — like the signature itself — is deliberately *outside*
the signed bytes.

⛔ **Treat that set as open, and never allow-list it.** The canonical bytes are computed
over every field except the signature itself, so a validator that rejects unknown fields
will reject valid grants the day we add one.

⚠ **The token is signed, not encrypted** — anyone who can read it can read those fields,
including whether that person's identity is verified. Treat a grant token as you would the
authorization decision itself: do not log it publicly or hand it to a browser.

⚠ **Sandbox grants are signed by a different key** (`/api/royall-sandbox-pubkey`, key ids
prefixed `sbx-`). A production verifier must load **only** the production key, so a sandbox
grant can never be mistaken for real clearance.

---

## 7. Revocation — how withdrawal reaches you

A person can revoke your access at any time, from their Royall account. When they do:

- `grant.revoked` is sent for each kind (delivered **even while your account is suspended**);
- the row stays in `/connections` as `status: "revoked"` with no `asset_codes`;
- **`/v1/authorize` returns `block:no_grant` immediately.**

Because grants expire in 15 minutes and authorization is re-derived per call, revocation
reaches you **architecturally** — but only if you re-ask. This is the one behaviour a Royall
integration must get right.

**Erasure.** If a person deletes their Royall account, their connection disappears from
`/connections` entirely. Absence is the signal; there is no separate "erased" event, because
an event naming an erased person would itself be a record of them.

---

## 8. Detections — what we spotted, and what that does not mean

**Status: off by default.** `detection_feed_enabled` is `False` on every new partner
account, and turning it on is a deliberate, audited act by a Royall administrator.
Assume you do not have it unless we have told you that you do. Read this
before you build against it, not after.

⛔ **And the switch is only half of it: the person has to agree, per connection.**
Telling you about a detection is a disclosure about someone, so it is a separate
permission on the consent screen, **unticked by default**, distinct from the
likeness grant itself. Someone can connect to you, grant you a voice licence, and
still not agree to this — that is a normal outcome, not a misconfiguration.

The consequence you have to design for: **a connection whose person did not agree
answers an empty feed, indefinitely, and looks exactly like a connection with
nothing to report.** We do not tell you which one you are looking at — saying "this
person declined to tell you about their detections" would itself be a disclosure
about them. Grants made before this permission existed count as *not agreed*.

⛔ **And it is not a one-time answer.** A person can turn this off — or back on —
from their Royall account at any time, without disconnecting from you and without
touching the licence they granted you. There is no event for it, for the reason
above. **A feed that has been returning detections can go quiet permanently, and
that is not a fault to retry or escalate.**

### The one sentence, and it is the important part

**This is a list of things we spotted that look like the person. It is not a list
of things done without their permission.**

⛔ **You may be shown your own authorized output.** When you generate something
using a creator's likeness — under a grant that creator gave you, for a use they
agreed to — and it is published, our scanning finds it exactly the way it finds an
impersonation. Nothing in the pipeline records who made a piece of media. There is
no signal that says *this one was authorized, and by you*.

We do exclude a creator's own uploads on channels they have declared to us —
**where the platform's URLs let us attribute a post to that channel.** Two honest
limits inside that: Spotify URLs never name the artist's handle, so own-content
exclusion never fires there at all; and on YouTube only modern `@handle` links
resolve — an artist's own video surfaced by its ordinary `watch?v=` permalink is
not own-suppressed. A miss in either direction is an extra card in the artist's
inbox, never a filing (deliberately conservative — we would rather show an artist
their own post than suppress someone else's clone). It does not touch the other
half, and we are not going to pretend otherwise: closing it would require you to
tell us what you generate, which is a feature with an obligation on your side and
it does not exist.

⛔ **So do not build a product on "Royall said it, therefore it is a violation."**
Nothing in the response asserts that a detection is unauthorized.

⚠ **Corrected 2026-09-04.** This paragraph used to say the response carries "a
link, a platform, a date and a confidence band, and no verdict." It carries more
than that: a `status`, and a `work` block naming which song we believe the
detection renders — with a `verdict`, a title, an ISWC, an ownership label, a
cover marker and the route that answered. What still holds, and is the point:
**`work.verdict` is song identity, not an infringement finding.** `probable`
means we think this is that song. It never means the use was unlicensed.

⛔ **Two limits you must know before you build on this.**

- **Detections are cut off at the moment the person connected to you.** We only show
  matches first seen *after* the grant. A clone that already existed before they
  connected is invisible to you permanently — and so is **how many** of them there are.
  Earlier detections are excluded before we start reading, so they cannot move
  `has_more` or produce a `next_cursor`, and an empty page over a long pre-connection
  history is indistinguishable from an empty page over none at all. If you need
  history, that is a conversation, not an API parameter.
  ⚠ **What we do not claim:** among detections you *are* entitled to, some are still
  withheld one at a time — we could not establish which likeness a match implicates, or
  the person had already settled it. Those refusals happen while we read, so a page can
  come back emptier than its paging fields suggest. That tells you we looked; it does
  not tell you what we found.
- **An empty feed is not a clean bill of health.** It means we could not establish
  anything, or the person did not agree to this disclosure — which is not the same as
  nothing existing. Measured on the only database we can currently check, almost no
  detections carry the attribution needed to match them to a person. Do not report
  "no detections" to an artist as "you are clear".

### Poll — the source of truth

```
GET /v1/connect/detections
Authorization: Bearer rc_live_...
```

| Parameter | Meaning |
|---|---|
| `connection_id` | Restrict to one connection. An id that is not yours answers `404 unknown_connection`, identically to one that does not exist. |
| `since` | ISO-8601. Filters on when we FIRST saw the detection. |
| `starting_after` | An opaque cursor from `next_cursor`. Do not parse it. |
| `limit` | 1–100, default 50. |

```json
{
  "data": [
    {
      "detection_id": "det_...",
      "connection_id": "cg_...",
      "kind": "voice",
      "platform": "youtube",
      "source_url": "https://...",
      "observed_at": "2026-08-06T12:00:00",
      "confidence_band": "high"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

- `confidence_band` is `high` (≥0.90), `medium` (≥0.80) or `low`. **We do not return
  the raw score.**
- `status` appears **only** if the creator explicitly chose to share it. It is off by
  default, and the vocabulary includes `takedown` — which means the person swore a
  legal notice under penalty of perjury. Treat its absence as normal, not as an error.
- **There is no media and no evidence endpoint.** Nothing here mints a URL to
  anything Royall stores. You get the public link we found.

### Errors you should handle

| Code | Meaning |
|---|---|
| `403 detection_feed_not_enabled` | The capability is off for you. Deliberately **not** an empty `200` — an empty list would read as a clean bill of health. |
| `400 sandbox_key_not_allowed` | Detections name real people; a sandbox key never reaches them. |
| `404 unknown_connection` | Not yours, or does not exist. The two are indistinguishable on purpose. |
| `422 invalid_cursor` / `422 invalid_since` | Cursors are opaque and are invalidated by a key rotation on our side; start again without one. |
| `503 disclosure_not_recorded` / `503 disclosure_ledger_not_arbitrated` | We could not record what we were about to disclose, so we disclosed nothing. Retry. |

### The webhook is a hint, and it carries nothing

`detection.available` has **one field**:

```json
{ "type": "detection.available", "data": { "connection_id": "cg_..." } }
```

No link, no platform, no confidence band, **not even a count**. A hint is delivered
outside the poll, so anything it carried would be information handed over without
the record that governs disclosure. **It means "poll now", and nothing else.**

Three consequences, and they are commitments rather than caveats:

1. **A hint may arrive with nothing behind it.** We re-check immediately before
   sending, but a person can withdraw consent, or settle a detection themselves, in
   the gap between the ping and your poll. An empty poll after a ping is correct
   behaviour, not an error.
2. **A hint may never arrive for a given detection.** We examine a bounded window of
   a creator's most recent detections, so one sitting behind a wall of newer rows can
   be passed over indefinitely. We say *may never* rather than *delayed* because that
   is what it is.
3. **Therefore: poll on a schedule.** The poll pages from the oldest with its own
   cursor and is not bounded by that window, so it always reaches everything you are
   entitled to see. **If you treat the webhook as the feed, you will miss things.**

⛔ **Drive your loop on `has_more`, never on `data`.** Each request examines a bounded
number of rows and applies the visibility rules per row, so a page can come back with
fewer results than `limit` — including **zero** — while `has_more` is still `true`.
Both of the loop conditions people reach for first (`while data is non-empty` and
`while len(data) == limit`) stop early and silently truncate your feed. Continue while
`has_more` is `true`.

### Rate limits

The per-partner daily budget that governs the rest of `/v1/connect/*` applies here
too. Exceeding it returns `429` with `Retry-After`.

## 9. Voice matching — "is this recording the artist?"

Submit a recording and ask whether it is the voice of a person behind an Asset
ID. This is the question the detections feed answers from our side of the
fence; this endpoint lets you ask it about media **you** hold.

> **Credentials — read this first.** This endpoint is a separate Royall
> service and is **not yet callable with your Connect keys** (`rc_live_…` /
> `rc_test_…`). It authenticates with its own credential that we issue per
> agreement, and access is authorized per Asset ID — talk to us and we will
> set you up. Access through the Connect keys you already hold is planned as
> part of the partner upload lane (§10), where the artist's consent basis for
> it is established explicitly. Everything below documents the contract so
> your team can build against it; the section becomes self-serve when that
> lane opens.

**The one sentence that matters, again:** a match score says *this sounds like
the person* — it never says *this use was unauthorized*, and it is not a
takedown. This endpoint files nothing, and nothing is ever filed on the
strength of a match score alone.

### Submit

The request is **multipart form data** — a form field `asset_code` plus the
file. There is no JSON body and no `kind` input: the media type is detected
from the file's own bytes (audio → voice matching; an image or video is
matched as a face), and a file whose name disagrees with its bytes is refused.

```
curl -X POST https://vault.royall.ai/v1/match \
  -H "Authorization: Bearer $KEY" \
  -F asset_code=RYL-V-2026-DEM0V01C-8 \
  -F file=@clip.wav
```

The response is a job, not an answer — matching runs on worker machines, never
inline. Poll it:

```
GET /v1/match/{id}
```

A job moves `queued → running → done | failed`. You will only ever see these
fields, allow-listed on purpose:

```json
{
  "id": 123, "status": "done",
  "asset_code": "RYL-V-2026-DEM0V01C-8", "kind": "voice",
  "score": 0.704, "matched": true, "threshold": 0.59,
  "error": "", "sandbox": false,
  "created_at": "…", "settled_at": "…"
}
```

No fingerprint, no creator identity, no media comes back — a match answers
*"is this them?"*, never *"here is who they are and how we know."*

### What the numbers mean

`score` is a cosine similarity between your recording and the artist's
enrolled references, **rounded to three decimals**. Cosine is signed: the
range is [-1, 1], and while real-world scores almost always land in [0, 1], a
negative score is a legitimate `done` response (it means "strongly unlike") —
do not validate `score >= 0`. `matched` is computed from the **unrounded**
internal value before rounding, so at the boundary `score >= threshold` can
disagree with `matched` by one thousandth: **trust `matched`, never re-derive
it from `score`.** The threshold is Royall-operated and re-calibrated as the
matching engine improves — **do not hardcode it, read it from the response.**

A `failed` job's `error` names one reason from a stable vocabulary:

* `not_measured` — **we could not measure this media** (nothing usable was
  detected in it, or our own matching engine was unavailable). This is the one
  to treat with care: it is *no answer*, never "not a match". Do not ship
  media on the strength of a `not_measured` job.
* `media_unavailable` / `media_undecodable` — your file never became
  measurable audio/frames.
* `no_reference_template` — the artist has no usable references for this
  media's kind right now. A refusal never says *why* a person's references
  were unavailable — that would be a fact about them you have no licence to.
* `internal_error` — our fault; retry later.
* `not_eligible` — the asset's basis lapsed between your 202 and the worker
  picking the job up. Eligibility is re-checked at execution, deliberately.
* `abandoned` — a worker died mid-match and the job's retries exhausted.
  Resubmit.
* `staging_expired` — your upload was accepted but its media never became
  durable (a storage fault on our side at the worst moment). Resubmit.

A job moves through **`staging`** (we are still storing your upload) →
**`queued`** → **`running`** → **`done`** or **`failed`**. `staging` is the
state `staging_expired` refers to: it is normal and brief, and only becomes an
error if the media never lands.

This list is the common set, **not the complete one** — treat any `error`
value not listed here as a refusal you cannot act on, never as a crash to
retry blindly.

### Limits and refusals

* **202** — accepted and queued.
* **503 `match_unavailable`** — the feature is switched off platform-wide,
  **or** media storage failed transiently while accepting your upload. The
  two are not distinguished; a retry after a pause is always the right move.
* **404 `asset_not_licensed`** — the most common refusal in real use: your
  credential's authorization does not cover this Asset ID right now (or the
  asset is not live). Deliberately indistinguishable from "no such asset".
* **401 `invalid_key_format`** — the credential is not this service's kind.
  Your Connect keys (`rc_…`) produce exactly this — see the credentials
  callout at the top of this section.
* **400 `match_not_available_in_sandbox`** — a sandbox credential can fake a
  consent *decision* (a rule); it cannot fake a *match* (a measurement
  against a real person). Matching needs this service's live credential with
  authorization for the Asset ID.
* **422 `invalid_asset_code`** — the code failed its checksum.
* **400 `empty_file`** / **413 `file_too_large`** /
  **400 `unsupported_media_type`** / **400 `media_type_mismatch`** — the
  upload itself was refused before a job was created.
* **404 `not_found`** on `GET /v1/match/{id}` — no such job, or not your key's
  job (the two are deliberately identical).
* **429** with a `Retry-After` header — over budget.
* Media: one file, up to 20 MiB. Submissions are metered on their own, much
  tighter allowance than the rest of the API — this call does real work on our
  side — while polling for the result is separate and generous. **Ask us for
  your number**; it is set per partner, and we would rather raise it for you
  than have you discover it.

## 10. How an artist's voice gets enrolled

What actually happens on our side when your artist connects, so your support
team can answer questions truthfully.

1. **They consent on Royall's surface** — the Connect flow you already
   integrate. You never collect the consent and we never take your word for it.
2. **They record one short clip at Royall, once** — about forty seconds. That
   recording is the anchor for everything that follows: a recording the artist
   made while signed in on Royall's own capture surface, answering a Royall
   prompt. To be precise about what that proves — it is an authenticated
   capture, not an independent biometric identity check; its job is to be the
   reference the rest of their catalogue is weighed against.
3. **They can then add recordings they already have.** Uploaded tracks count
   toward protection once about forty seconds of usable audio has passed our
   quality checks — the same bar the studio recording meets. Duplicate
   submissions of the same recording count once.
4. **Sentinel watches automatically from there.** No listing, no marketplace
   action, nothing outward — detections land in the artist's own inbox, and
   nothing is ever filed on a match alone: filings are made by the artist, or
   run only under a standing authorization the artist has expressly sworn.

One seam your support team should know about: Sentinel's own-content exclusion
(§8) keys on the channels the artist has **declared, where the URL names the
channel** — see §8 for the two platform limits (Spotify never; YouTube only
`@handle` links). A track enrolled here can therefore surface in the
detections feed as a match against the artist's own master — from an
undeclared channel, from their Spotify page, or from an ordinary YouTube
permalink. That is the matcher doing its job, not an infringement finding,
and it is another reason a match is never an accusation.

Recordings supplied **by a partner on the artist's behalf, through the API**,
are a separate lane with its own admission rules — §11. The artist's own
uploads and your submissions are recorded distinctly on every individual
recording, and each lane's *admission* rules are enforced separately. Once a
recording has been admitted to the vault, the matching engine weighs all
admitted references alike — the lane rules govern what gets in, not how
matching scores.


## 11. Sending us recordings on the artist's behalf

**This lane is contract-gated: we only give you access to it under a signed
agreement.** It is documented here so your engineers can build against it.

⛔ **It is a platform-wide capability, not a setting on your account.** When it is
switched off it answers `404 not_found` for *everyone*, and when it is on it is on
for everyone — there is no per-partner toggle, so do not assume its state tracks
your contract. **Ask us for its current status before you build against it.**
Nothing in this section is available on a sandbox key — it needs a live one.

The shape of it: you already hold recordings of an artist you represent. Once
the artist has connected (§1–§4) and given the permission described below, you
can hand those recordings to Royall instead of asking the artist to re-record
their own catalogue.

### The rule that governs the whole lane

**A recording you send is held until the artist says yes.** It is written to
their vault the moment it arrives, and it is held from that same moment — the
hold is not a later step that could be missed. It stays held, and does nothing,
until two things are both true: the artist has given you standing permission to
add recordings on their behalf, and the recording has passed the same quality
checks their own studio recording passed. Only then does it start counting as a
protection reference.

The artist is told when a recording of theirs arrives from you, and they can
disown it. A disowned recording stops being a reference immediately and shows
in your status view as `rejected` — being told is part of the decision, not a
courtesy.

### Send one recording

```
POST /v1/connect/connections/{connection_id}/media
Authorization: Bearer rc_live_...
Content-Type: multipart/form-data
```

One recording per call, sent as the `file` part. Audio only — this is the
singer lane, so a connection for a face or body will refuse. Up to 20 MiB.

```json
{
  "media_id": "pm_9f3c1a7b2e40c85d1739ab64",
  "state": "processing",
  "connection_id": "cg_EXAMPLECONNECTION",
  "note": "Checks are running. Whether it is then used is the artist's decision, not ours and not yours."
}
```

`state` is always `processing` in this response — the quality checks run after we
answer you, so they cannot have settled yet. Poll the read-back endpoint below to
watch it move to `held`.

### Read back what you sent

```
GET /v1/connect/connections/{connection_id}/media
```

Returns your own uploads for that connection — never the artist's own
recordings, and never another partner's — newest first:

```json
{
  "items": [
    {"media_id": "pm_9f3c1a7b2e40c85d1739ab64", "state": "held",
     "uploaded_at": "2026-08-18T09:14:22", "bytes": 4821904}
  ]
}
```

### What `state` means

| `state` | What it means |
|---|---|
| `processing` | We are still running the quality checks. |
| `held` | Checks passed; waiting on the artist's permission. |
| `protecting` | Released — it is counting as a protection reference. |
| `unusable` | The quality checks settled against it. It will not be used. |
| `not_live` | The artist's voice record is not live right now (withdrawn or under review). |
| `rejected` | The artist disowned it. |

`held` is not an error and there is no elapsed time that turns it into one.
Permission is the artist's to give.

### Refusals you should handle

* **404 `not_found`** — the lane is switched off. It is platform-wide, so this is
  not about your account or your agreement.
* **404 `connection_not_found`** — no such connection under your key, or it is
  no longer active. Unknown and not-yours are deliberately identical.
* **422 `connection_kind_not_supported`** — that connection is not a voice
  connection.
* **415 `unsupported_media_type`** — not an audio type we accept.
* **413** — too large. Just over 20 MiB you get `{"detail":"media_too_large"}`. Well
  above that the request is refused before it is even read, by a body-size limit that
  runs ahead of routing, and the body is a different shape. **Treat any `413` as "the
  file is too big" rather than matching on `detail`.**
* **400 `empty_media`** — the upload had no bytes.
* **409 `artist_not_ready`** — the artist's consent is not in force. Send them
  back through Connect rather than retrying.
* **429** with a `Retry-After` header — over your allowance. Allowances are described
  under *Authentication*, not §6.


---

<sub>Base URL for every endpoint in this guide: `https://vault.royall.ai`. Timestamps are
UTC; most are ISO-8601 without a `Z` suffix. **Three carry a `Z`**: `issued_at` and
`expires_at` inside a signed grant, and the copy of `expires_at` on the top-level authorize
response. **Any timestamp may carry a fractional second**, so parse with a format that accepts
one rather than a fixed whole-second pattern. The remaining exception is `expires_at` inside a signed
grant, which carries one.</sub>

## 12. Sending us the songs you administer

Everything above answers **whose voice**. This answers **which song** — and that is
usually the half you can act on, because copyright attaches to a work and not to a
voice.

Send us the songs you administer for an artist. When we then detect that artist, the
detection comes back naming which of those songs it renders. What you do with that —
file a takedown, claim it, take it to the artist — is your decision. We do not model it
and we do not need to know.

We do not check whether you are entitled to a song. **Putting it on this lane is your
assertion that you administer it**, we record that you were the one who told us, and we
never turn that into a statement about who wrote it.

### Send songs

```
POST /v1/connect/connections/{connection_id}/works
Authorization: Bearer rc_live_...
Content-Type: application/json
```

Up to 200 songs per call. Only `title` is required — but **`writer_ownership` is what
decides which letter you get**, so send it.

```json
{
  "works": [
    {"title": "Hotline Bling", "iswc": "T-123.456.789-0", "writers": "A. Graham",
     "alt_titles": "Hotline Bling (Remix)", "writer_ownership": "administers"}
  ]
}
```

**`writer_ownership` — the field that unlocks the copyright letter.** One of
`unknown` · `none` · `owns` · `co_owns` · `administers`. It defaults to `unknown`.

| you send | what you get when a match is confirmed |
|---|---|
| `owns`, `co_owns` or `administers` | the **composition** letter — a §512 copyright notice, sworn under penalty of perjury |
| `unknown`, `none`, or the field omitted | the **likeness** letter — a right-of-publicity request, not sworn, and materially weaker |

⚠ **Omitting it is not neutral.** A perfect title match on a song you administer still
comes back as a likeness request if we were never told you administer it — we will not
swear to a copyright claim on your behalf without an assertion of the right to make it.
If your integration only ever produces likeness letters, this is why.

```json
{"added": 1, "updated": 0, "confirmed": 0, "rejected": []}
```

**Re-sending a song you already sent updates it rather than duplicating it**, matched on
a normalised form of the title — so `Hotline Bling` and `  hotline   BLING  ` are one
song, and you can re-push your whole catalogue without cleaning up first.

A title that carries no letters or digits at all (`!!!`) is refused rather than stored,
because it could never match anything. It comes back in `rejected` with its index and
`unusable_title`, and the rest of the batch still lands.

Three more refusals work the same way, and each one names the entry it applies
to by its index in your list:

| `rejected` reason | what it means |
|---|---|
| `unusable_title` | the title has no letters or digits, so it could never match |
| `unusable_writer_ownership` | not one of the five permitted values |
| `registered_by_another_party` | someone else has already asserted this song on this artist, so we will not record a second, conflicting claim of the right to file |
| `catalogue_full` | you are already holding **5,000 songs** for this artist. That is the cap on what one partner may register against one artist; songs the artist registered themselves, and songs another partner registered, are not counted against yours. Withdraw something, or talk to us. |

`alt_titles` is newline- or pipe-separated. Every name a song goes by is matched, and the
longest match is the one that decides — so an alternate title is worth sending.

**Send the alternates, and keep them inside these limits.** Anything longer is
**cut to fit and stored trimmed**: you get a `200`, the entry is not listed in
`rejected`, and nothing tells you it happened. A catalogue export with long
alternate-title lists is the case that meets this.

| field | kept |
|---|---|
| `title` | 300 characters |
| `alt_titles` | 2,000 characters, across all alternates for one song |
| `writers` | 1,000 characters |
| `iswc` | 64 characters |

Read the entry back with `GET .../works` if the exact stored value matters to you.

### Read back what you sent

```
GET /v1/connect/connections/{connection_id}/works
```

Your own assertions for that connection, oldest first, **plus any song the artist
registered that you have confirmed** (see below). Never another partner's.

Every row says whose it is:

| field | meaning |
|---|---|
| `registered_by: "you"` | you sent us this song. You may update it and delete it. |
| `registered_by: "artist"` | **the artist** registered it and you have told us you administer it. It stays theirs. |
| `deletable` | `true` only for your own rows. |

⚠ On a row the artist registered, `writer_ownership` reads back **your** claim,
not theirs. ⛔ **That is what you told us, not what the letter acts on** — see
"Confirming does not change which letter you get" below. The two were stated
contradictorily in this document until 2026-09-04; this is the true one.

```json
{"data": [{"work_id": 41, "title": "Hotline Bling", "alt_titles": "",
           "iswc": "T-123.456.789-0", "writers": "A. Graham",
           "writer_ownership": "administers"}]}
```

⭐ **Read `writer_ownership` back after every push.** It is the one field that changes
which legal instrument you receive, and this endpoint is how you confirm we recorded what
you meant. A catalogue that reads back `unknown` will only ever produce likeness letters.

### When the artist already registered the song

An artist can register their own songs with us, and those rows are **shared** —
every partner connected to that artist matches against them.

If you push a song whose title matches one of theirs, we do **not** create a
second row and we do **not** take theirs. We record your assertion alongside it
and count it as `confirmed`:

```json
{"added": 0, "updated": 0, "confirmed": 1, "rejected": []}
```

Both statements are true at once — it is their song, and you do administer it —
so the record keeps both names. Concretely:

- The song stays the artist's. Nothing of theirs is changed: not the title, the
  alternates, the ISWC, the writers, nor their own `writer_ownership`.
- **Every other partner keeps matching against it.** Confirming does not take it
  out of anyone else's catalogue.
- It appears on `GET /works` as `registered_by: "artist"`.
- `DELETE` withdraws **your confirmation only**, never their song.

⚠ **Confirming does not change which letter you get.** On a song the artist
registered, the lane still reads **their** stated ownership, exactly as before —
so you may reach the composition lane on a song you have never mentioned to us.
Confirming records that you administer it; it is not yet a precondition for
anything.

### Before you can send: the artist has to say so

A notice you send carries `authorized agent for the rights holder` as your
capacity, and it is sworn under penalty of perjury. §512(c)(3)(A)(vi) does not
ask you to **own** the work — it asks you to swear you are **authorized to act
on behalf of** whoever does.

⛔ **So the artist grants that separately, and it is off until they do.** On the
screen where they connect you there is a box: *"Let [you] send takedown notices
for me. They sign these in my name, under penalty of perjury."* It is unticked,
it is not bundled with the detection permissions, and they can turn it off at
any time from their own settings.

Until they tick it, `POST .../file` answers `409 filing_not_authorized` and
nothing is transmitted.

⭐ **You can still do everything else.** `GET .../letter` returns the drafted
notice, and `POST .../action` records your decision, both without the
authorisation — reading what we would send, and telling us what you want done,
disclose nothing new. What needs the artist's word is us **sending** a sworn
document in their name.

⚠ **Added 2026-09-04.** Before that the notice asserted this capacity and
nothing ever asked for it.

### Withdraw one

```
DELETE /v1/connect/connections/{connection_id}/works/{work_id}
```

What this removes depends on whose song it is, and the response says which:

| the row | what happens | `removed` |
|---|---|---|
| you registered it | the song is deleted | `the_song` |
| the artist registered it and you confirmed it | **only your confirmation goes.** The artist keeps their song and every other partner keeps matching against it | `your_confirmation` |

⛔ **We will not delete an artist's own song on your say-so.**

⚠ **Withdrawing a confirmation does not change which letter you get.** This
paragraph claimed it did until 2026-09-04, which was wrong and wrong in the
dangerous direction: a partner would have believed they had disarmed a sworn
§512 notice. The lane reads the **artist's** stated ownership on their own rows,
confirmed or not.

A `work_id` that exists but is neither yours nor confirmed by you answers `404
work_not_found`, exactly as a nonexistent one does.

### Give a song a reference recording — match by SOUND, not by title

```
POST /v1/connect/connections/{connection_id}/works/{work_id}/audio
Authorization: Bearer rc_live_...
Content-Type: multipart/form-data
```

One recording as the `file` part, audio only, up to 20 MiB. Send us the real
track and we will recognise re-performances of it.

```json
{"work_id": 41, "title": "Hotline Bling", "fingerprinted": true,
 "note": "We kept a fingerprint of the composition. The recording was not stored."}
```

**We keep a fingerprint and throw the audio away.** What is stored is 2048 bytes
of numbers from which no audio can be reconstructed — nothing is written to disk,
no URL is minted, and no recording enters anyone's vault. There is nothing here
to disown, leak or delete later, which is why this lane is far lighter than the
voice one.

Why bother: without a reference recording we can only match a song when the
uploader **names it in the title** — and the uploads that matter most are exactly
the ones that do not. With one, we match on the composition itself, and it
survives a different singer, different instruments, a different tempo and a
different key.

**Refusals**, in the order we check them:

| status · code | what it means |
|---|---|
| `404 not_found` | the song lane is switched off platform-wide. Not about your account. |
| `404 connection_not_found` | no such connection under your key, or it is no longer active |
| `404 work_not_found` | no such song under your assertions |
| `503 song_matching_unavailable` | the lane is on but our model is not loaded. That is our problem, not yours, nothing was stored, and it is worth retrying later. |
| `415 unsupported_media_type` | an audio type we do not accept |
| `413 audio_too_large` | over 20 MiB |
| `422 empty_audio` | the `file` part arrived with no bytes in it |
| `422 unreadable_audio` | we could not decode it |

### What this changes on the detection feed

Every detection in §8 gains a `work` block:

```json
"work": {
  "verdict": "probable",
  "title": "Hotline Bling",
  "iswc": "T-123.456.789-0",
  "writer_ownership": "unknown",
  "cover_marker": true,
  "route": "title",
  "basis": "\"Hotline Bling\" matched 2 word(s) of the title"
}
```

`route` says **how** we answered: `audio` means we compared the recordings,
`title` means we read the uploader's own words. They deserve different
confidence, so we tell you which one you are looking at rather than blending
them into a single number.

| `verdict` | What it means |
|---|---|
| `unknown` | **We had nothing to compare against** — no songs on file for this artist at all, or the detection carried no title. This says nothing about the song. |
| `no_match` | We had a catalogue and none of it appears. Weak evidence of a new song, not proof — our signal is not exhaustive. |
| `possible` | Something matched, but not enough to act on alone. A single word of the title; a match found only in the uploader's account name, however many words it ran to; a recording that scored above our floor but not clearly above the next song in the catalogue; or a title match that the recording itself did not support. Treat as a lead. |
| `probable` | Enough matched that coincidence is unlikely. **This is the only verdict that can produce a sworn copyright letter** — see §14. |

⛔ **`unknown` and `no_match` are different answers and collapsing them will cost you
money.** `no_match` invites you to read "there is no claim here". `unknown` means we never
looked, and it is what you get until there is a catalogue to look at.

⚠ **The catalogue we match against is not only the one you sent.** An artist can
register their own songs with us, and those rows are matched for every partner
connected to them. So a `probable` verdict — and the composition letter that can
follow from it — can rest on a song you never pushed and cannot see on
`GET .../works`. The `title` and `iswc` in this block tell you which song it was;
if you do not recognise it, that is why, and it is worth checking before you file.

`writer_ownership` is whatever has been stated to us and defaults to `unknown`. **A
confident `verdict` never upgrades it** — identifying the song is not establishing who
wrote it, and an artist's recorded catalogue is full of songs written by other people.

`cover_marker` records whether the upload advertises itself as a cover or AI. It is
reported for your information and **never affects the verdict** — an uploader with a
reason to hide what they made is the case that matters most.

⚠ **It is only computed when `route` is `title`.** When we answered by comparing
the recordings, `cover_marker` is always `false` — we did not read the words, so
we are not reporting on them. Read `false` as "not established" on those rows,
never as "the upload did not say so".

`basis` is a plain sentence you can put in front of a human.

### Refusals you should handle

* **404 `not_found`** — the lane is switched off platform-wide. Not about your account.
* **404 `connection_not_found`** — no such connection under your key, or it is no longer
  active. Unknown and not-yours are deliberately identical.
* **404 `work_not_found`** — on delete: no such song under your assertions.
* **413 `too_many_works_per_call`** — more than 200. **We refuse the batch rather than
  silently taking the first 200**, so you always know what landed.
* **422 `no_works_supplied`** — an empty list.


## 13. The review queue — your record that a person looked

We will not tell you a fake is definitely your artist's voice. Nobody can:
identifying a singer in mixed music is an open research problem, and the vendors
who sell voice matching publish no accuracy figure for it. Anyone who quotes you
one is quoting something else.

What we give you instead is the thing you actually need when somebody challenges
a claim: **a record that one of your people looked at the evidence, and what they
decided.**

That matters more than it used to. Proposed US legislation would treat *failure
to review before filing* as a knowing misrepresentation, priced per notice. The
exposure is yours, not ours — so the record is yours too, and you can export it.

### Record a decision

```
POST /v1/connect/connections/{connection_id}/detections/{detection_id}/review
Authorization: Bearer rc_live_...
Content-Type: application/json
```

```json
{"decision": "confirmed", "reviewer_ref": "ops-7", "note": "matches our catalogue"}
```

| `decision` | Means |
|---|---|
| `confirmed` | Your person agrees this is yours and you are acting on it |
| `dismissed` | Looked at. Not yours, or not worth acting on |
| `deferred` | Looked at, cannot decide yet |

**`deferred` is a real answer and we would rather have it than a guess.** A queue
that only offers yes or no pushes people into deciding things they have not
decided.

**`reviewer_ref` is any string that identifies the person in YOUR system.** Do not
send us names or email addresses — we do not want your staff's personal data, and
you do not need us holding it. An internal id is enough, and it is yours to
resolve.

### What we freeze, and why

The moment you record a decision we **snapshot the evidence exactly as it stood**
— which song we matched, the confidence band, the link. That snapshot never
changes afterwards.

This is the entire point. Your catalogue grows, our thresholds move, a re-crawl
changes a score — and a year later the record still shows **what your reviewer
was actually looking at**, not what we would say today. A record that re-derived
its evidence would show your person approving something they were never shown.

### Read your decisions back

```
GET /v1/connect/connections/{connection_id}/reviews
```

Newest first. Yours only — never another partner's, even about the same artist.

`limit` is the one parameter: 1 to 500, default 100. **There is no cursor here.**
Once one connection holds more than 500 decisions, this endpoint can no longer
reach the oldest of them, and nothing in the response tells you that you have hit
the edge. If you need the full history, keep your own copy as you go: your
reviewer's id, the note and the evidence snapshot are yours to hold, and the
response to `POST .../review` returns only the detection id, the decision and its
two timestamps.

### The queue itself

Every detection in §8 now carries a `review` field: your standing decision, or
`null` if nobody has looked yet.

```json
"review": {
  "decision": "confirmed",
  "reviewer_ref": "ops-7",
  "note": "matches our catalogue",
  "reviewed_at": "2026-08-30T11:04:12",
  "recorded_at": "2026-08-31T09:20:44",
  "evidence_at_review": {"work": {"verdict": "probable", "title": "Hotline Bling"}},
  "superseded_reviews": []
}
```

**Two of those timestamps, and they are not the same fact.** `reviewed_at` is
**when one of your people first looked** at this detection — it never moves, which
is what lets the record prove somebody looked before anyone was accused.
`recorded_at` is **when the decision you are reading was made**. On a detection
reviewed once they are identical; on one that was looked at again they are not.

`superseded_reviews` holds the decisions this one replaced, oldest first, each as
`decision`, `evidence` — the snapshot as it stood at the time — and `at`, which
carries the first-look time rather than that decision's own. It is empty for a
detection reviewed once, and it keeps the **ten** most recent. A change of mind
does not erase what was decided before it.

**Your queue is the rows where `review` is `null`.** We deliberately do not filter
the feed for you: this endpoint's paging is precise about what `has_more` means,
and quietly removing rows from a page is how that guarantee gets broken.

### What we do NOT do with your decision

**Nothing.** Recording `confirmed` does not file a notice, send a message, notify
the artist, or change anything the artist sees. We keep the receipt; the action is
yours. If that ever changes it will be because you asked for it, in writing.

### Refusals

`POST .../review`, in the order we check them:

* **404 `not_found`** — the song lane is switched off platform-wide. Not about
  your account. Everything in this section and in §14 answers this while it is off.
* **422** — the decision word was not one of the three above. The message names
  the permitted values. This is checked **before** we look at your connection, so
  a bad decision word answers `422` even on a connection that is not yours.
* **404 `connection_not_found`** — no such connection under your key, or it is no
  longer active. Unknown and not-yours are deliberately identical.
* **404 `detection_not_found`** — no such detection for this connection, **or we
  would no longer disclose it to you**. Consent can be withdrawn, a grant revoked,
  or the artist can mark something as their own content; at that point the
  detection stops existing as far as you are concerned, and a review cannot be
  recorded against it.
* **409 `already_filed`** — a notice has been sent for this detection, so the
  record of what was decided beforehand can no longer be edited. **A change of
  mind after filing is a new fact, and it is not something this endpoint can
  record.** Tell us instead.

`GET .../reviews` can answer only the first and third of those: `404 not_found`
and `404 connection_not_found`.


## 14. The letter, and what you decide to do with it

> ⚠ **REPAIRED 2026-08-30, STILL SWITCHED OFF.**
> Two adversarial reviews found that the letter this section described **swore to the wrong
> thing**: a §512 notice, signed under penalty of perjury, whose body said the work infringed was
> the artist's *voice likeness* — never naming the song, while this API simultaneously reported
> it was filing about the song. The cause was that **Royall had no composition letter.**
>
> There is one now. The letter names the work, its writers and its ISWC, and the partner signs it
> as **authorized agent** for the named artist. A likeness claim and a copyright claim are
> different legal routes and now use different letters. The send refuses any notice still
> carrying a blank contact field or its own "do not file this letter" warning.
> See [the review](../security/PARTNER-FILING-LANE-REVIEW-2026-08-30.md) — 47 findings,
> 41 fixed, 0 open. The lane remains off pending the commercial question of whose name is on
> the notice.

Once one of your people has **confirmed** a detection, you can ask for the letter.

```
GET /v1/connect/connections/{connection_id}/detections/{detection_id}/letter
```

This is read-only. Nothing is sent, nothing is filed, nothing changes. It exists
so the person deciding can see the actual words before deciding.

```json
{
  "lane": "composition",
  "why": "renders your work \"Hotline Bling\"",
  "sworn": true,
  "file_at": "https://...", "send_to": "copyright@...",
  "how_to_file": "...",
  "letter": {"subject": "...", "body": "..."},
  "notice": "This copyright notice is sworn under penalty of perjury by whoever sends it. Read it before you do."
}
```

### Which letter you get, and why

**We choose it from the evidence, not from a menu.**

The `lane` field in the response tells you which one you have. There are two
values, and they are the words on the wire:

| What we found | `lane` | Sworn? |
|---|---|---|
| It renders a song, and you told us you administer the writing on it | `composition` | **Yes** — under penalty of perjury |
| Anything else, including your artist's voice with no work confirmed | `likeness` | No |

⚠ **`composition` is the word. Not `copyright`.** The letter is a copyright
notice and this guide describes it as one, but if your integration branches on
the field, branch on `composition`.

**Three things must all be true for the `composition` lane**, and any one of them
failing gives you the `likeness` letter instead:

1. **The song verdict is `probable`.** Not `possible`. §12 lists what produces
   each, and `possible` is our own word for evidence we are not willing to swear
   on — a single word of a title, a name matched only in the uploader's account
   name, or a recording that did not clearly support the title.
2. **`writer_ownership` on the matched song is `owns`, `co_owns` or
   `administers`.** `unknown` is the default and does not qualify. This is the
   field §12 tells you to read back after every push.
3. **The matched song is one you asserted, or one the artist registered
   themselves.** A song another partner registered will not steer you to swear
   to it.

⛔ **If we have no catalogue for that artist, you get the LIKENESS letter.** "We
could not check" is not "this is your song", and we will not steer anyone toward
a sworn statement on the strength of not having looked. Send us the catalogue and
the answer changes.

⚠ **Losing the oath is not losing the remedy.** Every case above still returns a
letter — a right-of-publicity request rather than a sworn copyright notice — and
the `why` field says in plain words which condition was not met.

⚠ **Read the copyright letter before it goes anywhere.** It is sworn under penalty
of perjury **by whoever sends it** — which, through an API, is a person who did
not write it. That is a real exposure for your organisation, and it is the exact
thing recent legislation is aimed at.

### Say what you decided to do

```
POST /v1/connect/connections/{connection_id}/detections/{detection_id}/action
```

```json
{"action": "partner_files", "action_ref": "ops-7"}
```

| `action` | Means |
|---|---|
| `partner_files` | **You are handling it yourself** — your own process, your own channel |
| `royall_files` | You would like Royall to file it for you |

**Please record `partner_files` even though we do nothing with it.** If you handle
ten takedowns your own way and never tell us, our record of your artist says
nothing happened — and a record that only counts what Royall did is worth very
little to you when somebody asks what you have been doing about it.

⛔ **`royall_files` records an intention. `POST /action` does not send anything.**
Choosing it on this endpoint raises a question we have not settled with you: whose
name goes on the notice — Royall acting as your named agent, or you. That belongs
in an agreement, not in code. `POST /action` answers `"sent": false` so no
integration can mistake recording an intention for transmitting a notice.

⚠ **`POST /file`, documented immediately below, DOES transmit.** It is the one
endpoint on this API that puts a letter on the wire, and it requires a sworn
attestation to do so. This paragraph is about `/action` only — do not read it as
saying Royall cannot send. It once did say that, and the sending shipped without
the sentence being updated.

### Read back exactly what you swore to

```
GET /v1/connect/connections/{connection_id}/detections/{detection_id}/filed-notice
```

The frozen text of the notice we transmitted under your reviewer's attestation —
not a re-render.

```json
{"detection_id": "det_...", "sworn_by": "ops-7", "reviewer_ref": "ops-3",
 "filed_at": "2026-08-31T22:14:09",
 "filed_to": "(the platform's designated agent address we sent it to)",
 "attested_statement": "To the Designated Agent, ..."}
```

⭐ **Why this is not the same as asking for the letter again.** Royall repairs
letter templates on existing installations — three sworn letters were corrected
that way on 2026-08-31 — so "what does this letter say today" is not an answer to
"what did we swear to". This endpoint answers the second question, and it is the
one that matters if anybody ever challenges the filing.

`sworn_by` is the person your side named at `/file`; `reviewer_ref` is whoever
confirmed the detection. They are frequently different people, and it is the
swearer who carries the §512(f) exposure.

`404 not_filed` if nothing has been transmitted for that detection. A detection
we would no longer disclose to you answers `404` here as it does everywhere else.

### Ask Royall to send it

```
POST /v1/connect/connections/{connection_id}/detections/{detection_id}/file
```

```json
{"sworn_by": "ops-7", "attestation": true}
```

Only after you have recorded `royall_files`. We send **the letter you were shown**,
byte for byte — never a fresh render, so an edited template can never change what
your person is on record as having sworn.

⛔ **For the copyright lane `attestation` must be explicitly `true`.** That notice
is sworn under penalty of perjury by the person named in `sworn_by`. We will not
transmit one on a default or an omission. The likeness lane needs no oath, but
still needs a name — somebody asked for it to go out.

**It cannot send twice.** The send is claimed atomically, so a double click, a
retry, or two of your workers racing will produce one notice and a `409
already_filed` for the loser.

**If we cannot tell which service hosts it** — some platform names cover several
companies — we refuse rather than guess an address, and you file it yourself.

**If the send fails** we clear the marker so you can try again, and the error is
recorded against the detection.

### Every way `POST /file` can refuse

Your integration will meet these. **Every one means nothing was sent.** They are
listed in the order the endpoint checks them, so the first that applies is the
one you get.

⚠ **A 4xx here is not a transport error and retrying will not clear it.** Each
needs a decision. Only `502 send_failed` is safe to retry blindly.

**Before the notice can be built**

| status · code | what happened |
|---|---|
| `404 not_found` | The song lane is switched off platform-wide. Not about your account, your connection, or this detection. |
| `404 connection_not_found` | No such connection under your key, or it is no longer active. Unknown and not-yours are deliberately identical. |
| `404 detection_not_found` | No such detection for this connection, or we would no longer disclose it to you. |
| `409 confirm_the_detection_first` | You have not recorded a review decision for this detection yet. |
| `409 filing_not_authorized` | **The artist has not authorised you to send notices in their name.** This is checked FIRST, before anything else about the detection. See below. |
| `409 choose_royall_files_first` | You reviewed it but did not choose `royall_files`. We only send what you asked us to send. |
| `409 already_filed` | You have already filed **this** detection. **Treat as success; do not retry.** (It is checked twice — here, and again at the moment of sending; see the last group.) |
| `409 no_frozen_letter` | We could not snapshot a letter when you recorded the action, so there is nothing to send. Record the action again. |
| `422 sworn_by_required` | Every filing names the person asking for it. Send `sworn_by`. |

**Who may file at all**

| status · code | what happened |
|---|---|
| `409 creator_cannot_file` | This artist's account cannot file right now (the response says why). |
| `409 beta_cohort` | This artist is in the beta programme; their notices are simulated, never transmitted. |
| `409 cannot_file_for_creator` | Your grant does not cover filing for this artist. |

**Whether the claim and the letter stand up**

| status · code | what happened |
|---|---|
| `409 composition_without_a_work` | The letter swears to a composition but no work is named. |
| `409 claim_recheck_failed` | We could not re-confirm the composition claim at send time. |
| `409 claim_withdrawn` | The catalogue entry behind this claim was withdrawn after the letter was frozen. |
| `409 likeness_named_as_the_copyrighted_work` | The letter names a voice or a face as the infringed work. Neither is copyrightable, so it cannot be sworn to as a copyright claim. **Do not retry — tell us, the letter needs correcting.** |
| `409 letter_says_do_not_file` | The letter contains an instruction not to file it. |
| `409 incomplete_filing_contact` | The letter still contains a placeholder where a contact value belongs. The response names which one and whose profile fills it in — some are yours, some are the artist's. |
| `422 attestation_required` | A copyright notice is sworn under penalty of perjury. Send `attestation: true` explicitly — we will not transmit one on a default or an omission. |

**Where it would go, and whether we can keep a copy**

| status · code | what happened |
|---|---|
| `400 cannot_identify_one_recipient` | The platform name covers several companies; we refuse rather than guess. |
| `400 platform_changed` | The letter is addressed to one platform and the content is on another. |
| `400 unknown_platform` | We cannot tell where this content is hosted. |
| `409 letter_too_long_to_record` | The notice is longer than we can record, so we will not send what we cannot keep. |
| `400 no_email_channel` | We have no verified address for this platform and will not guess one for a sworn notice. **File it yourself using `file_at` and `how_to_file`.** |
| `409 lane_changed` | Prepared as one kind of claim; your catalogue now shows it is the other — typically a song list that finished uploading after the letter was frozen. A right-of-publicity letter and a copyright letter are different documents and only one is sworn. **Review the current letter; do not re-send the old one.** |
| `409 destination_changed` | We have moved the address for this platform since your letter was frozen — usually because we found the first was the wrong desk. |

**The last gate, and the send**

| status · code | what happened |
|---|---|
| `429` | **You have filed ten notices in the last hour.** This is a separate budget from the daily allowance in the header, it is counted per partner rather than per artist, and only calls that got this far spend from it — a refusal above costs you nothing. Every notice is sworn, and a burst of them is the pattern platforms suspend complainants for. Wait, then continue. |
| `409 already_filed` | The same answer as above, reached at the moment of sending: two of your workers raced and this one lost. One notice was sent. **Treat as success; do not retry.** |
| `409 sibling_already_filed` | A **different detection for the same upload** has already been filed by you. One piece of content produces more than one detection when an artist has granted more than one likeness — voice and face give two detection ids for one video. Nothing was sent and nothing needs to be. **Treat as success; do not retry.** |
| `502 send_failed` | The transport refused it. The marker is cleared. **Safe to retry.** |

### Is the letter you reviewed still the letter we would write?

The `GET .../letter` request above returns `review_letter_is_current` alongside
the letter itself. There is no separate endpoint for it:

- `true` — the frozen copy matches what we would write today.
- `false` — **we have corrected this letter since your review.** The response
  also carries `review_letter`, the exact frozen text that would be transmitted
  if you filed right now, so you can compare it with the letter shown above and
  decide.
- `null` — nothing to compare: no review yet, or one recorded before we began
  keeping this.

⛔ We still transmit the frozen copy, deliberately — sending words nobody swore
to would be worse. This field exists so the decision is yours and not silent.

### What gets frozen

When you record an action we keep **the exact letter you were shown**. Templates
get edited. Without freezing it, a record from a year ago would show your person
choosing to send words that did not exist at the time — which is worse than
keeping no record at all.

### Refusals

These are for `GET .../letter` and `POST .../action`. `POST /file` has its own
list above.

* **404 `not_found`** — the song lane is switched off platform-wide. Not about
  your account.
* **404 `connection_not_found`** — no such connection under your key, or it is no
  longer active.
* **404 `detection_not_found`** — not yours, or we would no longer disclose it.
* **409 `confirm_the_detection_first`** — nobody has reviewed it yet. A letter is
  the thing you act on; we will not hand one over before somebody has looked.
* **409 `already_filed`** — on `POST .../action` only. The notice has been sent,
  so the letter that was sworn to cannot be replaced. **You cannot re-freeze a
  letter after it has gone out**, which also means you cannot change your mind
  from `royall_files` to `partner_files` afterwards. Use `GET .../filed-notice`
  to read what was actually transmitted.
* **422** — on `POST .../action` only: the action word was not one of the two
  above.
* **503 `no_letter_available`** — we hold no letter for that platform and lane
  yet. Tell us and we will add it.


## Testing

⛔ **There is no partner sandbox today, and you should plan around that.** A `rc_test_` key
does **not** produce a synthetic authorization: `/v1/authorize` answers
`200 BLOCK block:partner_sandbox_unavailable` before it looks at anything you send, and
`rc_test_` cannot create sessions either (a session emails a real human). So a test key
cannot currently produce an ALLOW or a signed grant. Develop against a live key and a
consenting test person, and talk to us about test access. *(The synthetic-authorization
sandbox that exists today belongs to a different Royall product surface, not to Connect.)*

**Reference Asset IDs** that are safe to use in your own tests and docs:

| Code | Resolves as |
|---|---|
| `RYL-V-2026-DEM0V01C-8` | `active` (a demonstration record) |
| `RYL-V-2026-7K3F9Q2X-N` | `unknown` — well-formed, unregistered |
| `RYL-V-2026-SANDB0X0-N` | Herald's `rh_test_` sandbox code. Resolves `unknown` on `/id/` and is not usable from Connect. |

`GET /id/{code}` resolves any Asset ID publicly: kind, status, and whether the human behind
it is identity-verified. A mistyped code returns `422 invalid_checksum` alongside a plain-English
`hint` field you can show your user directly. The last character
is a checksum, so typos are caught rather than silently resolving to someone else.

---

## Asset ID format

```
RYL-V-2026-DEM0V01C-8
 │  │  │      │      └── check character (Luhn mod-32)
 │  │  │      └───────── 8-character designation
 │  │  └──────────────── year of issue
 │  └─────────────────── kind: V=voice, F=face, B=body
 └────────────────────── prefix
```

Resolve any code yourself at [`/id/{code}`](https://vault.royall.ai/id/RYL-V-2026-DEM0V01C-8),
or see the human page at [`/verify/{code}`](https://vault.royall.ai/verify/RYL-V-2026-DEM0V01C-8).

Alphabet `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (no I, L, O, U). Parsing is case-insensitive and
tolerant of spaces or missing hyphens. Inside the 8-character designation and the check
character we also fold `I/L→1`, `O→0`, `U→V` — but the prefix, kind and year are matched
literally. A `U` typed where the kind belongs is rejected, and the
`looks mistyped — check the last characters` hint will be pointing you at the wrong end
of the code. **Always send the
code you were given; always store the canonical form we return.**

---

## Getting set up

Partner accounts, API keys, webhook endpoints and your scope cap are configured by Royall.
Talk to us and we will register your origins, set your cap, and issue keys. Your webhook
signing secret is shown once at that point.

⛔ **Saving a webhook URL always issues a new signing secret — even when the URL has not
changed.** Capture the secret every time the endpoint is saved. If you do not, deliveries
keep arriving but every signature check against your stored secret fails, and the symptom
(all webhooks failing) is a long way from the cause (a save that looked like a no-op).

You leave setup holding four things. Three of them are secrets; the fourth is not, and is
the one integrations forget:

| | What | Secret? |
|---|---|---|
| API key | `rc_live_…` — sent as `Authorization: Bearer` | **yes**, shown once |
| Webhook signing secret | `whrc_…` — verifies `X-Royall-Signature` | **yes**, shown once |
| Registered return-URL origins + scope cap | set by Royall on your record | n/a |
| **Partner public id** | `prt_…` — your audience value; `principal` in every grant we sign for you (§6) | **no** — but it belongs in config, not derived from a token |

### What else lives at `/v1`

Your integration is `/v1/authorize`, `/v1/connect/*` and `/v1/match`. You will also see
`/v1/datasets/*` on this host — that is a **different Royall product** (bulk licensed
training data for AI labs), it uses its own credential namespace, and your `rc_` and `rh_`
keys have no access to it. Nothing else is served here.

Questions: [support@royall.ai](mailto:support@royall.ai).
