> ## Documentation Index
> Fetch the complete documentation index at: https://ramps-docs-sync-20260603.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Register, reauthenticate, and manage Global Account credentials (passkey, OAuth, email OTP)

Every Global Account action beyond receiving funds must be authorized by a session signing key. Sessions are issued by verifying one of three **credential types** on the account:

| Type            | When to use it                                                                                                                                                |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`PASSKEY`**   | Best default. Biometric, phishing-resistant, usable across the user's devices via iCloud Keychain / Google Password Manager.                                  |
| **`OAUTH`**     | Your platform already authenticates the user via OIDC (Google, Apple, your own IdP) and you want Grid to trust the same identity.                             |
| **`EMAIL_OTP`** | Lowest-friction option. Works on any device with email access — no biometric hardware, identity provider, or client SDK required beyond the code entry field. |

A single internal account can hold one `EMAIL_OTP` credential and multiple distinct `PASSKEY` credentials concurrently. `OAUTH` credentials can be added for each supported provider identity.

## Registration vs. verification

Global Accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on the internal account. Use that credential to mint the first session, or use an already-registered `OAUTH` or `PASSKEY` credential after you add one.

To produce a session:

* **`EMAIL_OTP`** and **`OAUTH`** — call **`POST /auth/credentials/{id}/verify`** with the OTP value (or a fresh OIDC token) plus a `clientPublicKey`. The response carries the `encryptedSessionSigningKey`.
* **`PASSKEY`** — call **`POST /auth/credentials/{id}/challenge`** with your `clientPublicKey` to receive a Grid-issued WebAuthn `challenge` and `requestId`, run `navigator.credentials.get()` against that challenge, then call **`POST /auth/credentials/{id}/verify`** with the resulting assertion and the `Request-Id` header. Grid bakes the `clientPublicKey` from the `/challenge` call into the session-creation payload, so it does **not** appear on the `/verify` body.

To add another credential, call **`POST /auth/credentials`** with the new credential details. Because the account already has an `EMAIL_OTP` credential, Grid returns a `202` signed-retry challenge. Stamp that `payloadToSign` with an active session signing key, then retry the same request with `Grid-Wallet-Signature` and `Request-Id`. The signed retry returns the new `AuthMethod`.

Re-authentication after a session expires skips the original `POST /auth/credentials` create call but otherwise follows the same per-type pattern. `EMAIL_OTP` re-auth needs a fresh OTP email, so call `POST /auth/credentials/{id}/challenge` first; `PASSKEY` re-auth uses the same `/challenge` (with `clientPublicKey`) → `/verify` two-step as the first authentication; `OAUTH` is the exception — since proof of control is a fresh OIDC token, there's nothing to pre-issue, so `/verify` alone suffices.

## Passkey

### Passkey registration

Passkey registration spans four parties: the **client** (browser or app), your **integrator backend**, **Grid**, and the platform authenticator (Touch ID / Face ID / Windows Hello / a security key). Your backend issues the WebAuthn registration challenge. Registering the passkey on Grid is an additional-credential signed-retry flow, authorized by an active session from an existing credential. The first authentication challenge is requested explicitly via `POST /auth/credentials/{id}/challenge` — same call shape used for every subsequent reauthentication.

