Skip to content

Onboarding a new tenant

Operator playbook. When someone needs Attesto access — a new customer, a new app within your existing org, a separate dev environment — this is the procedure. End state: their backend can call your Attesto deployment with a working API key and (optionally) receive verified webhook events.

Time budget: roughly 30-60 minutes of active work, plus 1-7 days of calendar time waiting on Apple/Google access propagation.

Phase A — Pre-onboarding (collect info)

Before touching any system, get these from the tenant:

Apple (if they want Apple verification)

ItemWhere they get itNotes
Bundle IDTheir app — Xcode project settings or App Store ConnectExact match required (com.example.app). Watch / App Clip variants have separate bundle IDs and need separate Attesto tenants.
App Store Connect API Key (.p8)App Store Connect → Users and Access → Integrations → App Store Connect API → In-App Purchase → Generate API KeyThey download once. Tell them to send it via a secure channel — encrypted email, 1Password share, Signal, etc. Not Slack DMs.
Key IDShown next to the key in App Store Connect10-char uppercase alphanumeric
Issuer IDTop of the Keys page in App Store ConnectUUID, one per Apple team

Google (if they want Google verification)

ItemWhere they get itNotes
Package nameTheir app — applicationId in Gradle, also Play Console → Settings → App detailsExact match required
Service account JSONGoogle Cloud Console → IAM & Admin → Service Accounts → Create + Add Key → JSONThey generate it, then need to also invite the service account in Play Console (see Phase C). Send via secure channel.

Webhooks (if they want event delivery)

ItemNotes
Their callback URLMust be https://, must NOT be a private/internal IP. They build the receiver per the Integration guide
(generated by you) Webhook secretYou'll generate via openssl rand -base64 32 and share it back to them — they need it to verify signatures

Logistical

  • Tenant display name — what you'll record as name in tenants (e.g. "Acme Production", "Acme Staging")
  • Environment — production vs staging (affects API key prefix: attesto_live_ vs attesto_test_)
  • Contact — email of the engineer who'll integrate. They'll get the API key handoff and need to know who to ping if something breaks

Do this Phase A async

Send the tenant a checklist email asking for everything above. Wait until you have it all before starting Phase B. This avoids "I'll send the service account JSON tomorrow" extending a 1-hour task to 3 days.

Phase B — Create the tenant + API key

This is the fast part. You're at a terminal with mise working.

bash
# 1. Create the tenant
mise run cli -- tenant:create --name "Acme — Production"
# → { "id": "tenant_01HXY...", ... }

export TENANT_ID=tenant_01HXY...   # save it

# 2. Mint the API key — use --env live for production, --env test for staging
mise run cli -- key:create $TENANT_ID --env live --name "acme-backend"
# → { "rawKey": "attesto_live_8xYz...", ... }

Save rawKey immediately — Attesto only stores the SHA-256 hash, so this is your only chance to capture the value. Drop it into your secret manager under a name like attesto-key/acme-prod.

For self-hosted Docker:

bash
docker compose exec attesto attesto tenant:create --name "Acme — Production"
docker compose exec attesto attesto key:create $TENANT_ID --env live --name "acme-backend"

Phase C — Configure store credentials

Apple

Get the .p8 file onto the machine where you're running the CLI (your laptop for mise-based ops, or copy into the container for Docker via docker cp).

bash
mise run cli -- apple:set-credentials $TENANT_ID \
  --bundle-id com.acme.app \
  --key-id ABC1234567 \
  --issuer-id 57246542-96fe-1a63-e053-0824d011072a \
  --key-path ~/Downloads/AuthKey_ABC1234567.p8 \
  --environment auto

--environment auto is almost always correct — Attesto tries production first and falls back to sandbox if Apple says the transaction isn't there. Use --environment sandbox only if the tenant is exclusively testing StoreKit Testing in Xcode (uses a local CA, not Apple's).

After running, securely delete the .p8 from your filesystem. The encrypted version is in apple_credentials; you don't need the plaintext copy. The tenant has the original.

bash
shred -u ~/Downloads/AuthKey_ABC1234567.p8     # Linux
# or: rm -P on macOS

