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.
Need a parallel staging tenant?
If the customer's backend dev wants to test against attesto-staging before shipping to prod, see Setting up a staging tenant alongside production — same Apple .p8, different webhook URL, ~20 minutes once their staging callback URL exists.
Phase A — Pre-onboarding (collect info)
Before touching any system, get these from the tenant:
Apple (if they want Apple verification)
| Item | Where they get it | Notes |
|---|---|---|
| Bundle ID | Their app — Xcode project settings or App Store Connect | Exact 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 Key | They download once. Tell them to send it via a secure channel — encrypted email, 1Password share, Signal, etc. Not Slack DMs. |
| Key ID | Shown next to the key in App Store Connect | 10-char uppercase alphanumeric |
| Issuer ID | Top of the Keys page in App Store Connect | UUID, one per Apple team |
| App Apple ID (numeric) | App Store Connect → My Apps → their app → App Information → General Information → "Apple ID" | 9-10 digit number like 1671170558. Required when the app verifies production transactions. Optional for sandbox-only tenants. |
Google (if they want Google verification)
| Item | Where they get it | Notes |
|---|---|---|
| Package name | Their app — applicationId in Gradle, also Play Console → Settings → App details | Exact match required |
| Service account JSON | Google Cloud Console → IAM & Admin → Service Accounts → Create + Add Key → JSON | They 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)
| Item | Notes |
|---|---|
| Their callback URL | Must be https://, must NOT be a private/internal IP. They build the receiver per the Integration guide |
| (generated by you) Webhook secret | You'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
nameintenants(e.g. "Acme Production", "Acme Staging") - Environment — production vs staging (affects API key prefix:
attesto_live_vsattesto_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.
# 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:
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).
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 \
--app-apple-id 1234567890--app-apple-id is optional but strongly recommended at onboarding time. The Apple SDK requires it for production-environment verifier construction — without it, any verify call that resolves to production returns CREDENTIALS_MISSING. Sandbox-only tenants (TestFlight pre-launch) can skip it for now and add it later when the app goes live, but configuring it now removes a future re-onboarding step. The CLI emits a stderr warning if you omit it for production or auto environments.
Pick the right --environment for the tenant's app:
| Value | Use when |
|---|---|
production | App is published to the App Store with at least one live IAP transaction (or paid sandbox transactions exist on the production environment). |
sandbox | App is pre-launch — TestFlight only, internal distribution, pending App Store review, or has not yet had a production IAP transaction. Apple's production endpoint returns 401 for these apps; sandbox is the only working endpoint. |
auto | When you're not sure. Attesto tries production first and falls back to sandbox on 404 (transaction not found in this env) or 401 (app not authorized for production). |
Default to auto — it self-resolves the pre-launch case correctly. The only reason to pick sandbox explicitly is if the tenant has confirmed they're shipping live and you want to fail fast on misconfiguration. Once their app goes live, you can update the env via:
UPDATE apple_credentials SET environment = 'production'
WHERE tenant_id = '<tenant_id>';(No re-upload of the .p8 needed — the credentials themselves don't change.)
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.
shred -u ~/Downloads/AuthKey_ABC1234567.p8 # Linux
# or: rm -P on macOSGoogle
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:
- 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:
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)
# 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 OKand your tenant's callback should receive it within seconds
Google Pub/Sub setup
The trickiest part of Google integration. Tenant needs to:
- Cloud Console → Pub/Sub → Topics → Create topic (e.g.
acme-attesto-rtdn) - 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)
- Play Console → app → Monetize → Monetization setup → Real-time developer notifications: paste the topic name like
projects/acme-prod-12345/topics/acme-attesto-rtdn - 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
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
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":"2000000000000000"}'Use 2000000000000000 (16 digits, leading 2 like real Apple IDs) — it passes Apple's format check so the request reaches their lookup path. An all-zeros ID fails format validation upstream and returns a different error (4000006 Invalid transaction id) which is harder to interpret.
Expected response depends on whether you've completed Phase C (apple:set-credentials) yet:
| State | HTTP status | Body |
|---|---|---|
| Phase B done, Phase C not yet | 400 | {"valid":false,"error":"CREDENTIALS_MISSING","message":"Apple credentials are not configured for this tenant"} |
| Phase C done | 200 | {"valid":false,"error":"TRANSACTION_NOT_FOUND",...} |
If you're smoke-testing right after key:create and before apple:set-credentials, the 400 + CREDENTIALS_MISSING is the green signal — it proves auth resolved the key (no 401), tenant lookup succeeded (no 500), and the credentials loader correctly identified the gap. After Phase C the same call should flip to 200 + TRANSACTION_NOT_FOUND, proving the call now reaches Apple.
Use a known-bad transactionId either way so you don't burn quota or rely on a real purchase.
For Google, the equivalent post-credentials state is 200 + PURCHASE_NOT_FOUND for a deliberately-malformed purchaseToken.
Real transaction (optional, only if the tenant has a sandbox purchase ready)
If the tenant gives you a real sandbox transactionId:
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:
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/reference/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/healthDon'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.sqlSELECT 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_atshows traffic — if it's still null, they haven't actually integrated yet - [ ] Review their
valid: falserate — 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
.p8keys, 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 testkeys - ❌ Per-feature flag — never
What's next
Now that the tenant is onboarded:
- They follow Integration guide on their end
- You roll into routine Operations
- Calendar Maintenance tasks for credential rotation (Apple
.p8annually, Google service account every 90 days)