One key, many customers: subjects, bounded session policies, the relay lifecycle in curl, and key rotation.

View as Markdown

Integrating a multi-customer product

You run a product with many customers, and your product — a relay, a support agent, a scheduled job — needs to sign in to *their* accounts on *their* behalf. One API key, many people. This page is that lifecycle, in curl, with the two things that make it safe: a subject for every customer, and a session policy with an end date.

The shape of it

What it isWhy it exists
Your keyOne apk_key_… holding enrol, connectors:read, signin, credentials:write, audit:readIt covers every credential it enrols (coverEnrolled), so there is nothing to mint per customer.
A subjectYour customer, as this service sees them: an externalId, a label, a consent recordTheir passkeys are bound to it. A sign-in must name it; a revoke takes all of theirs at once.
A session policy{ mode: "session", expiresAt }, 30 days at mostUnattended signing that is bounded, revocable, scoped to your key, rate-limited and audited. Never silent.

The step you still cannot do: the customer signs in to the site first, in a browser you drive. This service holds nothing that gets past a login. Design your onboarding around that moment — it is the only one that needs them present.

export APK=https://app.solospace.dev
export APK_KEY=apk_key_…

1. Create the subject

Idempotent on externalId, so run it on every onboarding without checking first. Record the consent wording the person actually saw.

curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"externalId":"cust_8812","displayLabel":"Acme Ltd",
          "consent":{"text":"Let Acme Relay sign in to GitHub for me.","ip":"203.0.113.9","userAgent":"…"}}' \
     $APK/v1/subjects
# → { "state":"ok", "subjectId":"sub_…", "existed":false }

2. Enrol a passkey on the site's own page

With the customer signed in to the site in your browser, open its "add a passkey" page. Begin with the subject named and a bounded session policy:

curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"domain":"github.com","origin":"https://github.com","userName":"octocat",
          "subjectId":"sub_…",
          "policy":{"mode":"session","expiresAt":"2026-10-16T00:00:00Z"}}' \
     $APK/v1/enrol/begin
# → { "state":"ok", "ticket":"9a41…", "rpId":"github.com", "shim":"…" }

Evaluate shim in the page, click the site's "Add passkey" button, and watch window.__apkEnrol.status reach created (or poll GET /v1/enrol/9a41…). When the site confirms, close the loop:

curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"ticket":"9a41…","ok":true}' $APK/v1/enrol/complete
# → { "state":"ok", "status":"confirmed", "credentialId":"b64url…" }

The credential is now covered by your key, bound to sub_…, and will sign unattended until the expiresAt you set. It shows on the owner's dashboard as "Added via API".

3. Sign in, later, without the customer

The ticket path — your browser runs the page, the page fetches its own signature:

curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"credentialId":"b64url…","subjectId":"sub_…"}' $APK/v1/signin/begin
# → { "state":"ok", "ticket":"5f0c…", "signinUrl":"https://github.com/login", "shim":"…" }

Evaluate shim on the sign-in page, drive it to the passkey prompt (the connector's notes say where it is), and poll GET /v1/signin/5f0c… until used.

The fallback — the page's CSP will not let it reach this service, so you ferry the bytes:

curl -sS -H "Authorization: Bearer $APK_KEY" -H "content-type: application/json" \
     -d '{"credentialId":"b64url…","subjectId":"sub_…","origin":"https://github.com",
          "clientDataJSON":"eyJ0eXBl…","waitUpToMs":25000}' \
     $APK/v1/signin/complete
# → 200 { "authData":"…", "signature":"…", "userHandle":"…" }   hand these to window.__apk.deliver
# → 202 only if the policy is approval; a session policy answers at once

Omitting subjectId on a subject-bound credential — or naming the wrong one — is 403 subject_mismatch, terminal. That is the control that keeps one customer's key from being used under another customer's name; do not retry it with a different subject.

4. Read one credential back

curl -sS -H "Authorization: Bearer $APK_KEY" $APK/v1/credentials/b64url…
# → { "status":"active", "enrolledVia":"api", "subject":{"externalId":"cust_8812",…},
#     "policy":{"mode":"session","expiresAt":"2026-10-16T00:00:00Z","rateLimitPerHr":60} }

GET /v1/connectors?subjectId=sub_… lists everything one customer has; GET /v1/audit?subjectId=sub_… is what your product did in their name.

5. Offboard

One passkey, or the whole customer. Both idempotent; both 404 for anything outside your tenant, so an id from somewhere else is never confirmed to exist.

curl -sS -X POST -H "Authorization: Bearer $APK_KEY" $APK/v1/credentials/b64url…/revoke
# → { "revoked":true, "already":false }

curl -sS -X POST -H "Authorization: Bearer $APK_KEY" $APK/v1/subjects/sub_…/revoke
# → { "revoked":true, "credentialsRevoked":2 }

Rotating the key

Every unattended grant ends after 30 days at most, and so does the key. Subscribe to key.expiring on your webhook (fired 7 days and 1 day before), and rotate from a job:

curl -sS -X POST -H "Authorization: Bearer $APK_KEY" $APK/v1/keys/rotate
# → { "key":"apk_key_…", "prefix":"apk_key_7f3c", "expiresAt":"…", "previousExpiresAt":"<24 h from now>" }

Store the new key, redeploy inside the 24-hour grace, and the old one lapses on its own. The grant, the subjects and every credential carry over; only the secret changes.

What to tell your customers

  • Their passkey lives in this service, not in your product, and never leaves it.
  • Your product can sign in as them on the sites they connected, until the date they agreed to.
  • They — or you — can end it at any moment, and the audit log of what happened stays.
  • Their password is never involved, and no one on your side can read it, because nobody has it.