```mermaid theme={null}
sequenceDiagram
  participant C as Client
  participant IB as Integrator backend
  participant G as Grid
  participant A as Authenticator

  C->>IB: Start registration
  IB->>IB: Generate random challenge (stored in session)
  IB-->>C: { challenge, rpId, user }
  C->>A: navigator.credentials.create({ challenge, … })
  A-->>C: attestation (credentialId, clientDataJson, attestationObject)
  C->>IB: POST /my-backend/passkey/register (attestation)
  IB->>G: POST /auth/credentials { type: PASSKEY, challenge, attestation, … }
  G-->>IB: 202 { payloadToSign, requestId, expiresAt }
  IB-->>C: { payloadToSign, requestId }
  C->>C: stamp(payloadToSign, existingSessionPrivateKey)
  C->>IB: { stamp, requestId }
  IB->>G: Same POST<br/>Grid-Wallet-Signature: stamp<br/>Request-Id: requestId
  G-->>IB: 201 AuthMethod { id, type, accountId, … }
  IB-->>C: { credentialId }
  C->>C: generateClientKeyPair()
  C->>IB: POST /my-backend/passkey/challenge (clientPublicKey)
  IB->>G: POST /auth/credentials/{id}/challenge<br/>{ clientPublicKey }
  G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt }
  IB-->>C: { challenge, requestId }
  C->>A: navigator.credentials.get({ challenge, … })
  A-->>C: assertion (credentialId, clientDataJson, authenticatorData, signature)
  C->>IB: POST /my-backend/passkey/verify (assertion, requestId)
  IB->>G: POST /auth/credentials/{id}/verify<br/>Request-Id: requestId<br/>{ type: PASSKEY, assertion }
  G-->>IB: 200 AuthSession { encryptedSessionSigningKey, expiresAt }
  IB-->>C: { encryptedSessionSigningKey, expiresAt }
  C->>C: decrypt with private key, hold session signing key
```

The `challenge` on `POST /auth/credentials` is the one your backend issued for WebAuthn registration. The signed-retry `payloadToSign` is the Turnkey registration payload that the customer's current session authorizes. The challenge used for the WebAuthn assertion comes later from `POST /auth/credentials/{id}/challenge` — Grid bakes the `clientPublicKey` you send there into the session-creation payload, sealing the resulting session signing key to the device that generated it. Because that key plumbing happens on `/challenge`, the subsequent `/verify` call carries only the assertion (plus the `Request-Id` header).

