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)
| 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 |
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--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.
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":"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:
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/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/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)