Google

Get the service account JSON file accessible. Note that Google's flow has two steps — IAM grant in Cloud Console, plus a Play Console invitation. The tenant needs to do the Play Console part:

  1. Tenant action (in Play Console): Users and permissions → Invite new user → paste the service-account email → grant app permissions for the apps you'll verify (at minimum: "View app information and download bulk reports" + "View financial data, orders, and cancellation survey responses")

Then you run:

bash
mise run cli -- google:set-credentials $TENANT_ID \
  --package-name com.acme.app \
  --service-account-path ~/Downloads/acme-prod-service-account.json \
  --pubsub-audience https://attesto.your-operator.com/v1/webhooks/google/$TENANT_ID

--pubsub-audience is strongly recommended — even if the tenant isn't using webhooks today, configuring it now avoids having to re-onboard later. The value is the eventual webhook URL for this tenant.

Securely delete the service account JSON from your filesystem after.

Phase D — Configure webhooks (if requested)

bash
# Generate a fresh secret (give a copy to the tenant — they'll need it for HMAC verify)
NEW_SECRET="$(openssl rand -base64 32)"
echo "Webhook secret for $TENANT_ID: $NEW_SECRET"   # save this output securely

mise run cli -- webhook:set-config $TENANT_ID \
  --callback-url https://acme-backend.example.com/attesto-webhook \
  --secret "$NEW_SECRET"

Then the tenant does (or you instruct them to do):

Apple webhook URL setup