<Tip>
  Passkeys are domain-bound. Before shipping, set up your `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` entries so the platform authenticator binds the passkey to your origin and app bundles:

  * Web: [passkeys.dev bootstrapping guide](https://passkeys.dev/docs/use-cases/bootstrapping/#opting-the-user-into-passkeys)
  * Android: [Create passkeys on Android](https://developer.android.com/identity/passkeys/create-passkeys)
  * iOS: [Supporting passkeys](https://developer.apple.com/documentation/authenticationservices/supporting-passkeys)
</Tip>

#### Client sample code

The client never talks to Grid. It talks to your integrator backend — the snippets below simulate that call with `fetch('/my-backend/...')`, which your backend then relays to Grid.

<CodeGroup>
  ```typescript Web (TypeScript) theme={null}
  // 1. Ask your backend for a registration challenge.
  const startRes = await fetch("/my-backend/passkey/register/start", {
    method: "POST",
    credentials: "include",
  });
  const { challenge, rpId, user } = await startRes.json();

  // 2. Ask the authenticator to create a passkey.
  const attestation = (await navigator.credentials.create({
    publicKey: {
      challenge: base64urlToBytes(challenge),
      rp: { id: rpId, name: "Acme Wallet" },
      user: {
        id: base64urlToBytes(user.id),
        name: user.email,
        displayName: user.displayName,
      },
      pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256
      authenticatorSelection: { residentKey: "required", userVerification: "required" },
      timeout: 60_000,
    },
  })) as PublicKeyCredential;
  const att = attestation.response as AuthenticatorAttestationResponse;

  // 3. Send the attestation to your backend, which starts POST /auth/credentials.
  const registerRes = await fetch("/my-backend/passkey/register", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      nickname: "This device",
      credentialId: bytesToBase64url(new Uint8Array(attestation.rawId)),
      clientDataJson: bytesToBase64url(new Uint8Array(att.clientDataJSON)),
      attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)),
      transports: att.getTransports?.() ?? [],
    }),
  });
  const { payloadToSign, requestId: registrationRequestId } = await registerRes.json();

  // 4. Stamp the registration payload with an existing session signing key, then
  //    complete the signed retry. The backend retries the same Grid request with
  //    Grid-Wallet-Signature and Request-Id.
  const gridWalletSignature = await buildGridWalletSignature(
    existingSessionPrivateKeyBytes,
    payloadToSign,
  );
  const registerCompleteRes = await fetch("/my-backend/passkey/register/complete", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ requestId: registrationRequestId, gridWalletSignature }),
  });
  const { credentialId } = await registerCompleteRes.json();

  // 5. Generate the client key pair and ask Grid for an authentication challenge
  //    sealed to its public key.
  const { keyPair, publicKeyHex } = await generateClientKeyPair();
  const challengeRes = await fetch("/my-backend/passkey/challenge", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ credentialId, clientPublicKey: publicKeyHex }),
  });
  const { challenge: gridChallenge, requestId: authRequestId } = await challengeRes.json();

  // 6. Run the WebAuthn assertion against the Grid-issued challenge.
  const assertion = (await navigator.credentials.get({
    publicKey: {
      challenge: base64urlToBytes(gridChallenge),
      rpId,
      userVerification: "required",
      allowCredentials: [{ type: "public-key", id: base64urlToBytes(credentialId) }],
    },
  })) as PublicKeyCredential;
  const asr = assertion.response as AuthenticatorAssertionResponse;

  // 7. Send the assertion to your backend; it relays to POST /verify with Request-Id.
  const verifyRes = await fetch("/my-backend/passkey/verify", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      credentialId,
      requestId: authRequestId,
      assertion: {
        credentialId: bytesToBase64url(new Uint8Array(assertion.rawId)),
        clientDataJson: bytesToBase64url(new Uint8Array(asr.clientDataJSON)),
        authenticatorData: bytesToBase64url(new Uint8Array(asr.authenticatorData)),
        signature: bytesToBase64url(new Uint8Array(asr.signature)),
        userHandle: asr.userHandle
          ? bytesToBase64url(new Uint8Array(asr.userHandle))
          : null,
      },
    }),
  });
  const { encryptedSessionSigningKey, expiresAt } = await verifyRes.json();
  // Decrypt and cache the session signing key — see client-keys.mdx.
  ```

  ```kotlin Android (Kotlin) theme={null}
  // Uses Jetpack Credential Manager.
  // implementation("androidx.credentials:credentials:1.3.0")
  // implementation("androidx.credentials:credentials-play-services-auth:1.3.0")
  import androidx.credentials.CreatePublicKeyCredentialRequest
  import androidx.credentials.CredentialManager
  import androidx.credentials.GetCredentialRequest
  import androidx.credentials.GetPublicKeyCredentialOption
  import androidx.credentials.PublicKeyCredential

  suspend fun registerPasskey(context: Context, api: MyBackendApi): EmbeddedWalletSession {
      // 1. Backend → WebAuthn registration options JSON (challenge, rp, user, ...).
      val createOptionsJson = api.passkeyRegisterStart()

      val cm = CredentialManager.create(context)

      // 2. Authenticator creates the passkey.
      val createResp = cm.createCredential(
          context = context,
          request = CreatePublicKeyCredentialRequest(createOptionsJson),
      ) as androidx.credentials.CreatePublicKeyCredentialResponse
      val attestationJson = createResp.registrationResponseJson

      // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge.
      val registerChallenge = api.passkeyRegister(attestationJson, nickname = "This device")

      // 4. Client stamps the registration payload with an existing session key.
      val gridWalletSignature = buildGridWalletSignature(
          existingSessionPrivateKeyBytes,
          registerChallenge.payloadToSign,
      )
      val registerResp = api.passkeyRegisterComplete(
          requestId = registerChallenge.requestId,
          gridWalletSignature = gridWalletSignature,
      )

      // 5. Generate client key pair, then ask Grid for an authentication challenge
      //    sealed to that public key via POST /auth/credentials/{id}/challenge.
      val clientKeys = generateClientKeyPair(alias = "embedded-wallet-${registerResp.credentialId}")
      val challengeResp = api.passkeyChallenge(
          credentialId = registerResp.credentialId,
          clientPublicKeyHex = clientKeys.publicKeyHex,
      )

      // 6. Run WebAuthn assertion against Grid-issued challenge.
      val getOptionsJson = buildWebAuthnGetOptionsJson(
          challenge = challengeResp.challenge,
          rpId = registerResp.rpId,
          credentialId = registerResp.credentialId,
      )
      val getResp = cm.getCredential(
          context = context,
          request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(getOptionsJson))),
      ).credential as PublicKeyCredential
      val assertionJson = getResp.authenticationResponseJson

      // 7. Backend -> POST /verify with Request-Id. Returns encryptedSessionSigningKey.
      return api.passkeyVerify(
          credentialId = registerResp.credentialId,
          requestId = challengeResp.requestId,
          assertionJson = assertionJson,
      )
  }
  ```

  ```swift iOS (Swift) theme={null}
  import AuthenticationServices

  final class PasskeyCoordinator: NSObject, ASAuthorizationControllerDelegate {
      private let api: MyBackendApi
      private var continuation: CheckedContinuation<EmbeddedWalletSession, Error>?
      private var clientKeys: ClientKeyPair?
      private var pendingCredentialId: String?
      private var pendingRequestId: String?

      init(api: MyBackendApi) { self.api = api }

      func registerPasskey() async throws -> EmbeddedWalletSession {
          // 1. Backend → WebAuthn registration options.
          let options = try await api.passkeyRegisterStart()

          // 2. Authenticator creates the passkey.
          let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(
              relyingPartyIdentifier: options.rpId,
          )
          let request = provider.createCredentialRegistrationRequest(
              challenge: options.challenge,
              name: options.user.name,
              userID: options.user.id,
          )
          let attestation = try await withCheckedThrowingContinuation {
              (cont: CheckedContinuation<
                  ASAuthorizationPlatformPublicKeyCredentialRegistration, Error
              >) in
              let controller = ASAuthorizationController(authorizationRequests: [request])
              controller.delegate = AttestationDelegate(cont: cont)
              controller.performRequests()
          }

          // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge.
          let registerChallenge = try await api.passkeyRegister(
              credentialId: attestation.credentialID.base64URLEncoded,
              clientDataJson: attestation.rawClientDataJSON.base64URLEncoded,
              attestationObject: attestation.rawAttestationObject!.base64URLEncoded,
              nickname: "This device",
          )

          // 4. Client stamps the registration payload with an existing session key.
          let gridWalletSignature = try buildGridWalletSignature(
              sessionPrivateScalar: existingSessionPrivateScalar,
              payloadToSign: registerChallenge.payloadToSign,
          )
          let registered = try await api.passkeyRegisterComplete(
              requestId: registerChallenge.requestId,
              gridWalletSignature: gridWalletSignature,
          )

          // 5. Generate client key pair, ask Grid for an authentication challenge
          //    sealed to that public key via POST /auth/credentials/{id}/challenge.
          let keys = generateClientKeyPair()
          let challengeResp = try await api.passkeyChallenge(
              credentialId: registered.credentialId,
              clientPublicKeyHex: keys.publicKeyHex,
          )

          // 6. Run WebAuthn assertion against Grid-issued challenge.
          let assertRequest = provider.createCredentialAssertionRequest(
              challenge: challengeResp.challenge,
          )
          assertRequest.allowedCredentials = [
              ASAuthorizationPlatformPublicKeyCredentialDescriptor(
                  credentialID: Data(base64URLEncoded: registered.credentialId)!,
              ),
          ]
          let assertion = try await withCheckedThrowingContinuation {
              (cont: CheckedContinuation<
                  ASAuthorizationPlatformPublicKeyCredentialAssertion, Error
              >) in
              let controller = ASAuthorizationController(authorizationRequests: [assertRequest])
              controller.delegate = AssertionDelegate(cont: cont)
              controller.performRequests()
          }

          // 7. Backend -> POST /verify with Request-Id. Returns encryptedSessionSigningKey.
          return try await api.passkeyVerify(
              credentialId: registered.credentialId,
              requestId: challengeResp.requestId,
              clientDataJson: assertion.rawClientDataJSON.base64URLEncoded,
              authenticatorData: assertion.rawAuthenticatorData.base64URLEncoded,
              signature: assertion.signature.base64URLEncoded,
              userHandle: assertion.userID?.base64URLEncoded,
          )
      }
  }
  ```
</CodeGroup>

#### WebAuthn → Grid parameter map

These are the fields you need to pass through on each hop.

**Registration (`/auth/credentials`):**

| Browser (`credential`)       | Your backend payload | Grid request body                                  |
| ---------------------------- | -------------------- | -------------------------------------------------- |
| `credential.rawId`           | `credentialId`       | `attestation.credentialId`                         |
| `response.clientDataJSON`    | `clientDataJson`     | `attestation.clientDataJson`                       |
| `response.attestationObject` | `attestationObject`  | `attestation.attestationObject`                    |
| `response.getTransports()`   | `transports`         | `attestation.transports`                           |
| *(backend session state)*    | `challenge`          | `challenge` *(top-level, not under `attestation`)* |
| *(backend-chosen)*           | `nickname`           | `nickname`                                         |
| *(Grid account id)*          | —                    | `accountId`                                        |

**Authentication challenge (`/auth/credentials/{id}/challenge`):**

| Source               | Your backend payload | Grid request body |
| -------------------- | -------------------- | ----------------- |
| *(client-generated)* | `clientPublicKey`    | `clientPublicKey` |

**Assertion (`/auth/credentials/{id}/verify`):**

| Browser (`credential`)         | Your backend payload | Grid request body                   |
| ------------------------------ | -------------------- | ----------------------------------- |
| `credential.rawId`             | `credentialId`       | `assertion.credentialId`            |
| `response.clientDataJSON`      | `clientDataJson`     | `assertion.clientDataJson`          |
| `response.authenticatorData`   | `authenticatorData`  | `assertion.authenticatorData`       |
| `response.signature`           | `signature`          | `assertion.signature`               |
| `response.userHandle`          | `userHandle`         | `assertion.userHandle` *(optional)* |
| *(from `/challenge` response)* | `requestId`          | `Request-Id` header                 |

<Note>
  The WebAuthn spec names the field `clientDataJSON`. Grid spells it `clientDataJson` (lowercase `json`) for consistency with the rest of the API. The **bytes are identical** — only the field name changes.
</Note>

### Passkey reauthentication

When a session expires the client re-verifies without recreating the credential. Reauthentication uses the same `/challenge` → `/verify` shape as the first authentication: generate a fresh client key pair, call `POST /auth/credentials/{id}/challenge` with the new `clientPublicKey`, run `navigator.credentials.get()` against the returned challenge, then call `/verify` with the assertion and the matching `Request-Id` header.

```mermaid theme={null}
sequenceDiagram
  participant C as Client
  participant IB as Integrator backend
  participant G as Grid
  participant A as Authenticator

  C->>IB: session expired, re-authenticate
  C->>C: generateClientKeyPair()
  C->>IB: POST /my-backend/passkey/challenge (clientPublicKey)
  IB->>G: POST /auth/credentials/{id}/challenge<br/>{ clientPublicKey }
  G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt }
  IB-->>C: { challenge, requestId }
  C->>A: navigator.credentials.get({ challenge })
  A-->>C: assertion
  C->>IB: POST /my-backend/passkey/reauth (assertion, requestId)
  IB->>G: POST /auth/credentials/{id}/verify<br/>Request-Id: requestId<br/>{ type: PASSKEY, assertion }
  G-->>IB: 200 AuthSession { encryptedSessionSigningKey, expiresAt }
  IB-->>C: { encryptedSessionSigningKey, expiresAt }
```

## OAuth (OIDC)

Use an OAuth credential when your platform already authenticates the user with an OpenID Connect identity provider (Google, Apple, your own IdP) and you want Grid to trust that same identity. Adding OAuth to an initialized Global Account requires an active session from an existing credential and uses the same signed-retry pattern as passkey registration.

### OAuth registration

```mermaid theme={null}
sequenceDiagram
  participant C as Client
  participant IB as Integrator backend
  participant G as Grid
  participant OP as OIDC provider

  C->>OP: Sign-in flow
  OP-->>C: id_token (OIDC)
  C->>IB: POST /my-backend/oauth/register { oidcToken }
  IB->>G: POST /auth/credentials { type: OAUTH, oidcToken, accountId }
  G-->>IB: 202 { payloadToSign, requestId, expiresAt }
  IB-->>C: { payloadToSign, requestId }
  C->>C: stamp(payloadToSign, existingSessionPrivateKey)
  C->>IB: { stamp, requestId }
  IB->>G: Same POST<br/>Grid-Wallet-Signature: stamp<br/>Request-Id: requestId
  G->>OP: fetch .well-known/openid-configuration + jwks
  G-->>IB: 201 AuthMethod
  IB-->>C: { credentialId }
  C->>C: generateClientKeyPair()
  C->>OP: get fresh id_token (iat within 60s)
  OP-->>C: id_token
  C->>IB: POST /my-backend/oauth/verify { oidcToken, clientPublicKey }
  IB->>G: POST /auth/credentials/{id}/verify { type: OAUTH, oidcToken, clientPublicKey }
  G-->>IB: 200 AuthSession
  IB-->>C: { encryptedSessionSigningKey, expiresAt }
```

Grid validates the OIDC token signature against the issuer's JWKS on every call and requires `iat` to be no more than **60 seconds** older than the request. Use a fresh token for each `verify` call; cached tokens will fail. The token identity (`iss`, `aud`, and `sub`) must match the OAuth credential being verified.

<Note>
  In sandbox, OAuth still uses JWT-shaped OIDC tokens. The sandbox skips real IdP signature verification, but it validates the same identity and freshness claims. For `verify`, include `nonce` equal to `sha256(clientPublicKey)`. See [Sandbox testing](/global-accounts/platform-tools/sandbox-testing#oauth-oidc-token).
</Note>

```bash theme={null}
curl -X POST "$GRID_BASE_URL/auth/credentials" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "OAUTH",
    "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002",
    "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..."
  }'
```

**Response on the signed retry:** `201 AuthMethod` with `nickname` populated from the OIDC token's `email` claim.

### OAuth verify / reauthentication

`POST /auth/credentials/{id}/verify` is also the reauthentication path — call it with a fresh OIDC token whenever the session expires.

```bash theme={null}
curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "OAUTH",
    "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...",
    "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2"
  }'
```

## Email OTP

The lowest-friction credential type — works on any device with email access and requires no biometric hardware, identity provider, or client-side setup beyond an input field for the code. Global Accounts are initialized with an `EMAIL_OTP` credential; call `POST /auth/credentials` for `EMAIL_OTP` only if the credential was removed and you need to add it back.

### Default Email OTP credential

Grid creates the first `EMAIL_OTP` credential when the Global Account is provisioned. The credential uses the customer email on file for the internal account. To authenticate with it, send an OTP challenge and then verify the code.

```mermaid theme={null}
sequenceDiagram
  participant C as Client
  participant IB as Integrator backend
  participant G as Grid
  participant E as Email

  C->>IB: POST /my-backend/otp/challenge { credentialId }
  IB->>G: POST /auth/credentials/{id}/challenge
  G->>E: deliver OTP email (to customer email on file)
  G-->>IB: 200 AuthMethod
  IB-->>C: ok
  E-->>C: OTP code
  C->>C: generateClientKeyPair()
  C->>IB: POST /my-backend/otp/verify { otp, clientPublicKey }
  IB->>G: POST /auth/credentials/{id}/verify { type: EMAIL_OTP, otp, clientPublicKey }
  G-->>IB: 200 AuthSession
  IB-->>C: { encryptedSessionSigningKey, expiresAt }
```

```bash theme={null}
curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
```

**Response (200):**

```json theme={null}
{
  "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004",
  "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002",
  "type": "EMAIL_OTP",
  "nickname": "jane@example.com",
  "createdAt": "2026-04-19T12:00:00Z",
  "updatedAt": "2026-04-19T12:00:00Z"
}
```

Then verify with the OTP value:

```bash theme={null}
curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "EMAIL_OTP",
    "otp": "123456",
    "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2"
  }'
```

<Note>
  **In sandbox, the OTP is always `000000`** regardless of what's emailed. Pass `"otp": "000000"` to skip the email round-trip when scripting tests. The `encryptedSessionSigningKey` returned in sandbox is a stub — see <a href="client-keys#3-decrypt-the-session-signing-key">Client keys</a>.
</Note>

### Resending an OTP

If the code expires or the email didn't arrive, re-issue the challenge with `POST /auth/credentials/{id}/challenge`. This sends a fresh OTP email and leaves the `AuthMethod` otherwise untouched.

```bash theme={null}
curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
```

<Warning>
  Challenge re-issues are rate-limited. On `429`, back off for the duration returned in the `Retry-After` header before retrying.
</Warning>

### Email OTP reauthentication

Same pattern as the first activation: call `/challenge` to send a new OTP, then `/verify` with the new code and a fresh `clientPublicKey`.

### Changing the email OTP address

The `EMAIL_OTP` address comes from the customer email on file. To change it, update the customer with `PATCH /customers/{customerId}`. If the customer has tied Embedded Wallet `EMAIL_OTP` credentials, Grid returns a signed-retry challenge; stamp the returned `payloadToSign` with an active session signing key, then retry the same customer update with `Grid-Wallet-Signature` and `Request-Id`. Grid syncs the customer email and tied `EMAIL_OTP` credential email together.

## Managing credentials

Every Global Account starts with a single `EMAIL_OTP` credential — the one used in the <a href="overview#quickstart">quickstart</a>. In production, encourage customers to register a backup credential, such as a passkey or OAuth credential, so the account is recoverable if their primary device is lost. Adding, revoking, and rotating credentials after the first all go through the same **two-step signed-retry** pattern.

### List credentials

```bash theme={null}
curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
```

**Response (200):**

```json theme={null}
{
  "data": [
    {
      "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001",
      "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002",
      "type": "PASSKEY",
      "credentialId": "KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew",
      "nickname": "iPhone Face-ID",
      "createdAt": "2026-04-08T15:30:01Z",
      "updatedAt": "2026-04-08T15:30:01Z"
    },
    {
      "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004",
      "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002",
      "type": "EMAIL_OTP",
      "nickname": "jane@example.com",
      "createdAt": "2026-04-09T10:15:00Z",
      "updatedAt": "2026-04-09T10:15:00Z"
    }
  ]
}
```

The response is not paginated — each account holds a small, bounded number of credentials.

### The signed-retry pattern

Adding an additional credential, revoking a credential, refreshing or revoking a session, exporting a wallet, updating wallet privacy, and updating a customer email tied to `EMAIL_OTP` all share the same shape:

```mermaid theme={null}
sequenceDiagram
  participant C as Client
  participant IB as Integrator backend
  participant G as Grid

  IB->>G: Request (no headers)
  G-->>IB: 202 { payloadToSign, requestId, expiresAt }
  IB-->>C: { payloadToSign, requestId }
  C->>C: stamp(payloadToSign, sessionPrivateKey)
  C->>IB: { stamp }
  IB->>G: Same request<br/>Grid-Wallet-Signature: stamp<br/>Request-Id: requestId
  G-->>IB: 2xx (terminal success)
  IB-->>C: done
```

Key rules:

* Always stamp the `payloadToSign` **byte-for-byte as Grid returned it**. Do not re-parse, re-serialize, or modify whitespace.
* Sign with the **session private key** held on the client — never ship it back to your backend.
* The retry must reach Grid before `expiresAt` (typically 5 minutes from issue).
* The `requestId` is returned as `Request:<uuid>` and is single-use; reusing one yields `401`.

### Add an additional credential

Requires an active session on an *existing* credential on the same account. The first call uses the normal credential-create body; Grid detects the pre-existing credential and responds `202` instead of `201`. `OAUTH` and `PASSKEY` are the typical additional credential types. `EMAIL_OTP` can be added back only after the existing email OTP credential has been removed, because each account supports one.

<Steps>
  <Step title="First call — receive the challenge">
    ```bash theme={null}
    curl -X POST "$GRID_BASE_URL/auth/credentials" \
      -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
      -H "Content-Type: application/json" \
      -d '{
        "type": "OAUTH",
        "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002",
        "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..."
      }'
    ```

    **Response (202):**

    ```json theme={null}
    {
      "type": "OAUTH",
      "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"oauthProviders\":[{\"oidcToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...\",\"providerName\":\"Google\"}],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_CREATE_OAUTH_PROVIDERS\"}",
      "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21",
      "expiresAt": "2026-04-08T15:35:00Z"
    }
    ```
  </Step>

  <Step title="Client stamps the payload">
    Send `payloadToSign` to the client. The client builds a Turnkey API-key stamp with the session signing key from the existing credential's active session — see <a href="client-keys#4-sign-a-payloadtosign">signing payloads</a>.
  </Step>

  <Step title="Signed retry — credential is created">
    Re-run the same request with the stamp and request id in headers:

    ```bash theme={null}
    curl -X POST "$GRID_BASE_URL/auth/credentials" \
      -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
      -H "Content-Type: application/json" \
      -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \
      -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \
      -d '{
        "type": "OAUTH",
        "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002",
        "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..."
      }'
    ```

    **Response (201):** a plain `AuthMethod`.
  </Step>

  <Step title="Activate the new credential">
    Activate the new credential the same way you would activate the first credential of that type — `EMAIL_OTP` and `OAUTH` go straight to `POST /auth/credentials/{id}/verify` with a fresh `clientPublicKey`; `PASSKEY` first calls `POST /auth/credentials/{id}/challenge` with the `clientPublicKey` to get a Grid-issued WebAuthn challenge, then `POST /auth/credentials/{id}/verify` with the assertion and the `Request-Id` header.
  </Step>
</Steps>

<Note>
  Only one `EMAIL_OTP` credential is allowed per internal account. Multiple distinct `PASSKEY` credentials are allowed; registering the same WebAuthn credentialId twice returns `400 PASSKEY_CREDENTIAL_ALREADY_EXISTS`.
</Note>

### Revoke a credential

A credential is revoked by signing with a session from **a different credential on the same account**. This prevents a compromised credential from revoking itself to lock the legitimate owner out. An account must keep at least one credential — if only one exists, the revoke call returns `400`.

<Steps>
  <Step title="First call — receive the challenge">
    ```bash theme={null}
    curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \
      -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
    ```

    **Response (202):**

    ```json theme={null}
    {
      "type": "PASSKEY",
      "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"authenticatorIds\":[\"authenticator_2m9F...\"],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_DELETE_AUTHENTICATORS\"}",
      "requestId": "Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45",
      "expiresAt": "2026-04-08T15:35:00Z"
    }
    ```
  </Step>

  <Step title="Client stamps with a different credential's session">
    The client stamps `payloadToSign` with the session signing key of an active session on any *other* credential (not the one being revoked).
  </Step>

  <Step title="Signed retry — credential is revoked">
    ```bash theme={null}
    curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \
      -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
      -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \
      -H "Request-Id: Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45"
    ```

    **Response:** `204 No Content`. All active sessions issued by the revoked credential are also revoked.
  </Step>
</Steps>
