# 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 is | Why it exists |
|---|---|---|
| Your key | One `apk_key_…` holding `enrol`, `connectors:read`, `signin`, `credentials:write`, `audit:read` | It covers every credential it enrols (`coverEnrolled`), so there is nothing to mint per customer. |
| A subject | Your customer, as this service sees them: an `externalId`, a label, a consent record | Their 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 most | Unattended 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.

```bash
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.

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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

```bash
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.

```bash
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:

```bash
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.
