Skip to content

Tenants ​

Every request to /v1/* is authenticated with a tenant-scoped API key. A tenant represents one "customer" of an Attesto deployment — typically one app, but you can also use tenants to separate environments (staging vs production) or per-team isolation.

Tenant model ​

tenant
  ├── api_keys          (one tenant → many keys)
  ├── apple_credentials (one tenant → 0 or 1)
  ├── google_credentials (one tenant → 0 or 1)
  └── webhook_configs   (one tenant → 0 or 1)

Each tenant has its own encrypted Apple .p8, Google service-account JSON, and webhook secret. There's no cross-tenant data sharing — a tenant's keys can't verify another tenant's transactions.

Create a tenant ​

bash
mise run cli -- tenant:create --name "My App"

For self-hosted Docker:

bash
docker compose exec attesto attesto tenant:create --name "My App"

Output:

json
{ "id": "tenant_01HXY...", "name": "My App", "createdAt": "2026-04-25T..." }

The id is a ULID-prefixed string (tenant_<26-char ULID>). Save it — every subsequent command needs it.

The name is a human label only; it doesn't affect routing or auth. You can list and rename tenants later.

List tenants ​

bash
mise run cli -- tenant:list

Output: one JSON object per tenant.

{ "id": "tenant_01HXY...", "name": "My App",  "isActive": true,  "createdAt": "..." }
{ "id": "tenant_01HXZ...", "name": "Test App","isActive": true,  "createdAt": "..." }

API keys ​

API keys are how clients authenticate to the verify endpoints. They follow this format:

attesto_<env>_<43-char-base64url>

attesto_live_8xYzKj2pNm4QrVtA9bC1dF6gH8jL0mN3pQ4rS6tU
attesto_test_3aB5cD7eF9gH1iJ3kL5mN7oP9qR1sT3uV5wX7yZ

The prefix tells you the environment (live or test), useful for:

  • Spotting a wrong-env key in logs and error messages
  • Allowing your client code to fail loudly if it's running with the wrong-env key (if (key.startsWith("attesto_test_") && env === "production") throw …)

The remaining 32 bytes are random; the entire key is cryptographically unguessable.

Mint a key ​

bash
mise run cli -- key:create tenant_01HXY... --env test --name "dev machine"

Options:

FlagMeaning
(positional)Tenant ID — required
--envlive (default) or test — sets the prefix
--nameOptional human label (shown in key:list)

Output:

json
{
  "id": "key_01HXY...",
  "tenantId": "tenant_01HXY...",
  "keyPrefix": "a8Fz3Q1c",
  "name": "dev machine",
  "rawKey": "attesto_test_8xYz...",
  "warning": "Save the rawKey — it cannot be recovered after this line."
}

The rawKey is shown ONCE

Attesto stores only the SHA-256 hash of the key. There is no recovery path. Lose the raw key and you have to mint a new one and revoke the old. Save it immediately into a password manager or secret store.

The keyPrefix is the first 8 characters of the random suffix — safe to display in UIs / logs / dashboards as an identifier without exposing the secret.

List keys for a tenant ​

bash
mise run cli -- key:list tenant_01HXY... [--limit 100] [--offset 0]

Each key prints one JSON object:

json
{
  "id": "key_01HXY...",
  "tenantId": "tenant_01HXY...",
  "keyPrefix": "a8Fz3Q1c",
  "name": "dev machine",
  "createdAt": "2026-04-25T...",
  "lastUsedAt": "2026-04-25T...",
  "revokedAt": null
}

lastUsedAt is updated on every successful auth-middleware lookup (with a small race-tolerant window so concurrent requests don't fight). It's your audit trail for "is this key actually being used?"

Revoke a key ​

bash
mise run cli -- key:revoke key_01HXY...

Revocation is immediate. The partial unique index on api_keys.key_hash is filtered to WHERE revoked_at IS NULL, so the next request with the revoked key returns 401 UNAUTHENTICATED on the very next lookup — no cache window.

Re-running key:revoke on an already-revoked key returns a clear "already revoked" message and exit code 1, so it's safe to use in idempotent scripts.

Multi-environment patterns ​

A few common ways to use tenants:

One tenant per app, one key per environment ​

Simple and works for most teams:

tenant: "Acme Production"
  ├─ key (live, "production-backend")
  ├─ key (live, "ops-cli")
  └─ key (test, "staging-backend")
  apple_credentials: production .p8
  google_credentials: production service account

You'll have one set of credentials uploaded but multiple keys minted for different consumers. Both live and test keys can call the same verify endpoints — the prefix is informational.

One tenant per environment ​

Cleaner separation, more credential management:

tenant: "Acme — Production"
  ├─ apple_credentials: production .p8
  └─ google_credentials: production service account

tenant: "Acme — Staging"
  ├─ apple_credentials: staging .p8 (or same file with --environment sandbox)
  └─ google_credentials: same JSON or staging account

Use this when staging and production have different Apple keys or Google service accounts. Required if your staging Play Console app is a separately-registered package (com.acme.app.staging).

One tenant per customer (multi-tenant SaaS) ​

If you're running Attesto as a service for multiple downstream apps:

tenant: "Customer A — myapp"
  ├─ apple_credentials: their .p8
  └─ google_credentials: their service account

tenant: "Customer B — otherapp"
  ├─ apple_credentials: their .p8
  └─ google_credentials: their service account

Each customer's credentials are encrypted with column-scoped subkeys, so a database leak doesn't cross-correlate plaintexts.

Audit-log setting per tenant ​

The validation audit log is decided per tenant. The tenant's own setting wins; ENABLE_VALIDATION_AUDIT_LOG is only the fallback for tenants that express no preference.

bash
# Nothing is kept for this tenant, whatever the deployment default is
mise run cli -- tenant:set-audit-log tenant_01HXY... --enabled false

# Audit this tenant even on a deployment that is otherwise silent
mise run cli -- tenant:set-audit-log tenant_01HXY... --enabled true

# Back to following ENABLE_VALIDATION_AUDIT_LOG
mise run cli -- tenant:set-audit-log tenant_01HXY... --enabled default

Output:

json
{
  "id": "tenant_01HXY...",
  "auditLogEnabled": false,
  "note": "no audit rows written for this tenant, whatever the deployment default"
}

This is what lets one installation serve a customer who has been promised that nothing is kept alongside one that wants a full audit trail. See Data retention for the guarantee that follows from it.

Erase a tenant's data ​

tenant:deactivate below is a soft delete — it flips is_active and keeps every row. When someone asks you to actually delete their data, use tenant:purge.

bash
# Always look first. --dry-run needs no confirmation and deletes nothing.
mise run cli -- tenant:purge tenant_01HXY... --dry-run

# Then commit to it. --confirm must repeat the tenant id exactly.
mise run cli -- tenant:purge tenant_01HXY... --confirm tenant_01HXY...

Output:

json
{
  "id": "tenant_01HXY...",
  "dryRun": false,
  "deleted": {
    "apiKeys": 2,
    "appleCredentials": 1,
    "googleCredentials": 1,
    "webhookConfigs": 1,
    "webhookEvents": 1402,
    "webhookDeliveries": 1402,
    "googlePurchaseChains": 37,
    "validationAudit": 95231,
    "tenant": 1
  }
}

This removes the data from the live database, and there is no undo in Attesto. Retyping the id is the only guard; take the dry run seriously — it prints the tenant's name, which is what catches a pasted wrong id.

What "deleted" does and does not cover ​

Be careful quoting this to a data subject. The purge clears the live database. It does not reach:

  • Database backups. Fly Postgres keeps snapshots for 7 days (free) or 30 days (paid), and Operations documents restoring a single tenant from one. Until those age out, the purged rows still exist off-cluster and are recoverable. For a hard erasure guarantee, either wait out your snapshot retention before confirming deletion, or expire the affected snapshots.
  • Shipped logs. The tenant id appears in every access-log line. No identifier HMACs or purchase tokens are logged (see Data retention → Logging), but the tenant id itself persists for whatever retention your log provider is set to.
  • The running process's memory. Decrypted Apple and Google credentials are cached for up to 5 minutes and Google OAuth tokens for up to an hour. The CLI runs in a separate process and cannot clear them. API-key auth is unaffected — it reads the database on every request, so a purged tenant is locked out immediately — but restart the service if you need the decrypted credentials out of memory now.

A re-run is safe: the command is idempotent, and running it twice is the fix if it is ever interrupted part-way.

Why it is not simply DELETE FROM tenants: most tables cascade from tenants, but validation_audit does not — it stores tenant_id as a plain column with no foreign key, so the audit trail can outlive the tenant row. Deleting the tenant by hand therefore leaves behind an HMAC of every identifier that tenant ever looked up, plus the timing of each lookup, indefinitely. tenant:purge deletes those rows explicitly, and does so even when the tenant row is already gone — which is exactly the state a hand-rolled delete leaves behind.

See Data retention for what is held where.

Deactivate a tenant ​

To soft-delete a tenant (preserving historical audit data):

bash
mise run cli -- tenant:deactivate tenant_01HXY...

For self-hosted Docker:

bash
docker compose exec attesto attesto tenant:deactivate tenant_01HXY...

Output:

json
{
  "id": "tenant_01HXY...",
  "isActive": false,
  "deactivatedAt": "2026-04-29T..."
}

Re-running on an already-deactivated tenant returns exit code 1 with a clear "already deactivated" message — safe to use in idempotent scripts.

Setting is_active = false causes:

  • All API keys for the tenant to fail auth-middleware lookup with 401
  • Webhook receivers to reject inbound events with 404 TENANT_NOT_FOUND
  • Outbound deliveries on existing webhook_deliveries rows to be abandoned on the next dispatch tick (no longer retried)

Existing audit data, encrypted credentials, and event history are preserved. This is not a hard delete — to fully remove the tenant including its credentials, drop the relevant rows directly via SQL.

What's next ​

  • Onboarding a tenant — full procedure for adding a new tenant including pre-onboarding checklist, smoke tests, and handoff
  • Apple setup — install Apple credentials for a tenant
  • Google setup — install Google credentials
  • Webhooks — configure outbound webhook callback per tenant
  • Integration guide — what you'll hand to the tenant's backend developer
  • Maintenance — credential rotation and key hygiene