# Hybrid ID guest profiles — integration contract v1

Guest profiles are authoritative, persisted Identity records scoped to one registered
application. They need no email or password and grant no sign-in, KYC, wallet, agent
or cross-app authority. Keep gameplay, match records, searches and progress in your
own database. Never create a new profile for each visit or match.

Machine-readable contracts: [Guest API](https://hybrid-id.com/integrations/guest-profiles.openapi.json) and [application/guest-credential management](https://hybrid-id.com/integrations/developer-openapi.json).

## Registration and credentials

1. In Hybrid ID → Developer Apps, register the application and exact HTTPS callbacks.
   Edit the app and enable **Guest profiles**. This adds the `guest:link` OAuth scope.
2. Create an **app-scoped guest credential** in that application's guest section.
   Select `guests:read`, `guests:write`, and, if needed, `guests:link`. Fresh
   authenticator verification is required in the portal. Store the returned
   `hid_guest_...` token only in your backend's secret manager. It is shown once,
   expires after 30 days and can be revoked independently of the SSO secret.
   Each app allows up to five non-revoked guest credentials. Credential creation
   is not idempotent: inspect the credential list after an uncertain response
   before attempting another creation.
3. Agents can perform the same steps through
   `POST https://hybrid-id.com/api/developer/v1/applications`, using an owner-delegated
   `hid_dev_...` credential with `applications:write`. Actions:
   - `update`: normal app metadata plus `guest_profiles_enabled: true`;
   - `guest-credential-create`: `client_id`, `label`, `scopes`;
   - `guest-credential-list`: `client_id` (`applications:read` suffices);
   - `guest-credential-revoke`: `client_id`, `credential_id`.
   The developer must own the app. The OIDC client secret is **not** a guest API
   credential. Do not send any credential in URLs, logs, browser bundles or chat.

Example credential enrollment body (use your existing developer credential):
```json
{"action":"guest-credential-create","client_id":"YOUR_CLIENT_ID","label":"Production game server","scopes":["guests:read","guests:write","guests:link"]}
```

## Guest API

All operations use `POST https://hybrid-id.com/api/developer/v1/guests`, JSON and
`Authorization: Bearer <app-scoped guest credential>`. Success is
`{"success":true,"data":{...}}`. The credential fixes the application and owner:
never send an app ID, owner, email or canonical user ID in these requests.

### Create or recover the same guest (`guests:write`)

Generate one cryptographically random UUID per persistent guest and normalize it
to 32 lowercase hexadecimal characters. Generate a separate 32-byte random claim
secret encoded as base64url without padding (43 characters). Register the SHA-256
hex digest of the **encoded secret string**, not its decoded bytes.

```json
{"action":"create","external_guest_id":"5f493cbd7ab84f059ac848bfc1cc1a9d4","claim_secret_sha256":"64_LOWERCASE_HEX_CHARACTERS"}
```

Store your guest UUID, returned `guest_profile_id`, and claim-secret binding
persistently. Keep the browser guest session in a Secure, HttpOnly cookie, with
server-side hashed-token validation. The claim secret can be that session proof
only if its format and lifetime match; otherwise use a dedicated claim secret
bound to your already validated local guest session. Never trust a browser-posted
guest ID alone. Never store the claim secret in localStorage or expose it in URLs.

Response includes `guest_profile_id` (32 hex), `state: "guest"`, `created_at`
(Unix seconds), `linked_at: null`, `created: true|false`, and `proof_status`.
Same app + external guest ID + proof digest returns the same profile indefinitely.
A changed proof digest for the same external ID returns 409
`GUEST_REGISTRATION_CONFLICT`; resolve the conflict before retrying.
A lost response can be retried with the identical request. No separate idempotency
header is required. Do not rotate a guest proof by re-registering the guest.

### Read (`guests:read`)

```json
{"action":"read","guest_profile_id":"RETURNED_GUEST_PROFILE_ID"}
```

Only the credential's own app can read the profile. Reads do not return an email,
canonical Identity UUID, guest secret, or another user's linked subject. Last-seen
writes are coalesced hourly; no gameplay telemetry is retained by this API.

### Link after explicit user action (`guests:link`)

Offer **Keep my progress with Hybrid ID**. Validate the current local guest session,
then use standard Authorization Code + PKCE with `openid profile email guest:link`.
Validate state, nonce, PKCE, issuer, signature and audience in the callback. A user
may sign into an established account; never match or merge by email.

After the user confirms linking, call from your backend:
```json
{"action":"link","guest_profile_id":"RETURNED_GUEST_PROFILE_ID","guest_secret":"43_CHARACTER_BASE64URL_GUEST_PROOF","access_token":"CURRENT_HYBRID_ID_OIDC_ACCESS_TOKEN"}
```

The service requires the app-scoped credential, valid guest proof, same-app live
OIDC access token and grant with `guest:link`, and current Identity authentication.
The response includes `state: "linked"`, `sub`, and `issuer`. Verify `sub` matches
the validated ID token's subject and issuer equals `https://auth.hybrid-id.com`.
These are the app's normal pairwise identity, not a new account or a global ID.
Another identity cannot claim an already linked guest. Repeating the same link
is idempotent. Read/create alone never reveal the linked canonical subject.

In your own database, atomically map the guest profile to `(issuer, sub)`. Preserve
an existing registered game account. Merge only server-verified career events,
deduplicate match/award IDs and recalculate rank; never overwrite the account with
untrusted client scores. Commit the local merge idempotently so a crash after the
Identity link can retry safely. Do not destroy the old guest record before success.

This API links a guest to the account the user actually authenticated. It does not
create a password/email account, open public Hybrid ID registration, or bypass the
current invitation policy. A newly registered account can be linked through the
same normal sign-in flow when registration is available. In-place conversion into
a new native account is not an operation in v1.

### Usage (`guests:read`)

```json
{"action":"usage"}
```

Returns `retained_guest_profiles`, `unclaimed`, `linked`, `limit`, `remaining`,
`quota_basis: "unclaimed_guest_profiles"`, `usage_warning` (null,
`approaching_limit` at 80%, or `limit_reached` at 100%), and
`scope: "developer_all_apps"`. The flags `counts_toward_monthly_users: false` and
`guest_usage_charges_enabled: false` distinguish guests from registered MAU.

Guest capacity is **5× the plan's included monthly active-user allowance**, not
5× the current number of users. All of a developer's apps share this capacity.

| Plan | Included monthly active users | Unclaimed guest slots |
|---|---:|---:|
| Developer | 1,000 | 5,000 |
| Growth | 2,500 | 12,500 |
| Pro | 10,000 | 50,000 |
| Scale | 50,000 | 250,000 |

Only `state: guest` records occupy slots. Successful authorized linking releases
one slot immediately while preserving the historical record and proof commitment.
Consequently, `retained_guest_profiles` may exceed `limit`: compare **unclaimed**
to the limit. Linking retries never free a slot twice. Registered users count under
the existing deduplicated owner-wide monthly sign-in meter, not as extra guests.
Guest limits do not reset monthly, and a new app does not create a new allowance.

New registrations are admitted transactionally under a developer-wide lock;
parallel requests cannot overrun capacity. At capacity or after a downgrade,
existing reads, identical creation retries and valid linking remain available.
No records are automatically deleted. `remaining` is clamped to zero when the
current plan is smaller than existing usage. Capacity only increases when the
server recognizes the new entitlement; checkout redirects are not payment proof.
Paid checkout remains pending activation; contact the operator if an upgrade is
needed before self-service checkout is available.

Guest access is not billed at present. Optional app-data storage is metered
separately. Requests are limited to 240/minute per developer across apps.

### Required integration behavior at capacity

`409 GUEST_PROFILE_QUOTA` means **capacity action required**, with
`retryable: false`. Stop new guest registration attempts, notify the developer,
and present the plan upgrade action. Confirm available capacity through the usage
API before resuming registration. Preserve the original external ID and proof
for each request.

Show affected users a clear message that new guest registration is temporarily
unavailable. Existing registered guests and normal Hybrid ID sign-in remain
available subject to their own controls.

Quota rejection is not a transient service failure. Do not retry it automatically.
For transient failures, use bounded retries; honor `Retry-After` for 429.
Disabled features and revoked credentials require configuration repair.

## Errors and retries

Errors: `{"success":false,"error":{"code":"...","retryable":false}}`.

| HTTP | Code | Action |
|---|---|---|
|400|INVALID_GUEST_REQUEST / INVALID_GUEST_AUTHORITY|Fix schema or proof format; no caller-selected authority.|
|401|INVALID_GUEST_CREDENTIAL / GUEST_CREDENTIAL_EXPIRED_OR_REVOKED|Replace the backend credential through the owner flow.|
|403|GUEST_SCOPE_REQUIRED / GUEST_PROFILES_DISABLED / GUEST_OWNER_UNAVAILABLE / GUEST_APP_OWNER_CHANGED|Check app enablement, credential scope and owner status.|
|403|GUEST_LINK_CONSENT_REQUIRED / SIGN_IN_REQUIRED / IDENTITY_CHANGED|Restart the normal user-approved OIDC flow.|
|403|GUEST_LINK_DENIED|Validate local guest session and proof. Do not guess or match emails.|
|404|GUEST_NOT_FOUND|Wrong app or unknown profile; do not enumerate.|
|409|GUEST_REGISTRATION_CONFLICT|External ID is already registered with another proof.|
|409|GUEST_ALREADY_LINKED|Preserve the existing link; never overwrite it.|
|409|GUEST_PROFILE_QUOTA|Stop registration retries, alert the developer and present the plan upgrade action.|
|413|INVALID_GUEST_REQUEST|Reduce the JSON body to at most 8,000 bytes.|
|415|INVALID_GUEST_REQUEST|Send Content-Type: application/json.|
|429|GUEST_RATE_LIMIT|Honor Retry-After; back off with jitter.|
|503|GUEST_SERVICE_UNAVAILABLE / GUEST_PROFILES_DISABLED|Retry transient failures with bounded backoff; disabled features require configuration repair.|

Use bounded exponential backoff and jitter for transient failures. A timeout does
not establish whether registration succeeded: retain the same external ID, proof
digest and guest profile mapping on retry. Quota failures require capacity action
before registration resumes. The response sets `retryable: true` for 429 and
5xx, and false for other errors. For a disabled feature, configuration repair
is still required even when the transport retry flag is true.

## Guest access and subscription usage

Unclaimed guest profiles do **not** consume the developer's monthly active-user
allowance or authenticated app-profile storage slots. Guest access is not billed
at present. The separate 5× unclaimed-guest capacity and API rate limits still apply.

**Plans & usage → Guest access** displays total retained guest profiles, unclaimed
guests, and guests linked to Hybrid ID, pooled across the developer's apps. These
are profile counts, not visits or unique people. Unavailable counts are shown as
unavailable rather than zero. The authenticated `billing-summary` management
operation also returns `guest_usage`, including `status`, these three counts when
available, `counts_toward_monthly_users: false` and
`guest_usage_charges_enabled: false`.

Successful Hybrid ID authentication uses the existing monthly active-user meter.
Linking preserves the guest record but does not add a second active-user charge.
Repeated sign-ins by the same authenticated identity are deduplicated across the
developer's apps within the UTC month. No guest credentials can create an
authenticated session or write authenticated app data.

## Counting, privacy and proofs

Persisted guests contribute to the hourly managed-identity aggregate. Linking does
not insert another guest record: the canonical registered account and retained
historical guest are each counted once; the alias adds no third count. The aggregate
is managed records, not unique people, verified users or active users.

Identity retains pseudonymous guest records and append-only lifecycle commitments.
Raw guest UUIDs, proof secrets, canonical links and game activity are not published.
**Explorer publication is not activated by v1**: `proof_status: "not_published"`
is explicit. An audit commitment or pending publication intent is not a finalized
chain proof. Do not display a verified/on-chain badge from this status. A future
publisher must supply verifiable finality and historical resolution before doing so.
Unclaimed guest records have no automatic expiry. Contact the operator for a
privacy/deletion request; this version has no self-service deletion endpoint.

## StarQuest handoff

Registered production client: `hid_app_SYZiNqsWrU9wrio5T5Nn4vsK`; origin
`https://starquest.live`; issuer `https://auth.hybrid-id.com`. Preserve the existing
registered callbacks. Use the app's existing server-side guest UUID and validated
browser session, not a per-match guest. Keep PostgreSQL career events in StarQuest.
Store the Hybrid guest reference beside that guest, and use the existing OIDC
callback plus `guest:link` when the user explicitly keeps their progress.

Before production cutover, exercise duplicate creation/lost responses, another
app's profile denial, bad proof, missing consent, existing-account linking,
conflicting claim, replay, disabled/expired credential, quota exhaustion and a
restart during the local merge. The source tests use isolated fixtures; no shared
public sandbox credential is supplied. Register a separate staging app and owner
credential for integration tests. Never paste secrets into an agent conversation.