In App Store Connect → Apps → their app → App Store Server Notifications:

  • Set Production Server URL AND Sandbox Server URL to:
    https://attesto.your-operator.com/v1/webhooks/apple/<TENANT_ID>
  • Version: V2 (V1 isn't supported)
  • Test it: click "Request a Test Notification" — Attesto should return 200 OK and your tenant's callback should receive it within seconds

Google Pub/Sub setup

The trickiest part of Google integration. Tenant needs to:

  1. Cloud Console → Pub/Sub → Topics → Create topic (e.g. acme-attesto-rtdn)
  2. Click topic → Subscriptions → Create subscription:
    • Type: Push
    • Endpoint: https://attesto.your-operator.com/v1/webhooks/google/<TENANT_ID>
    • Authentication: enable, choose the same service account from Phase C
    • Audience: paste the same endpoint URL (must match --pubsub-audience)
  3. Play Console → app → Monetize → Monetization setup → Real-time developer notifications: paste the topic name like projects/acme-prod-12345/topics/acme-attesto-rtdn
  4. Click "Send test notification" — should arrive at your callback within seconds

Phase E — Smoke test

Don't hand off the API key without verifying the basics work.

Health check

bash
curl https://attesto.your-operator.com/health
# → {"status":"ok"}

curl https://attesto.your-operator.com/ready
# → {"status":"ok","checks":{"db":"ok","encryption":"ok"}}

Auth check

bash
ATTESTO_KEY="attesto_live_…"   # the key you minted in Phase B

curl -X POST https://attesto.your-operator.com/v1/apple/verify \
  -H "Authorization: Bearer $ATTESTO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"transactionId":"0000000000000000"}'

Expected response: 200 OK with {"valid":false,"error":"TRANSACTION_NOT_FOUND",...}. This proves auth works (no 401), credentials are configured (no CREDENTIALS_MISSING), and the call reaches Apple. Use a known-bad transactionId so you don't burn quota or rely on a real purchase.

For Google, you can do the same with a deliberately-malformed purchaseToken. You'll get PURCHASE_NOT_FOUND — same signal.

Real transaction (optional, only if the tenant has a sandbox purchase ready)

If the tenant gives you a real sandbox transactionId:

bash
curl -X POST https://attesto.your-operator.com/v1/apple/verify \
  -H "Authorization: Bearer $ATTESTO_KEY" \
  -d '{"transactionId":"2000000123456789"}'

Expected: 200 OK with {"valid":true, ...}. If you get BUNDLE_ID_MISMATCH, the bundle ID you configured doesn't match the transaction's — re-run apple:set-credentials with the correct bundle.

Webhook check (if configured)

Ask the tenant to click "Request a Test Notification" in App Store Connect or "Send test notification" in Play Console. Verify in Attesto logs:

bash
fly logs -a attesto | jq -c 'select(.path | startswith("/v1/webhooks"))'

You should see a 200 OK for the webhook endpoint, and the tenant should see the event arrive at their callback URL.

Phase F — Handoff package

Send the tenant:

Tenant ID:           tenant_01HXY...
API base URL:        https://attesto.your-operator.com
API key:             [via secure channel — 1Password share, Signal, encrypted email]
Webhook callback URL: [their URL — confirm spelling]
Webhook secret:      [via secure channel]

Documentation:       https://attesto-docs.netlify.app/guide/integration
API reference:       https://attesto-docs.netlify.app/reference/api
Webhook format:      https://attesto-docs.netlify.app/guide/webhooks

Apple Server URL (set this in App Store Connect):
  https://attesto.your-operator.com/v1/webhooks/apple/tenant_01HXY...

Google Pub/Sub push endpoint:
  https://attesto.your-operator.com/v1/webhooks/google/tenant_01HXY...

Support: [your email / Slack channel]
Status / health: https://attesto.your-operator.com/health

Don't paste the raw API key into Slack / email. Use a secret-sharing service that auto-deletes (1Password share, Bitwarden Send, https://onetimesecret.com, Signal disappearing messages).

Phase G — Post-onboarding monitoring (first week)

For the first week of the tenant's traffic:

Daily

  • [ ] Check webhook_deliveries.status='failed' count for this tenant — should be near 0. A persistent count means their callback URL is broken.
    sql
    SELECT count(*) FROM webhook_deliveries
     WHERE tenant_id = 'tenant_01HXY...' AND status = 'failed';
  • [ ] Skim Fly logs filtered to this tenant for unexpected errors:
    bash
    fly logs -a attesto | jq -c 'select(.tenantId == "tenant_01HXY...") | select(.level == "error" or .level == "warn")'

Once at end of week 1

  • [ ] Verify their last_used_at shows traffic — if it's still null, they haven't actually integrated yet
  • [ ] Review their valid: false rate — high rate (>10%) suggests something wrong on their end (wrong env, fake test IDs, etc.)
  • [ ] Confirm webhook deliveries are flowing if applicable

After week 1, drop to weekly checks and roll into your normal Operations routine.

Common onboarding-time errors

CREDENTIALS_MISSING on the smoke test

You skipped Phase C, or apple:set-credentials / google:set-credentials errored silently. Re-run, watch the output for the success JSON line.

GOOGLE_API_ERROR with details.status: 401

The tenant didn't complete the Play Console "Invite new user" step — their service account is recognized by Google Cloud but doesn't have permission on the Play app. Have them recheck Phase C step 1.

GOOGLE_API_ERROR with details.status: 403

The Google Play Android Developer API isn't enabled in the tenant's GCP project. Have them go to Cloud Console → APIs & Services → Library → search "Google Play Android Developer API" → Enable.

BUNDLE_ID_MISMATCH on first verify

The bundle ID you configured doesn't match what's actually on the tenant's transaction. Re-run apple:set-credentials with the correct value. Watch out for Watch extensions / App Clips having distinct bundle IDs.

Webhook test arrives at Attesto but not at the tenant

Check webhook_deliveries for that tenant — the last_response_code / last_response_body columns will show what their endpoint returned. Common: 404 (URL has a typo), 401 (their HMAC verifier is wrong), 5xx (their endpoint crashed).

When NOT to use a new tenant

A tenant is a security boundary. Use one tenant per app as the default. Reasons to split further:

  • ✅ Production vs staging — separate .p8 keys, different blast radius
  • ✅ Different App Store / Play Console accounts — must be separate
  • ✅ Different paid customer of your service (multi-tenant SaaS)

Reasons NOT to split:

  • ❌ Per-environment in your CI pipeline (CI / dev / staging / prod) — one tenant + multiple keys is enough
  • ❌ Per-developer (every engineer gets their own tenant) — overkill, use shared --env test keys
  • ❌ Per-feature flag — never

What's next

Now that the tenant is onboarded: