Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
693 changes: 686 additions & 7 deletions mintlify/openapi.yaml

Large diffs are not rendered by default.

116 changes: 111 additions & 5 deletions mintlify/snippets/global-accounts/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -582,13 +582,15 @@ curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000

The TEK public key becomes the session API key. Unlike `OAUTH` and `PASSKEY` flows, `EMAIL_OTP` does **not** return `encryptedSessionSigningKey` — the client already holds the session signing key (the TEK private key it generated).

`verify` shares its terminal status code with the still-processing response: a `200` here is only a session if the body isn't `{ "status": "PROCESSING" }`. See <a href="#handling-a-still-processing-response">handling a still-processing response</a> — if you get `PROCESSING`, re-send the identical signed retry (same `encryptedOtpBundle`, `Grid-Wallet-Signature`, and `Request-Id`) until you get the session shown above.

<Note>
**In sandbox, the OTP code is always `000000`** — encrypt that value in the bundle. The sandbox runs real HPKE end-to-end; the only shortcut is skipping email delivery. See <a href="client-keys#encrypt-the-otp-code-email_otp-only">Client keys</a> for the encryption flow.
</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.
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. `challenge` can also return a <a href="#handling-a-still-processing-response">still-processing `200`</a> while the resend settles at the wallet provider — re-request the challenge until you get the `AuthMethod` back rather than a fresh OTP being sent twice.

```bash
curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \
Expand All @@ -605,7 +607,7 @@ Same pattern as the first activation: call `/challenge` to send a new OTP and re

### 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.
The `EMAIL_OTP` address comes from the customer email on file, so changing it re-keys the credential the customer logs in with. Use the contact-change endpoints — see <a href="#changing-the-contact-on-file">changing the contact on file</a>. `email` and `phoneNumber` on `PATCH /customers/{customerId}` are deprecated in favor of them; they still work during migration.

## Managing credentials

Expand Down Expand Up @@ -648,7 +650,7 @@ The response is not paginated — each account holds a small, bounded number of

### 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:
Adding an additional credential, revoking a credential, refreshing or revoking a session, exporting a wallet, and updating wallet privacy all share the same shape. Contact changes use the same stamp over the same kind of `payloadToSign`, but keep the challenge on a resource instead of in one response — see <a href="#changing-the-contact-on-file">changing the contact on file</a>.

```mermaid
sequenceDiagram
Expand All @@ -662,6 +664,10 @@ sequenceDiagram
C->>C: stamp(payloadToSign, sessionPrivateKey)
C->>IB: { stamp }
IB->>G: Same request<br/>Grid-Wallet-Signature: stamp<br/>Request-Id: requestId
alt still settling at the wallet provider
G-->>IB: 200 { status: "PROCESSING" }
IB->>G: Re-send the identical signed request
end
G-->>IB: 2xx (terminal success)
IB-->>C: done
```
Expand All @@ -673,6 +679,41 @@ Key rules:
- 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`.

### Handling a still-processing response

The signed retry can come back `200` with a `WalletOperationProcessing` body instead of the terminal success shown above:

```json
{
"status": "PROCESSING",
"message": "This login is still being processed. Retry the same request in a moment."
}
```

This means Grid's wallet provider accepted the operation but hasn't settled it yet — the request is not lost, and nothing failed. **Design for this as the normal path, not a rare edge case**: handle it the same way you handle the terminal success and error responses below, not as a bolted-on afterthought.

Every signed-retry endpoint can return it on the retry step:

| Endpoint | Action |
|---|---|
| `POST /auth/credentials` | Add a credential |
| `POST /auth/credentials/{id}/challenge` | Re-send an OTP / re-issue a challenge |
| `POST /auth/credentials/{id}/verify` | Log in / verify a credential |
| `POST /auth/sessions/{id}/refresh` | Refresh a session |
| `DELETE /auth/credentials/{id}` | Revoke a credential |
| `DELETE /auth/sessions/{id}` | Revoke a session |
| `POST /customers/{customerId}/contact-changes/{changeId}/submit` | Change the email or phone on file |

To handle it:

1. **Re-send the exact same signed request** — identical body, `Grid-Wallet-Signature`, and `Request-Id` (for a contact-change submit, there is no body or `Request-Id`: just the same stamp on the same `changeId`). Don't regenerate the stamp or start a fresh request; Grid correlates the retry with the same underlying operation and returns its real outcome once the provider settles, rather than starting a second one.
2. **Show a pending state in your UI** while you retry — "removing…" for a credential revoke, "signing in…" for a login — instead of treating `PROCESSING` as an error or leaving the caller blocked with no feedback.
3. **Keep retrying with backoff** until you get a terminal response — the success code above, or an error. Grid also reconciles the operation to its terminal state on its own, so even a client that stops retrying and checks back later (for example via `GET /auth/credentials` or `GET /auth/sessions`) will see the settled result.

<Note>
`verify`, `challenge`, and the contact-change `submit` already use `200` for their terminal success body (`AuthSession`, the challenge response, or the applied `ContactChange`). For those three, `200` alone doesn't tell you which case you're in — check the response body's `status` field for `"PROCESSING"` to distinguish it from a settled success. The other four endpoints (`add credential`, `session refresh`, `revoke credential`, `revoke session`) use `201` or `204` on success, so a `200` by itself already means still-processing.
</Note>

### 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.
Expand Down Expand Up @@ -720,7 +761,7 @@ Requires an active session on an *existing* credential on the same account. The
}'
```

**Response (201):** a plain `AuthMethod`.
**Response (201):** a plain `AuthMethod`. If the underlying wallet-provider activity is still settling, this returns `200 { status: "PROCESSING" }` instead — see <a href="#handling-a-still-processing-response">handling a still-processing response</a>; re-send the identical signed request until you get the `201`.
</Step>
<Step title="Activate the new credential">
Activate the new credential the same way you would activate the first credential of that type — `OAUTH` goes straight to `POST /auth/credentials/{id}/verify` with a fresh `clientPublicKey`; `EMAIL_OTP` uses the `otpEncryptionTargetBundle` from the signed-retry registration response when present, or first calls `POST /auth/credentials/{id}/challenge` if the bundle is absent; `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.
Expand Down Expand Up @@ -764,6 +805,71 @@ A credential is revoked by signing with a session from **a different credential
-H "Request-Id: Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45"
```

**Response:** `204 No Content`. All active sessions issued by the revoked credential are also revoked.
**Response:** `204 No Content`. All active sessions issued by the revoked credential are also revoked. If the revocation is still settling at the wallet provider, this returns `200 { status: "PROCESSING" }` instead — <a href="#handling-a-still-processing-response">re-send the identical `DELETE`</a> until you get the `204`, and show the credential as "removing…" in your UI in the meantime rather than assuming success or failure.
</Step>
</Steps>

### Changing the contact on file

`POST /customers/{customerId}/contact-changes` is the one endpoint for changing a customer's email or phone number. (`email` and `phoneNumber` on `PATCH /customers/{customerId}` are deprecated in favor of it, and still work during migration.)

It's a single endpoint because whether a signature is needed isn't something your backend should have to work out. The email behind `EMAIL_OTP` and the phone number behind `SMS_OTP` are what the customer logs in with, so changing either re-keys a login credential and needs the customer's own signature. A contact that isn't backing a credential is just a field. Grid decides which case applies and tells you in the response `status`:

| Code | Arrival `status` | Means | What to do |
|---|---|---|---|
| `202` | `AWAITING_SIGNATURE` | A tied `EMAIL_OTP` / `SMS_OTP` credential exists, so the change re-keys a login credential. Carries `payloadToSign` and `expiresAt`. | Stamp and submit — the steps below. |
| `201` | `APPLIED` | No tied credential of that type, so there was nothing to re-key. Grid applied it on create; no `payloadToSign`, no `expiresAt`. | Nothing. It's done. |
| `201` | `FAILED` | Same path, but applying it didn't work. `failureReason` carries a stable code; the contact is unchanged. | Branch on the code, then create a new change. Don't retry this one — it's terminal. |

**Branch on `status`, not on what you think the customer has.** A wallet that gained or lost an OTP credential since you last looked flips which one you get, and the whole point of one endpoint is that you don't have to track that.

The status code answers a different question — whether you still owe Grid anything. `202` means accepted but waiting on the customer's signature, the same convention as every other Embedded Wallet call that needs one first. `201` means created and settled, so nothing further is required of you; "settled" includes a recorded `FAILED` attempt, so a `201` means the change was **recorded**, not that it worked. A `4xx` is different again: the request was refused before anything was attempted, so no change exists at all — which is why a duplicate value or an in-flight change of the same type comes back `409` rather than `201 FAILED`.

The rest of this section covers the `AWAITING_SIGNATURE` path.

<Steps>
<Step title="Create the change">
```bash
curl -X POST "$GRID_BASE_URL/customers/Customer:019542f5-b3e7-1d02-0000-000000000001/contact-changes" \
-u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{ "type": "EMAIL", "value": "jane.smith@example.com" }'
```

**Response (202):**

```json
{
"id": "ContactChange:019542f5-b3e7-1d02-0000-000000000020",
"customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001",
"type": "EMAIL",
"value": "jane.smith@example.com",
"status": "AWAITING_SIGNATURE",
"payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"userEmail\":\"jane.smith@example.com\",\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_UPDATE_USER_EMAIL\"}",
"expiresAt": "2026-04-08T15:35:00Z",
"createdAt": "2026-04-08T15:30:00Z",
"updatedAt": "2026-04-08T15:30:00Z"
}
```

One active change per contact type. A second `POST` while this one is still active returns `409 CONTACT_CHANGE_PENDING` with `details.contactChangeId` naming it, rather than a competing change.
</Step>
<Step title="Client stamps the payload">
The client stamps `payloadToSign` with the session signing key of any verified credential on the customer's wallet — the same stamp as every other signed operation. If your app loses the create response, re-read `payloadToSign` from `GET /customers/{customerId}/contact-changes/{changeId}` instead of starting a new change.
</Step>
<Step title="Submit the signature">
```bash
curl -X POST "$GRID_BASE_URL/customers/Customer:019542f5-b3e7-1d02-0000-000000000001/contact-changes/ContactChange:019542f5-b3e7-1d02-0000-000000000020/submit" \
-u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
-H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9"
```

**Response (200):** the change with `"status": "APPLIED"`. There is no request body and no `Request-Id` — `changeId` in the path is the correlation. If the wallet provider hasn't settled yet you get `200 { status: "PROCESSING" }` instead; <a href="#handling-a-still-processing-response">re-send the identical request</a> until the change comes back `APPLIED`, and show "updating…" in the meantime. Re-sending after it applies returns the same `APPLIED` change, so a retry is never destructive.
</Step>
</Steps>

Grid updates the customer contact field and every tied matching OTP credential as one operation. If any tied credential can't be updated, the contact field is left alone and the change ends `FAILED` with a `failureReason` — nothing lands half-applied.

Don't run a contact change and an OTP credential add of the same type at once — they rewrite the same thing. If a credential add has already been submitted, the contact change is refused with `409 AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` until it settles; wait and retry. In the other order, an applied contact change invalidates an outstanding credential-add challenge, because the payload the client stamped names the old contact — that add fails and has to be re-issued against the new value. Sequence the two rather than overlapping them.

To abandon a change the customer backed out of, `DELETE /customers/{customerId}/contact-changes/{changeId}` while it is still `AWAITING_SIGNATURE`. After the signature is submitted there is nothing to cancel: the change resolves to `APPLIED` or `FAILED` on its own. Unsubmitted changes lapse to `EXPIRED` at `expiresAt`, so cancelling is only for deliberately backing out. `GET /customers/{customerId}/contact-changes` lists them newest first for a customer's change history.
4 changes: 2 additions & 2 deletions mintlify/snippets/global-accounts/managing-sessions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ The list endpoint returns all **active** sessions; expired sessions are not incl

## Refresh a session

Session refresh creates a new session signing key from an existing active session. Use this when the customer is still present and the current session is close to expiration. If the session has already expired, reauthenticate with the original credential instead.
Session refresh creates a new session signing key from an existing active session. Use this when the customer is still present and the current session is close to expiration. If the session has already expired, reauthenticate with the original credential instead. Refresh uses the same <a href="authentication#the-signed-retry-pattern">signed-retry pattern</a> as credential management, including the possibility of a <a href="authentication#handling-a-still-processing-response">still-processing `200` response</a> on the signed retry — re-send the same request until you get the `201` below.

<Steps>
<Step title="First call — receive the challenge">
Expand Down Expand Up @@ -82,7 +82,7 @@ Session refresh creates a new session signing key from an existing active sessio

## Revoke a session

Session revocation uses the same <a href="authentication#the-signed-retry-pattern">signed-retry pattern</a> as credential management. Unlike credential revocation, a session **can revoke itself** — this is how self-logout works: sign with the session key you are about to invalidate.
Session revocation uses the same <a href="authentication#the-signed-retry-pattern">signed-retry pattern</a> as credential management, including a possible <a href="authentication#handling-a-still-processing-response">still-processing `200` response</a> on the signed retry. Unlike credential revocation, a session **can revoke itself** — this is how self-logout works: sign with the session key you are about to invalidate.

<Steps>
<Step title="First call — receive the challenge">
Expand Down
Loading
Loading