From eb7d3ef10964e2d8bca448645279fe6a74369adf Mon Sep 17 00:00:00 2001 From: carsonp6 Date: Fri, 21 Aug 2026 13:03:20 -0700 Subject: [PATCH 1/8] feat: add contact-changes endpoints for customer email and phone updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing the email behind EMAIL_OTP or the phone behind SMS_OTP re-keys what the customer logs in with, so it needs the customer's signature and it has to fan out to every tied OTP credential. PATCH /customers carried that as a 202 challenge inside one request/response pair, which left the challenge unrecoverable if the caller lost the response, gave the platform nothing to read or cancel while it was pending, and mixed a login-security operation into a profile edit. Model it as a resource instead: - POST /customers/{customerId}/contact-changes creates a change in AWAITING_SIGNATURE holding payloadToSign and expiresAt. One active change per contact type; a second create returns 409 CONTACT_CHANGE_PENDING with details.contactChangeId naming the live one. - POST .../{changeId}/submit carries the stamp in Grid-Wallet-Signature. No body and no Request-Id: the change holds what is being changed and changeId is the correlation. Idempotent, so it returns WalletOperationProcessing while the activity is in flight and the APPLIED change once it settles. - GET (list, newest first) and GET by id make a pending change readable — including re-reading payloadToSign after a lost create response — and give the applied/failed/expired/cancelled history somewhere to live. - DELETE cancels while still AWAITING_SIGNATURE. After submit there is nothing to cancel; the change resolves on its own. New 409 codes: CONTACT_CHANGE_PENDING, CONTACT_CHANGE_NOT_AWAITING_SIGNATURE, plus the SMS_OTP counterparts of the existing EMAIL_OTP codes (SMS_OTP_PHONE_ALREADY_EXISTS, SMS_OTP_CREDENTIAL_SET_CHANGED) and 424's SMS_OTP_CREDENTIAL_SYNC_FAILED — PATCH /customers already described those phone cases in prose without ever defining codes for them. PATCH /customers is untouched; deprecating its contact fields is a later step. Co-Authored-By: Claude Fable 5 --- mintlify/openapi.yaml | 606 +++++++++++++++++- openapi.yaml | 606 +++++++++++++++++- .../schemas/customers/ContactChange.yaml | 86 +++ .../customers/ContactChangeCreateRequest.yaml | 20 + .../customers/ContactChangeListResponse.yaml | 24 + .../customers/ContactChangeStatus.yaml | 24 + .../ContactChangeSubmitResponse.yaml | 14 + .../schemas/customers/ContactChangeType.yaml | 19 + .../components/schemas/errors/Error409.yaml | 8 + .../components/schemas/errors/Error424.yaml | 2 + openapi/openapi.yaml | 6 + ...ustomers_{customerId}_contact-changes.yaml | 263 ++++++++ ...ustomerId}_contact-changes_{changeId}.yaml | 146 +++++ ...Id}_contact-changes_{changeId}_submit.yaml | 142 ++++ 14 files changed, 1926 insertions(+), 40 deletions(-) create mode 100644 openapi/components/schemas/customers/ContactChange.yaml create mode 100644 openapi/components/schemas/customers/ContactChangeCreateRequest.yaml create mode 100644 openapi/components/schemas/customers/ContactChangeListResponse.yaml create mode 100644 openapi/components/schemas/customers/ContactChangeStatus.yaml create mode 100644 openapi/components/schemas/customers/ContactChangeSubmitResponse.yaml create mode 100644 openapi/components/schemas/customers/ContactChangeType.yaml create mode 100644 openapi/paths/customers/customers_{customerId}_contact-changes.yaml create mode 100644 openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}.yaml create mode 100644 openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 1e90eb416..2b0209e73 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -1178,6 +1178,429 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /customers/{customerId}/contact-changes: + parameters: + - name: customerId + in: path + description: System-generated unique customer identifier + required: true + schema: + type: string + post: + summary: Start a contact change + description: | + Start changing an Embedded Wallet customer's email or phone number. + + The email behind an `EMAIL_OTP` credential and the phone number behind an `SMS_OTP` credential are what the customer logs in with, so changing either one is a login-security operation that the customer's own wallet session has to authorize. This endpoint creates the change as a durable resource and returns the `payloadToSign` for it; the change takes effect only after `POST /customers/{customerId}/contact-changes/{changeId}/submit` carries the signature. + + 1. Call this endpoint with the contact `type` and its new `value`. Grid resolves every tied Embedded Wallet OTP credential of that type, binds that set into `payloadToSign`, and returns `201` with the change in `AWAITING_SIGNATURE`. + + 2. Use the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets to build an API-key stamp over `payloadToSign`, then submit it. Grid updates the customer contact field and every tied matching OTP credential as one logical operation; if any tied credential cannot be updated, the customer contact field is left unchanged. + + A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. + + Unlike the `202` challenge on `PATCH /customers/{customerId}`, the challenge here survives the response: a client that lost the create response can re-read `payloadToSign` from the change resource instead of starting over. + operationId: createContactChange + tags: + - Customers + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChangeCreateRequest' + examples: + email: + summary: Change the customer email + value: + type: EMAIL + value: jane.smith@example.com + phone: + summary: Change the customer phone number + value: + type: PHONE + value: '+14155559876' + responses: + '201': + description: Contact change created in `AWAITING_SIGNATURE`, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChange' + examples: + email: + summary: Email change awaiting signature + value: + 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' + phone: + summary: Phone change awaiting signature + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000021 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: AWAITING_SIGNATURE + payloadToSign: '{"organizationId":"org_2m9F...","parameters":{"userId":"user_2m9F...","userPhoneNumber":"+14155559876"},"timestampMs":"1775681700000","type":"ACTIVITY_TYPE_UPDATE_USER_PHONE_NUMBER"}' + expiresAt: '2026-04-08T15:35:00Z' + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' + '400': + description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`, and when the customer has no tied Embedded Wallet OTP credential of the requested type — such a customer has no login credential to re-key, so update its contact fields with `PATCH /customers/{customerId}` instead.' + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Conflict. Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + examples: + alreadyPending: + summary: An active contact change of this type already exists + value: + status: 409 + code: CONTACT_CHANGE_PENDING + message: This customer already has an active EMAIL contact change. Submit or cancel it before starting another. + details: + contactChangeId: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + get: + summary: List contact changes + description: |- + List a customer's contact changes, newest first — the active one, if any, plus the history of applied, failed, expired, and cancelled attempts. + + Use this to show a customer's pending contact change after losing the create response, or to audit who changed what and when. To check only whether a change is in flight, filter on `status`. + operationId: listContactChanges + tags: + - Customers + security: + - BasicAuth: [] + parameters: + - name: status + in: query + description: Return only contact changes in this state. Omit to return every contact change on the customer. + required: false + schema: + $ref: '#/components/schemas/ContactChangeStatus' + - name: limit + in: query + description: Maximum number of results to return (default 20, max 100) + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: cursor + in: query + description: Cursor for pagination (returned from previous request) + required: false + schema: + type: string + responses: + '200': + description: Contact changes on this customer, newest first. Returns an empty `data` array when the customer has never had a contact change. + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChangeListResponse' + examples: + pendingAndHistory: + summary: One active change plus prior attempts + value: + data: + - id: ContactChange:019542f5-b3e7-1d02-0000-000000000021 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: AWAITING_SIGNATURE + payloadToSign: '{"organizationId":"org_2m9F...","parameters":{"userId":"user_2m9F...","userPhoneNumber":"+14155559876"},"timestampMs":"1775681700000","type":"ACTIVITY_TYPE_UPDATE_USER_PHONE_NUMBER"}' + expiresAt: '2026-04-08T15:35:00Z' + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' + - id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: APPLIED + createdAt: '2026-04-07T09:12:00Z' + updatedAt: '2026-04-07T09:13:04Z' + hasMore: false + totalCount: 2 + empty: + summary: No contact changes on this customer + value: + data: [] + hasMore: false + totalCount: 0 + '400': + description: Bad request. Returned with `INVALID_INPUT` when `status` is not a recognized contact change status or the pagination parameters are out of range. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /customers/{customerId}/contact-changes/{changeId}: + parameters: + - name: customerId + in: path + description: System-generated unique customer identifier + required: true + schema: + type: string + - name: changeId + in: path + description: The id of the contact change (the `id` field returned from `POST /customers/{customerId}/contact-changes`). + required: true + schema: + type: string + get: + summary: Get a contact change + description: |- + Read a single contact change. + + While the change is `AWAITING_SIGNATURE` this returns its `payloadToSign` and `expiresAt`, so a client that lost the create response can recover the payload to stamp rather than starting a new change. Once the change is `PROCESSING`, this is also how a client that stopped re-sending the signed submit learns the outcome — Grid reconciles the underlying activity to terminal on its own. + operationId: getContactChange + tags: + - Customers + security: + - BasicAuth: [] + responses: + '200': + description: The contact change. + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChange' + examples: + applied: + summary: Applied email change + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:31:12Z' + failed: + summary: Failed phone change + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000021 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: FAILED + failureReason: One or more tied SMS_OTP credentials could not be updated. + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:31:44Z' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer or contact change not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + delete: + summary: Cancel a contact change + description: |- + Cancel a contact change that has not been submitted yet, moving it to `CANCELLED`. + + Only an `AWAITING_SIGNATURE` change can be cancelled. Once the signature is submitted the change is in the wallet provider's hands and resolves to `APPLIED` or `FAILED` on its own — Grid does not pretend to un-sign it. An unsubmitted change also lapses to `EXPIRED` on its own at `expiresAt`, so cancelling is for abandoning a change deliberately (the customer backed out, or the platform needs to start one with a different value) rather than for cleanup. + operationId: cancelContactChange + tags: + - Customers + security: + - BasicAuth: [] + responses: + '200': + description: 'Contact change cancelled. The body is the change with `status: "CANCELLED"`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChange' + examples: + cancelled: + summary: Cancelled email change + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: CANCELLED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:32:00Z' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer or contact change not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is not `AWAITING_SIGNATURE` — a submitted change (`PROCESSING`) has to resolve on its own, and a change that is already `APPLIED`, `FAILED`, `EXPIRED`, or `CANCELLED` is terminal. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /customers/{customerId}/contact-changes/{changeId}/submit: + parameters: + - name: customerId + in: path + description: System-generated unique customer identifier + required: true + schema: + type: string + - name: changeId + in: path + description: The id of the contact change being submitted (the `id` field returned from `POST /customers/{customerId}/contact-changes`). + required: true + schema: + type: string + post: + summary: Submit a signed contact change + description: | + Apply a contact change by submitting the customer's signature over its `payloadToSign`. + + Build an API-key stamp over `payloadToSign` **byte-for-byte as Grid returned it**, using the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets, and send the full stamp as `Grid-Wallet-Signature`. The change itself carries what is being changed, so there is no request body and no `Request-Id` to echo — `changeId` in the path is the correlation the other Embedded Wallet signed-retry endpoints get from `Request-Id`. + + Grid updates the customer contact field and every tied matching OTP credential as one logical operation. If any tied credential cannot be updated, the customer contact field is left unchanged. + + This endpoint is idempotent, which is what makes the still-processing response safe to drive: re-sending the same stamp returns `WalletOperationProcessing` while the wallet-provider activity is in flight and the `APPLIED` change once it settles, without starting a second change. A client that stops re-sending loses nothing — Grid reconciles the activity on its own, and `GET /customers/{customerId}/contact-changes/{changeId}` reaches the same answer. + operationId: submitContactChange + tags: + - Customers + security: + - BasicAuth: [] + parameters: + - name: Grid-Wallet-Signature + in: header + required: true + description: Full API-key stamp built over the contact change's `payloadToSign` with the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets. + schema: + type: string + example: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9 + responses: + '200': + description: 'Either the applied contact change or a `WalletOperationProcessing` body with `status: "PROCESSING"`. `200` alone does not distinguish them — check the body: a `PROCESSING` status means the wallet-provider activity is still in flight, so re-send this same stamped request until the change comes back `APPLIED`. A change that is already `APPLIED` returns that same body on a re-send.' + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChangeSubmitResponse' + examples: + applied: + summary: Contact change applied + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:31:12Z' + processing: + summary: Still settling at the wallet provider + value: + status: PROCESSING + message: This contact change is still being processed. Retry the same request in a moment. + '401': + description: Unauthorized. Returned when `Grid-Wallet-Signature` is missing, malformed, or was not produced by a verified authentication credential on one of the customer's tied Embedded Wallets, or when the stamp does not match this contact change's `payloadToSign`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer or contact change not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is `EXPIRED`, `CANCELLED`, or `FAILED` — all terminal, so start a new contact change rather than re-signing this one. A change that is `PROCESSING` or `APPLIED` is not a conflict and returns `200`. Also returned with `EMAIL_OTP_CREDENTIAL_SET_CHANGED` or `SMS_OTP_CREDENTIAL_SET_CHANGED` when the set of tied OTP credentials bound into `payloadToSign` at create time no longer matches the account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by another credential between create and submit. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '424': + description: Failed dependency. Returned with `EMAIL_OTP_CREDENTIAL_SYNC_FAILED` or `SMS_OTP_CREDENTIAL_SYNC_FAILED` when Grid cannot update one or more tied Embedded Wallet OTP credentials. The customer contact field is not changed, and the contact change moves to `FAILED`. A failure that only surfaces after this call returned `PROCESSING` has no status code to land on and shows up as `FAILED` with a `failureReason` on the change itself. + content: + application/json: + schema: + $ref: '#/components/schemas/Error424' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /sca/factors: parameters: - name: customerId @@ -13655,6 +14078,10 @@ components: | UMA_ADDRESS_EXISTS | UMA address already exists | | EMAIL_OTP_EMAIL_ALREADY_EXISTS | Email address is already associated with an EMAIL_OTP credential | | EMAIL_OTP_CREDENTIAL_SET_CHANGED | Tied EMAIL_OTP credential set changed after the signed-retry challenge was issued | + | SMS_OTP_PHONE_ALREADY_EXISTS | Phone number is already associated with an SMS_OTP credential | + | SMS_OTP_CREDENTIAL_SET_CHANGED | Tied SMS_OTP credential set changed after the signed-retry challenge was issued | + | CONTACT_CHANGE_PENDING | The customer already has an active contact change of this type. `details.contactChangeId` names it; submit or cancel it before starting another | + | CONTACT_CHANGE_NOT_AWAITING_SIGNATURE | The contact change is not `AWAITING_SIGNATURE`, so it can no longer be signed or cancelled | | PASSKEY_ALREADY_ENROLLED | The customer already has an enrolled passkey factor; only one passkey per customer is supported. Delete the existing one before enrolling another | | SCA_SESSION_REQUIRED | The customer's Strong Customer Authentication login session is missing or expired. Re-authenticate the customer, then retry the request. Distinct from a `401`, which means the platform's own API credentials were rejected | | BENEFICIARY_TRUSTED | The external account is currently a trusted beneficiary, so it cannot be deleted. Untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete | @@ -13668,6 +14095,10 @@ components: - UMA_ADDRESS_EXISTS - EMAIL_OTP_EMAIL_ALREADY_EXISTS - EMAIL_OTP_CREDENTIAL_SET_CHANGED + - SMS_OTP_PHONE_ALREADY_EXISTS + - SMS_OTP_CREDENTIAL_SET_CHANGED + - CONTACT_CHANGE_PENDING + - CONTACT_CHANGE_NOT_AWAITING_SIGNATURE - PASSKEY_ALREADY_ENROLLED - SCA_SESSION_REQUIRED - BENEFICIARY_TRUSTED @@ -13852,12 +14283,14 @@ components: | NO_COMPATIBLE_UMA_VERSION | No compatible UMA version | | LNURLP_REQUEST_FAILED | LNURLP request failed | | EMAIL_OTP_CREDENTIAL_SYNC_FAILED | Failed to update one or more tied EMAIL_OTP credentials | + | SMS_OTP_CREDENTIAL_SYNC_FAILED | Failed to update one or more tied SMS_OTP credentials | enum: - PAYREQ_REQUEST_FAILED - COUNTERPARTY_PUBKEY_FETCH_ERROR - NO_COMPATIBLE_UMA_VERSION - LNURLP_REQUEST_FAILED - EMAIL_OTP_CREDENTIAL_SYNC_FAILED + - SMS_OTP_CREDENTIAL_SYNC_FAILED message: type: string description: Error message @@ -13928,6 +14361,159 @@ components: type: string description: The one-time verification code the customer received via email or SMS. In sandbox, the code is always `123456`. example: '123456' + ContactChangeStatus: + title: Contact Change Status + type: string + enum: + - AWAITING_SIGNATURE + - PROCESSING + - APPLIED + - FAILED + - EXPIRED + - CANCELLED + description: |- + Lifecycle state of a contact change. + | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | + Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. + example: AWAITING_SIGNATURE + ContactChangeType: + title: Contact Change Type + type: string + enum: + - EMAIL + - PHONE + description: |- + Which contact field a contact change replaces. + + - `EMAIL`: the customer `email` on file, together with the `EMAIL_OTP` credential on every tied Embedded Wallet internal account. + - `PHONE`: the customer `phoneNumber` on file, together with the `SMS_OTP` credential on every tied Embedded Wallet internal account. + + A contact change covers exactly one field. Changing both email and phone takes two separate contact changes, each signed on its own. + example: EMAIL + ContactChange: + title: Contact Change + type: object + description: |- + A pending or completed change to a customer's email or phone number. + + Changing the contact on an Embedded Wallet customer is a login-security operation, not a profile edit: the email behind `EMAIL_OTP` and the phone number behind `SMS_OTP` are the credentials the customer authenticates with. So the change is authorized by the customer's own wallet session rather than by platform credentials alone, and it lands as a durable resource the platform can read, retry, and cancel — instead of a challenge that exists only inside one request/response pair. + + Grid applies the customer contact field and every tied matching OTP credential as one logical operation. If any tied credential cannot be updated, the customer contact field is left unchanged and the change goes to `FAILED`. + required: + - id + - customerId + - type + - value + - status + - createdAt + - updatedAt + properties: + id: + type: string + description: System-generated unique identifier for the contact change. + example: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: + type: string + description: Identifier of the customer whose contact is being changed. + example: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: + $ref: '#/components/schemas/ContactChangeType' + description: Which contact field this change replaces. + value: + type: string + description: 'The new contact value: an email address when `type` is `EMAIL`, an E.164 phone number when `type` is `PHONE`. This is the value that becomes the customer contact field and the nickname of every tied matching OTP credential once the change is `APPLIED`.' + example: jane.smith@example.com + status: + $ref: '#/components/schemas/ContactChangeStatus' + description: Lifecycle state of this contact change. + payloadToSign: + type: string + description: Canonical payload for the submit authorization stamp. Build an API-key stamp over this exact value with the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets, then send the full stamp as `Grid-Wallet-Signature` on `POST /customers/{customerId}/contact-changes/{changeId}/submit`. Present only while `status` is `AWAITING_SIGNATURE` — a client that lost the create response can re-read it here rather than starting over. + example: '{"organizationId":"org_2m9F...","parameters":{"userEmail":"jane.smith@example.com","userId":"user_2m9F..."},"timestampMs":"1775681700000","type":"ACTIVITY_TYPE_UPDATE_USER_EMAIL"}' + expiresAt: + type: string + format: date-time + description: Deadline for the signed submit. A change with no submit by this time moves to `EXPIRED`. Present only while `status` is `AWAITING_SIGNATURE`. + example: '2026-04-08T15:35:00Z' + failureReason: + type: string + description: Human-readable explanation of why the change could not be applied. Present only when `status` is `FAILED`. + example: One or more tied SMS_OTP credentials could not be updated. + createdAt: + type: string + format: date-time + description: When the contact change was created. + example: '2026-04-08T15:30:00Z' + updatedAt: + type: string + format: date-time + description: When the contact change last changed status. + example: '2026-04-08T15:31:12Z' + ContactChangeListResponse: + title: Contact Change List Response + type: object + description: Paginated list of a customer's contact changes, newest first. + required: + - data + - hasMore + properties: + data: + type: array + description: Contact changes on this customer, ordered by `createdAt` descending. + items: + $ref: '#/components/schemas/ContactChange' + hasMore: + type: boolean + description: Indicates if more results are available beyond this page. + nextCursor: + type: string + description: Cursor to retrieve the next page of results. + example: eyJpZCI6IkNvbnRhY3RDaGFuZ2U6MDE5NTQyZjUifQ== + totalCount: + type: integer + description: Total number of contact changes matching the query. + example: 3 + ContactChangeCreateRequest: + title: Contact Change Create Request + type: object + description: Request body for `POST /customers/{customerId}/contact-changes`. Names the contact field to change and its new value; Grid resolves which tied Embedded Wallet OTP credentials must be updated alongside it and binds that set into the returned `payloadToSign`. + required: + - type + - value + properties: + type: + $ref: '#/components/schemas/ContactChangeType' + description: Which contact field to change. + value: + type: string + description: 'The new contact value: an email address when `type` is `EMAIL`, an E.164 phone number when `type` is `PHONE`.' + example: jane.smith@example.com + WalletOperationProcessing: + title: Wallet Operation Processing + description: '`200` response returned by an Embedded Wallet operation that the wallet provider has accepted but not yet settled — a consensus- or approval-gated activity that is still in flight. It is not an error and needs no client action beyond patience: the backend reconciles the operation to its terminal state on its own. The client MAY re-send the byte-identical request to converge sooner; the request is idempotent and returns the settled success response once the operation completes.' + type: object + required: + - status + properties: + status: + type: string + enum: + - PROCESSING + description: Always `PROCESSING`. Marks a still-in-flight operation whose terminal result is not yet available. + example: PROCESSING + message: + type: string + description: Human-readable explanation that the operation is still being processed and the same request may be retried. + example: This login is still being processed. Retry the same request in a moment. + example: + status: PROCESSING + message: This login is still being processed. Retry the same request in a moment. + ContactChangeSubmitResponse: + title: Contact Change Submit Response + description: 'Response body for `POST /customers/{customerId}/contact-changes/{changeId}/submit`. Normally a `ContactChange` — the change with `status: "APPLIED"`. When the underlying wallet-provider activity is still in flight, this is instead a `WalletOperationProcessing` body with `status: "PROCESSING"` — re-send the byte-identical signed submit until it settles; the backend also reconciles the activity to terminal on its own, so `GET /customers/{customerId}/contact-changes/{changeId}` reaches the same answer for a client that stops retrying.' + anyOf: + - $ref: '#/components/schemas/ContactChange' + - $ref: '#/components/schemas/WalletOperationProcessing' ScaFactor: type: string enum: @@ -23479,26 +24065,6 @@ components: SMS_OTP: '#/components/schemas/SmsOtpCredentialCreateRequest' OAUTH: '#/components/schemas/OauthCredentialCreateRequest' PASSKEY: '#/components/schemas/PasskeyCredentialCreateRequest' - WalletOperationProcessing: - title: Wallet Operation Processing - description: '`200` response returned by an Embedded Wallet operation that the wallet provider has accepted but not yet settled — a consensus- or approval-gated activity that is still in flight. It is not an error and needs no client action beyond patience: the backend reconciles the operation to its terminal state on its own. The client MAY re-send the byte-identical request to converge sooner; the request is idempotent and returns the settled success response once the operation completes.' - type: object - required: - - status - properties: - status: - type: string - enum: - - PROCESSING - description: Always `PROCESSING`. Marks a still-in-flight operation whose terminal result is not yet available. - example: PROCESSING - message: - type: string - description: Human-readable explanation that the operation is still being processed and the same request may be retried. - example: This login is still being processed. Retry the same request in a moment. - example: - status: PROCESSING - message: This login is still being processed. Retry the same request in a moment. AuthMethodResponse: title: Auth Method Response description: |- diff --git a/openapi.yaml b/openapi.yaml index 1e90eb416..2b0209e73 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1178,6 +1178,429 @@ paths: application/json: schema: $ref: '#/components/schemas/Error500' + /customers/{customerId}/contact-changes: + parameters: + - name: customerId + in: path + description: System-generated unique customer identifier + required: true + schema: + type: string + post: + summary: Start a contact change + description: | + Start changing an Embedded Wallet customer's email or phone number. + + The email behind an `EMAIL_OTP` credential and the phone number behind an `SMS_OTP` credential are what the customer logs in with, so changing either one is a login-security operation that the customer's own wallet session has to authorize. This endpoint creates the change as a durable resource and returns the `payloadToSign` for it; the change takes effect only after `POST /customers/{customerId}/contact-changes/{changeId}/submit` carries the signature. + + 1. Call this endpoint with the contact `type` and its new `value`. Grid resolves every tied Embedded Wallet OTP credential of that type, binds that set into `payloadToSign`, and returns `201` with the change in `AWAITING_SIGNATURE`. + + 2. Use the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets to build an API-key stamp over `payloadToSign`, then submit it. Grid updates the customer contact field and every tied matching OTP credential as one logical operation; if any tied credential cannot be updated, the customer contact field is left unchanged. + + A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. + + Unlike the `202` challenge on `PATCH /customers/{customerId}`, the challenge here survives the response: a client that lost the create response can re-read `payloadToSign` from the change resource instead of starting over. + operationId: createContactChange + tags: + - Customers + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChangeCreateRequest' + examples: + email: + summary: Change the customer email + value: + type: EMAIL + value: jane.smith@example.com + phone: + summary: Change the customer phone number + value: + type: PHONE + value: '+14155559876' + responses: + '201': + description: Contact change created in `AWAITING_SIGNATURE`, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChange' + examples: + email: + summary: Email change awaiting signature + value: + 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' + phone: + summary: Phone change awaiting signature + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000021 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: AWAITING_SIGNATURE + payloadToSign: '{"organizationId":"org_2m9F...","parameters":{"userId":"user_2m9F...","userPhoneNumber":"+14155559876"},"timestampMs":"1775681700000","type":"ACTIVITY_TYPE_UPDATE_USER_PHONE_NUMBER"}' + expiresAt: '2026-04-08T15:35:00Z' + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' + '400': + description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`, and when the customer has no tied Embedded Wallet OTP credential of the requested type — such a customer has no login credential to re-key, so update its contact fields with `PATCH /customers/{customerId}` instead.' + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Conflict. Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + examples: + alreadyPending: + summary: An active contact change of this type already exists + value: + status: 409 + code: CONTACT_CHANGE_PENDING + message: This customer already has an active EMAIL contact change. Submit or cancel it before starting another. + details: + contactChangeId: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + get: + summary: List contact changes + description: |- + List a customer's contact changes, newest first — the active one, if any, plus the history of applied, failed, expired, and cancelled attempts. + + Use this to show a customer's pending contact change after losing the create response, or to audit who changed what and when. To check only whether a change is in flight, filter on `status`. + operationId: listContactChanges + tags: + - Customers + security: + - BasicAuth: [] + parameters: + - name: status + in: query + description: Return only contact changes in this state. Omit to return every contact change on the customer. + required: false + schema: + $ref: '#/components/schemas/ContactChangeStatus' + - name: limit + in: query + description: Maximum number of results to return (default 20, max 100) + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: cursor + in: query + description: Cursor for pagination (returned from previous request) + required: false + schema: + type: string + responses: + '200': + description: Contact changes on this customer, newest first. Returns an empty `data` array when the customer has never had a contact change. + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChangeListResponse' + examples: + pendingAndHistory: + summary: One active change plus prior attempts + value: + data: + - id: ContactChange:019542f5-b3e7-1d02-0000-000000000021 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: AWAITING_SIGNATURE + payloadToSign: '{"organizationId":"org_2m9F...","parameters":{"userId":"user_2m9F...","userPhoneNumber":"+14155559876"},"timestampMs":"1775681700000","type":"ACTIVITY_TYPE_UPDATE_USER_PHONE_NUMBER"}' + expiresAt: '2026-04-08T15:35:00Z' + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' + - id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: APPLIED + createdAt: '2026-04-07T09:12:00Z' + updatedAt: '2026-04-07T09:13:04Z' + hasMore: false + totalCount: 2 + empty: + summary: No contact changes on this customer + value: + data: [] + hasMore: false + totalCount: 0 + '400': + description: Bad request. Returned with `INVALID_INPUT` when `status` is not a recognized contact change status or the pagination parameters are out of range. + content: + application/json: + schema: + $ref: '#/components/schemas/Error400' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /customers/{customerId}/contact-changes/{changeId}: + parameters: + - name: customerId + in: path + description: System-generated unique customer identifier + required: true + schema: + type: string + - name: changeId + in: path + description: The id of the contact change (the `id` field returned from `POST /customers/{customerId}/contact-changes`). + required: true + schema: + type: string + get: + summary: Get a contact change + description: |- + Read a single contact change. + + While the change is `AWAITING_SIGNATURE` this returns its `payloadToSign` and `expiresAt`, so a client that lost the create response can recover the payload to stamp rather than starting a new change. Once the change is `PROCESSING`, this is also how a client that stopped re-sending the signed submit learns the outcome — Grid reconciles the underlying activity to terminal on its own. + operationId: getContactChange + tags: + - Customers + security: + - BasicAuth: [] + responses: + '200': + description: The contact change. + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChange' + examples: + applied: + summary: Applied email change + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:31:12Z' + failed: + summary: Failed phone change + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000021 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: FAILED + failureReason: One or more tied SMS_OTP credentials could not be updated. + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:31:44Z' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer or contact change not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + delete: + summary: Cancel a contact change + description: |- + Cancel a contact change that has not been submitted yet, moving it to `CANCELLED`. + + Only an `AWAITING_SIGNATURE` change can be cancelled. Once the signature is submitted the change is in the wallet provider's hands and resolves to `APPLIED` or `FAILED` on its own — Grid does not pretend to un-sign it. An unsubmitted change also lapses to `EXPIRED` on its own at `expiresAt`, so cancelling is for abandoning a change deliberately (the customer backed out, or the platform needs to start one with a different value) rather than for cleanup. + operationId: cancelContactChange + tags: + - Customers + security: + - BasicAuth: [] + responses: + '200': + description: 'Contact change cancelled. The body is the change with `status: "CANCELLED"`.' + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChange' + examples: + cancelled: + summary: Cancelled email change + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: CANCELLED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:32:00Z' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer or contact change not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is not `AWAITING_SIGNATURE` — a submitted change (`PROCESSING`) has to resolve on its own, and a change that is already `APPLIED`, `FAILED`, `EXPIRED`, or `CANCELLED` is terminal. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' + /customers/{customerId}/contact-changes/{changeId}/submit: + parameters: + - name: customerId + in: path + description: System-generated unique customer identifier + required: true + schema: + type: string + - name: changeId + in: path + description: The id of the contact change being submitted (the `id` field returned from `POST /customers/{customerId}/contact-changes`). + required: true + schema: + type: string + post: + summary: Submit a signed contact change + description: | + Apply a contact change by submitting the customer's signature over its `payloadToSign`. + + Build an API-key stamp over `payloadToSign` **byte-for-byte as Grid returned it**, using the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets, and send the full stamp as `Grid-Wallet-Signature`. The change itself carries what is being changed, so there is no request body and no `Request-Id` to echo — `changeId` in the path is the correlation the other Embedded Wallet signed-retry endpoints get from `Request-Id`. + + Grid updates the customer contact field and every tied matching OTP credential as one logical operation. If any tied credential cannot be updated, the customer contact field is left unchanged. + + This endpoint is idempotent, which is what makes the still-processing response safe to drive: re-sending the same stamp returns `WalletOperationProcessing` while the wallet-provider activity is in flight and the `APPLIED` change once it settles, without starting a second change. A client that stops re-sending loses nothing — Grid reconciles the activity on its own, and `GET /customers/{customerId}/contact-changes/{changeId}` reaches the same answer. + operationId: submitContactChange + tags: + - Customers + security: + - BasicAuth: [] + parameters: + - name: Grid-Wallet-Signature + in: header + required: true + description: Full API-key stamp built over the contact change's `payloadToSign` with the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets. + schema: + type: string + example: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9 + responses: + '200': + description: 'Either the applied contact change or a `WalletOperationProcessing` body with `status: "PROCESSING"`. `200` alone does not distinguish them — check the body: a `PROCESSING` status means the wallet-provider activity is still in flight, so re-send this same stamped request until the change comes back `APPLIED`. A change that is already `APPLIED` returns that same body on a re-send.' + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChangeSubmitResponse' + examples: + applied: + summary: Contact change applied + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:31:12Z' + processing: + summary: Still settling at the wallet provider + value: + status: PROCESSING + message: This contact change is still being processed. Retry the same request in a moment. + '401': + description: Unauthorized. Returned when `Grid-Wallet-Signature` is missing, malformed, or was not produced by a verified authentication credential on one of the customer's tied Embedded Wallets, or when the stamp does not match this contact change's `payloadToSign`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error401' + '404': + description: Customer or contact change not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error404' + '409': + description: Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is `EXPIRED`, `CANCELLED`, or `FAILED` — all terminal, so start a new contact change rather than re-signing this one. A change that is `PROCESSING` or `APPLIED` is not a conflict and returns `200`. Also returned with `EMAIL_OTP_CREDENTIAL_SET_CHANGED` or `SMS_OTP_CREDENTIAL_SET_CHANGED` when the set of tied OTP credentials bound into `payloadToSign` at create time no longer matches the account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by another credential between create and submit. + content: + application/json: + schema: + $ref: '#/components/schemas/Error409' + '424': + description: Failed dependency. Returned with `EMAIL_OTP_CREDENTIAL_SYNC_FAILED` or `SMS_OTP_CREDENTIAL_SYNC_FAILED` when Grid cannot update one or more tied Embedded Wallet OTP credentials. The customer contact field is not changed, and the contact change moves to `FAILED`. A failure that only surfaces after this call returned `PROCESSING` has no status code to land on and shows up as `FAILED` with a `failureReason` on the change itself. + content: + application/json: + schema: + $ref: '#/components/schemas/Error424' + '500': + description: Internal service error + content: + application/json: + schema: + $ref: '#/components/schemas/Error500' /sca/factors: parameters: - name: customerId @@ -13655,6 +14078,10 @@ components: | UMA_ADDRESS_EXISTS | UMA address already exists | | EMAIL_OTP_EMAIL_ALREADY_EXISTS | Email address is already associated with an EMAIL_OTP credential | | EMAIL_OTP_CREDENTIAL_SET_CHANGED | Tied EMAIL_OTP credential set changed after the signed-retry challenge was issued | + | SMS_OTP_PHONE_ALREADY_EXISTS | Phone number is already associated with an SMS_OTP credential | + | SMS_OTP_CREDENTIAL_SET_CHANGED | Tied SMS_OTP credential set changed after the signed-retry challenge was issued | + | CONTACT_CHANGE_PENDING | The customer already has an active contact change of this type. `details.contactChangeId` names it; submit or cancel it before starting another | + | CONTACT_CHANGE_NOT_AWAITING_SIGNATURE | The contact change is not `AWAITING_SIGNATURE`, so it can no longer be signed or cancelled | | PASSKEY_ALREADY_ENROLLED | The customer already has an enrolled passkey factor; only one passkey per customer is supported. Delete the existing one before enrolling another | | SCA_SESSION_REQUIRED | The customer's Strong Customer Authentication login session is missing or expired. Re-authenticate the customer, then retry the request. Distinct from a `401`, which means the platform's own API credentials were rejected | | BENEFICIARY_TRUSTED | The external account is currently a trusted beneficiary, so it cannot be deleted. Untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete | @@ -13668,6 +14095,10 @@ components: - UMA_ADDRESS_EXISTS - EMAIL_OTP_EMAIL_ALREADY_EXISTS - EMAIL_OTP_CREDENTIAL_SET_CHANGED + - SMS_OTP_PHONE_ALREADY_EXISTS + - SMS_OTP_CREDENTIAL_SET_CHANGED + - CONTACT_CHANGE_PENDING + - CONTACT_CHANGE_NOT_AWAITING_SIGNATURE - PASSKEY_ALREADY_ENROLLED - SCA_SESSION_REQUIRED - BENEFICIARY_TRUSTED @@ -13852,12 +14283,14 @@ components: | NO_COMPATIBLE_UMA_VERSION | No compatible UMA version | | LNURLP_REQUEST_FAILED | LNURLP request failed | | EMAIL_OTP_CREDENTIAL_SYNC_FAILED | Failed to update one or more tied EMAIL_OTP credentials | + | SMS_OTP_CREDENTIAL_SYNC_FAILED | Failed to update one or more tied SMS_OTP credentials | enum: - PAYREQ_REQUEST_FAILED - COUNTERPARTY_PUBKEY_FETCH_ERROR - NO_COMPATIBLE_UMA_VERSION - LNURLP_REQUEST_FAILED - EMAIL_OTP_CREDENTIAL_SYNC_FAILED + - SMS_OTP_CREDENTIAL_SYNC_FAILED message: type: string description: Error message @@ -13928,6 +14361,159 @@ components: type: string description: The one-time verification code the customer received via email or SMS. In sandbox, the code is always `123456`. example: '123456' + ContactChangeStatus: + title: Contact Change Status + type: string + enum: + - AWAITING_SIGNATURE + - PROCESSING + - APPLIED + - FAILED + - EXPIRED + - CANCELLED + description: |- + Lifecycle state of a contact change. + | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | + Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. + example: AWAITING_SIGNATURE + ContactChangeType: + title: Contact Change Type + type: string + enum: + - EMAIL + - PHONE + description: |- + Which contact field a contact change replaces. + + - `EMAIL`: the customer `email` on file, together with the `EMAIL_OTP` credential on every tied Embedded Wallet internal account. + - `PHONE`: the customer `phoneNumber` on file, together with the `SMS_OTP` credential on every tied Embedded Wallet internal account. + + A contact change covers exactly one field. Changing both email and phone takes two separate contact changes, each signed on its own. + example: EMAIL + ContactChange: + title: Contact Change + type: object + description: |- + A pending or completed change to a customer's email or phone number. + + Changing the contact on an Embedded Wallet customer is a login-security operation, not a profile edit: the email behind `EMAIL_OTP` and the phone number behind `SMS_OTP` are the credentials the customer authenticates with. So the change is authorized by the customer's own wallet session rather than by platform credentials alone, and it lands as a durable resource the platform can read, retry, and cancel — instead of a challenge that exists only inside one request/response pair. + + Grid applies the customer contact field and every tied matching OTP credential as one logical operation. If any tied credential cannot be updated, the customer contact field is left unchanged and the change goes to `FAILED`. + required: + - id + - customerId + - type + - value + - status + - createdAt + - updatedAt + properties: + id: + type: string + description: System-generated unique identifier for the contact change. + example: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: + type: string + description: Identifier of the customer whose contact is being changed. + example: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: + $ref: '#/components/schemas/ContactChangeType' + description: Which contact field this change replaces. + value: + type: string + description: 'The new contact value: an email address when `type` is `EMAIL`, an E.164 phone number when `type` is `PHONE`. This is the value that becomes the customer contact field and the nickname of every tied matching OTP credential once the change is `APPLIED`.' + example: jane.smith@example.com + status: + $ref: '#/components/schemas/ContactChangeStatus' + description: Lifecycle state of this contact change. + payloadToSign: + type: string + description: Canonical payload for the submit authorization stamp. Build an API-key stamp over this exact value with the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets, then send the full stamp as `Grid-Wallet-Signature` on `POST /customers/{customerId}/contact-changes/{changeId}/submit`. Present only while `status` is `AWAITING_SIGNATURE` — a client that lost the create response can re-read it here rather than starting over. + example: '{"organizationId":"org_2m9F...","parameters":{"userEmail":"jane.smith@example.com","userId":"user_2m9F..."},"timestampMs":"1775681700000","type":"ACTIVITY_TYPE_UPDATE_USER_EMAIL"}' + expiresAt: + type: string + format: date-time + description: Deadline for the signed submit. A change with no submit by this time moves to `EXPIRED`. Present only while `status` is `AWAITING_SIGNATURE`. + example: '2026-04-08T15:35:00Z' + failureReason: + type: string + description: Human-readable explanation of why the change could not be applied. Present only when `status` is `FAILED`. + example: One or more tied SMS_OTP credentials could not be updated. + createdAt: + type: string + format: date-time + description: When the contact change was created. + example: '2026-04-08T15:30:00Z' + updatedAt: + type: string + format: date-time + description: When the contact change last changed status. + example: '2026-04-08T15:31:12Z' + ContactChangeListResponse: + title: Contact Change List Response + type: object + description: Paginated list of a customer's contact changes, newest first. + required: + - data + - hasMore + properties: + data: + type: array + description: Contact changes on this customer, ordered by `createdAt` descending. + items: + $ref: '#/components/schemas/ContactChange' + hasMore: + type: boolean + description: Indicates if more results are available beyond this page. + nextCursor: + type: string + description: Cursor to retrieve the next page of results. + example: eyJpZCI6IkNvbnRhY3RDaGFuZ2U6MDE5NTQyZjUifQ== + totalCount: + type: integer + description: Total number of contact changes matching the query. + example: 3 + ContactChangeCreateRequest: + title: Contact Change Create Request + type: object + description: Request body for `POST /customers/{customerId}/contact-changes`. Names the contact field to change and its new value; Grid resolves which tied Embedded Wallet OTP credentials must be updated alongside it and binds that set into the returned `payloadToSign`. + required: + - type + - value + properties: + type: + $ref: '#/components/schemas/ContactChangeType' + description: Which contact field to change. + value: + type: string + description: 'The new contact value: an email address when `type` is `EMAIL`, an E.164 phone number when `type` is `PHONE`.' + example: jane.smith@example.com + WalletOperationProcessing: + title: Wallet Operation Processing + description: '`200` response returned by an Embedded Wallet operation that the wallet provider has accepted but not yet settled — a consensus- or approval-gated activity that is still in flight. It is not an error and needs no client action beyond patience: the backend reconciles the operation to its terminal state on its own. The client MAY re-send the byte-identical request to converge sooner; the request is idempotent and returns the settled success response once the operation completes.' + type: object + required: + - status + properties: + status: + type: string + enum: + - PROCESSING + description: Always `PROCESSING`. Marks a still-in-flight operation whose terminal result is not yet available. + example: PROCESSING + message: + type: string + description: Human-readable explanation that the operation is still being processed and the same request may be retried. + example: This login is still being processed. Retry the same request in a moment. + example: + status: PROCESSING + message: This login is still being processed. Retry the same request in a moment. + ContactChangeSubmitResponse: + title: Contact Change Submit Response + description: 'Response body for `POST /customers/{customerId}/contact-changes/{changeId}/submit`. Normally a `ContactChange` — the change with `status: "APPLIED"`. When the underlying wallet-provider activity is still in flight, this is instead a `WalletOperationProcessing` body with `status: "PROCESSING"` — re-send the byte-identical signed submit until it settles; the backend also reconciles the activity to terminal on its own, so `GET /customers/{customerId}/contact-changes/{changeId}` reaches the same answer for a client that stops retrying.' + anyOf: + - $ref: '#/components/schemas/ContactChange' + - $ref: '#/components/schemas/WalletOperationProcessing' ScaFactor: type: string enum: @@ -23479,26 +24065,6 @@ components: SMS_OTP: '#/components/schemas/SmsOtpCredentialCreateRequest' OAUTH: '#/components/schemas/OauthCredentialCreateRequest' PASSKEY: '#/components/schemas/PasskeyCredentialCreateRequest' - WalletOperationProcessing: - title: Wallet Operation Processing - description: '`200` response returned by an Embedded Wallet operation that the wallet provider has accepted but not yet settled — a consensus- or approval-gated activity that is still in flight. It is not an error and needs no client action beyond patience: the backend reconciles the operation to its terminal state on its own. The client MAY re-send the byte-identical request to converge sooner; the request is idempotent and returns the settled success response once the operation completes.' - type: object - required: - - status - properties: - status: - type: string - enum: - - PROCESSING - description: Always `PROCESSING`. Marks a still-in-flight operation whose terminal result is not yet available. - example: PROCESSING - message: - type: string - description: Human-readable explanation that the operation is still being processed and the same request may be retried. - example: This login is still being processed. Retry the same request in a moment. - example: - status: PROCESSING - message: This login is still being processed. Retry the same request in a moment. AuthMethodResponse: title: Auth Method Response description: |- diff --git a/openapi/components/schemas/customers/ContactChange.yaml b/openapi/components/schemas/customers/ContactChange.yaml new file mode 100644 index 000000000..5cdc62498 --- /dev/null +++ b/openapi/components/schemas/customers/ContactChange.yaml @@ -0,0 +1,86 @@ +title: Contact Change +type: object +description: >- + A pending or completed change to a customer's email or phone number. + + + Changing the contact on an Embedded Wallet customer is a login-security + operation, not a profile edit: the email behind `EMAIL_OTP` and the phone + number behind `SMS_OTP` are the credentials the customer authenticates + with. So the change is authorized by the customer's own wallet session + rather than by platform credentials alone, and it lands as a durable + resource the platform can read, retry, and cancel — instead of a + challenge that exists only inside one request/response pair. + + + Grid applies the customer contact field and every tied matching OTP + credential as one logical operation. If any tied credential cannot be + updated, the customer contact field is left unchanged and the change goes + to `FAILED`. +required: + - id + - customerId + - type + - value + - status + - createdAt + - updatedAt +properties: + id: + type: string + description: System-generated unique identifier for the contact change. + example: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: + type: string + description: Identifier of the customer whose contact is being changed. + example: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: + $ref: ./ContactChangeType.yaml + description: Which contact field this change replaces. + value: + type: string + description: >- + The new contact value: an email address when `type` is `EMAIL`, an + E.164 phone number when `type` is `PHONE`. This is the value that + becomes the customer contact field and the nickname of every tied + matching OTP credential once the change is `APPLIED`. + example: jane.smith@example.com + status: + $ref: ./ContactChangeStatus.yaml + description: Lifecycle state of this contact change. + payloadToSign: + type: string + description: >- + Canonical payload for the submit authorization stamp. Build an API-key + stamp over this exact value with the session API keypair of a verified + authentication credential on one of the customer's tied Embedded + Wallets, then send the full stamp as `Grid-Wallet-Signature` on + `POST /customers/{customerId}/contact-changes/{changeId}/submit`. + Present only while `status` is `AWAITING_SIGNATURE` — a client that + lost the create response can re-read it here rather than starting + over. + example: '{"organizationId":"org_2m9F...","parameters":{"userEmail":"jane.smith@example.com","userId":"user_2m9F..."},"timestampMs":"1775681700000","type":"ACTIVITY_TYPE_UPDATE_USER_EMAIL"}' + expiresAt: + type: string + format: date-time + description: >- + Deadline for the signed submit. A change with no submit by this time + moves to `EXPIRED`. Present only while `status` is + `AWAITING_SIGNATURE`. + example: '2026-04-08T15:35:00Z' + failureReason: + type: string + description: >- + Human-readable explanation of why the change could not be applied. + Present only when `status` is `FAILED`. + example: One or more tied SMS_OTP credentials could not be updated. + createdAt: + type: string + format: date-time + description: When the contact change was created. + example: '2026-04-08T15:30:00Z' + updatedAt: + type: string + format: date-time + description: When the contact change last changed status. + example: '2026-04-08T15:31:12Z' diff --git a/openapi/components/schemas/customers/ContactChangeCreateRequest.yaml b/openapi/components/schemas/customers/ContactChangeCreateRequest.yaml new file mode 100644 index 000000000..edef74e36 --- /dev/null +++ b/openapi/components/schemas/customers/ContactChangeCreateRequest.yaml @@ -0,0 +1,20 @@ +title: Contact Change Create Request +type: object +description: >- + Request body for `POST /customers/{customerId}/contact-changes`. Names the + contact field to change and its new value; Grid resolves which tied + Embedded Wallet OTP credentials must be updated alongside it and binds + that set into the returned `payloadToSign`. +required: + - type + - value +properties: + type: + $ref: ./ContactChangeType.yaml + description: Which contact field to change. + value: + type: string + description: >- + The new contact value: an email address when `type` is `EMAIL`, an + E.164 phone number when `type` is `PHONE`. + example: jane.smith@example.com diff --git a/openapi/components/schemas/customers/ContactChangeListResponse.yaml b/openapi/components/schemas/customers/ContactChangeListResponse.yaml new file mode 100644 index 000000000..18a1512ea --- /dev/null +++ b/openapi/components/schemas/customers/ContactChangeListResponse.yaml @@ -0,0 +1,24 @@ +title: Contact Change List Response +type: object +description: >- + Paginated list of a customer's contact changes, newest first. +required: + - data + - hasMore +properties: + data: + type: array + description: Contact changes on this customer, ordered by `createdAt` descending. + items: + $ref: ./ContactChange.yaml + hasMore: + type: boolean + description: Indicates if more results are available beyond this page. + nextCursor: + type: string + description: Cursor to retrieve the next page of results. + example: eyJpZCI6IkNvbnRhY3RDaGFuZ2U6MDE5NTQyZjUifQ== + totalCount: + type: integer + description: Total number of contact changes matching the query. + example: 3 diff --git a/openapi/components/schemas/customers/ContactChangeStatus.yaml b/openapi/components/schemas/customers/ContactChangeStatus.yaml new file mode 100644 index 000000000..6d5f4f51f --- /dev/null +++ b/openapi/components/schemas/customers/ContactChangeStatus.yaml @@ -0,0 +1,24 @@ +title: Contact Change Status +type: string +enum: + - AWAITING_SIGNATURE + - PROCESSING + - APPLIED + - FAILED + - EXPIRED + - CANCELLED +description: >- + Lifecycle state of a contact change. + + | Value | Terminal | Meaning | + |-------|----------|---------| + | `AWAITING_SIGNATURE` | no | Created; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | + | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | + | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. | + | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | + | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | + | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | + + Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` + accepts a cancel. A change never leaves a terminal state. +example: AWAITING_SIGNATURE diff --git a/openapi/components/schemas/customers/ContactChangeSubmitResponse.yaml b/openapi/components/schemas/customers/ContactChangeSubmitResponse.yaml new file mode 100644 index 000000000..fd2c2b5ec --- /dev/null +++ b/openapi/components/schemas/customers/ContactChangeSubmitResponse.yaml @@ -0,0 +1,14 @@ +title: Contact Change Submit Response +description: >- + Response body for + `POST /customers/{customerId}/contact-changes/{changeId}/submit`. Normally + a `ContactChange` — the change with `status: "APPLIED"`. When the + underlying wallet-provider activity is still in flight, this is instead a + `WalletOperationProcessing` body with `status: "PROCESSING"` — re-send the + byte-identical signed submit until it settles; the backend also reconciles + the activity to terminal on its own, so `GET + /customers/{customerId}/contact-changes/{changeId}` reaches the same + answer for a client that stops retrying. +anyOf: + - $ref: ./ContactChange.yaml + - $ref: ../common/WalletOperationProcessing.yaml diff --git a/openapi/components/schemas/customers/ContactChangeType.yaml b/openapi/components/schemas/customers/ContactChangeType.yaml new file mode 100644 index 000000000..c72354d7b --- /dev/null +++ b/openapi/components/schemas/customers/ContactChangeType.yaml @@ -0,0 +1,19 @@ +title: Contact Change Type +type: string +enum: + - EMAIL + - PHONE +description: >- + Which contact field a contact change replaces. + + + - `EMAIL`: the customer `email` on file, together with the `EMAIL_OTP` + credential on every tied Embedded Wallet internal account. + + - `PHONE`: the customer `phoneNumber` on file, together with the `SMS_OTP` + credential on every tied Embedded Wallet internal account. + + + A contact change covers exactly one field. Changing both email and phone + takes two separate contact changes, each signed on its own. +example: EMAIL diff --git a/openapi/components/schemas/errors/Error409.yaml b/openapi/components/schemas/errors/Error409.yaml index 49e09e9a8..def189714 100644 --- a/openapi/components/schemas/errors/Error409.yaml +++ b/openapi/components/schemas/errors/Error409.yaml @@ -19,6 +19,10 @@ properties: | UMA_ADDRESS_EXISTS | UMA address already exists | | EMAIL_OTP_EMAIL_ALREADY_EXISTS | Email address is already associated with an EMAIL_OTP credential | | EMAIL_OTP_CREDENTIAL_SET_CHANGED | Tied EMAIL_OTP credential set changed after the signed-retry challenge was issued | + | SMS_OTP_PHONE_ALREADY_EXISTS | Phone number is already associated with an SMS_OTP credential | + | SMS_OTP_CREDENTIAL_SET_CHANGED | Tied SMS_OTP credential set changed after the signed-retry challenge was issued | + | CONTACT_CHANGE_PENDING | The customer already has an active contact change of this type. `details.contactChangeId` names it; submit or cancel it before starting another | + | CONTACT_CHANGE_NOT_AWAITING_SIGNATURE | The contact change is not `AWAITING_SIGNATURE`, so it can no longer be signed or cancelled | | PASSKEY_ALREADY_ENROLLED | The customer already has an enrolled passkey factor; only one passkey per customer is supported. Delete the existing one before enrolling another | | SCA_SESSION_REQUIRED | The customer's Strong Customer Authentication login session is missing or expired. Re-authenticate the customer, then retry the request. Distinct from a `401`, which means the platform's own API credentials were rejected | | BENEFICIARY_TRUSTED | The external account is currently a trusted beneficiary, so it cannot be deleted. Untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete | @@ -32,6 +36,10 @@ properties: - UMA_ADDRESS_EXISTS - EMAIL_OTP_EMAIL_ALREADY_EXISTS - EMAIL_OTP_CREDENTIAL_SET_CHANGED + - SMS_OTP_PHONE_ALREADY_EXISTS + - SMS_OTP_CREDENTIAL_SET_CHANGED + - CONTACT_CHANGE_PENDING + - CONTACT_CHANGE_NOT_AWAITING_SIGNATURE - PASSKEY_ALREADY_ENROLLED - SCA_SESSION_REQUIRED - BENEFICIARY_TRUSTED diff --git a/openapi/components/schemas/errors/Error424.yaml b/openapi/components/schemas/errors/Error424.yaml index 19ec53f68..9a67d8c85 100644 --- a/openapi/components/schemas/errors/Error424.yaml +++ b/openapi/components/schemas/errors/Error424.yaml @@ -19,12 +19,14 @@ properties: | NO_COMPATIBLE_UMA_VERSION | No compatible UMA version | | LNURLP_REQUEST_FAILED | LNURLP request failed | | EMAIL_OTP_CREDENTIAL_SYNC_FAILED | Failed to update one or more tied EMAIL_OTP credentials | + | SMS_OTP_CREDENTIAL_SYNC_FAILED | Failed to update one or more tied SMS_OTP credentials | enum: - PAYREQ_REQUEST_FAILED - COUNTERPARTY_PUBKEY_FETCH_ERROR - NO_COMPATIBLE_UMA_VERSION - LNURLP_REQUEST_FAILED - EMAIL_OTP_CREDENTIAL_SYNC_FAILED + - SMS_OTP_CREDENTIAL_SYNC_FAILED message: type: string description: Error message diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 8c164aafd..a5f7a96e0 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -159,6 +159,12 @@ paths: $ref: paths/customers/customers_{customerId}_verify-phone.yaml /customers/{customerId}/verify-phone/confirm: $ref: paths/customers/customers_{customerId}_verify-phone_confirm.yaml + /customers/{customerId}/contact-changes: + $ref: paths/customers/customers_{customerId}_contact-changes.yaml + /customers/{customerId}/contact-changes/{changeId}: + $ref: paths/customers/customers_{customerId}_contact-changes_{changeId}.yaml + /customers/{customerId}/contact-changes/{changeId}/submit: + $ref: paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml /sca/factors: $ref: paths/sca/sca_factors.yaml /sca/factors/confirm: diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml new file mode 100644 index 000000000..b72813585 --- /dev/null +++ b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml @@ -0,0 +1,263 @@ +parameters: + - name: customerId + in: path + description: System-generated unique customer identifier + required: true + schema: + type: string +post: + summary: Start a contact change + description: > + Start changing an Embedded Wallet customer's email or phone number. + + + The email behind an `EMAIL_OTP` credential and the phone number behind an + `SMS_OTP` credential are what the customer logs in with, so changing + either one is a login-security operation that the customer's own wallet + session has to authorize. This endpoint creates the change as a durable + resource and returns the `payloadToSign` for it; the change takes effect + only after `POST + /customers/{customerId}/contact-changes/{changeId}/submit` carries the + signature. + + + 1. Call this endpoint with the contact `type` and its new `value`. Grid + resolves every tied Embedded Wallet OTP credential of that type, binds + that set into `payloadToSign`, and returns `201` with the change in + `AWAITING_SIGNATURE`. + + + 2. Use the session API keypair of a verified authentication credential on + one of the customer's tied Embedded Wallets to build an API-key stamp + over `payloadToSign`, then submit it. Grid updates the customer contact + field and every tied matching OTP credential as one logical operation; if + any tied credential cannot be updated, the customer contact field is left + unchanged. + + + A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) + contact change per contact type. Creating a second one while the first is + still active returns `409` naming the active change, which the caller can + read with `GET /customers/{customerId}/contact-changes/{changeId}` or + abandon with `DELETE`. + + + Unlike the `202` challenge on `PATCH /customers/{customerId}`, the + challenge here survives the response: a client that lost the create + response can re-read `payloadToSign` from the change resource instead of + starting over. + operationId: createContactChange + tags: + - Customers + security: + - BasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: ../../components/schemas/customers/ContactChangeCreateRequest.yaml + examples: + email: + summary: Change the customer email + value: + type: EMAIL + value: jane.smith@example.com + phone: + summary: Change the customer phone number + value: + type: PHONE + value: '+14155559876' + responses: + '201': + description: >- + Contact change created in `AWAITING_SIGNATURE`, carrying the + `payloadToSign` to stamp and the `expiresAt` deadline to submit by. + content: + application/json: + schema: + $ref: ../../components/schemas/customers/ContactChange.yaml + examples: + email: + summary: Email change awaiting signature + value: + 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' + phone: + summary: Phone change awaiting signature + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000021 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: AWAITING_SIGNATURE + payloadToSign: '{"organizationId":"org_2m9F...","parameters":{"userId":"user_2m9F...","userPhoneNumber":"+14155559876"},"timestampMs":"1775681700000","type":"ACTIVITY_TYPE_UPDATE_USER_PHONE_NUMBER"}' + expiresAt: '2026-04-08T15:35:00Z' + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' + '400': + description: >- + Bad request. Returned with `INVALID_INPUT` when `value` is not a valid + email address for `type: EMAIL` or not a valid E.164 phone number for + `type: PHONE`, and when the customer has no tied Embedded Wallet OTP + credential of the requested type — such a customer has no login + credential to re-key, so update its contact fields with + `PATCH /customers/{customerId}` instead. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error400.yaml + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: Customer not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + Conflict. Returned with `CONTACT_CHANGE_PENDING` when the customer + already has an active contact change of this type — `details` + carries `contactChangeId` so the caller can read or cancel it — + with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is + already associated with an `EMAIL_OTP` credential, or with + `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is + already associated with an `SMS_OTP` credential, on this or another + internal account. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error409.yaml + examples: + alreadyPending: + summary: An active contact change of this type already exists + value: + status: 409 + code: CONTACT_CHANGE_PENDING + message: >- + This customer already has an active EMAIL contact change. + Submit or cancel it before starting another. + details: + contactChangeId: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml +get: + summary: List contact changes + description: >- + List a customer's contact changes, newest first — the active one, if any, + plus the history of applied, failed, expired, and cancelled attempts. + + + Use this to show a customer's pending contact change after losing the + create response, or to audit who changed what and when. To check only + whether a change is in flight, filter on `status`. + operationId: listContactChanges + tags: + - Customers + security: + - BasicAuth: [] + parameters: + - name: status + in: query + description: >- + Return only contact changes in this state. Omit to return every + contact change on the customer. + required: false + schema: + $ref: ../../components/schemas/customers/ContactChangeStatus.yaml + - name: limit + in: query + description: Maximum number of results to return (default 20, max 100) + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: cursor + in: query + description: Cursor for pagination (returned from previous request) + required: false + schema: + type: string + responses: + '200': + description: >- + Contact changes on this customer, newest first. Returns an empty + `data` array when the customer has never had a contact change. + content: + application/json: + schema: + $ref: ../../components/schemas/customers/ContactChangeListResponse.yaml + examples: + pendingAndHistory: + summary: One active change plus prior attempts + value: + data: + - id: ContactChange:019542f5-b3e7-1d02-0000-000000000021 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: AWAITING_SIGNATURE + payloadToSign: '{"organizationId":"org_2m9F...","parameters":{"userId":"user_2m9F...","userPhoneNumber":"+14155559876"},"timestampMs":"1775681700000","type":"ACTIVITY_TYPE_UPDATE_USER_PHONE_NUMBER"}' + expiresAt: '2026-04-08T15:35:00Z' + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' + - id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: APPLIED + createdAt: '2026-04-07T09:12:00Z' + updatedAt: '2026-04-07T09:13:04Z' + hasMore: false + totalCount: 2 + empty: + summary: No contact changes on this customer + value: + data: [] + hasMore: false + totalCount: 0 + '400': + description: >- + Bad request. Returned with `INVALID_INPUT` when `status` is not a + recognized contact change status or the pagination parameters are + out of range. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error400.yaml + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: Customer not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}.yaml new file mode 100644 index 000000000..d97b9baa4 --- /dev/null +++ b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}.yaml @@ -0,0 +1,146 @@ +parameters: + - name: customerId + in: path + description: System-generated unique customer identifier + required: true + schema: + type: string + - name: changeId + in: path + description: >- + The id of the contact change (the `id` field returned from + `POST /customers/{customerId}/contact-changes`). + required: true + schema: + type: string +get: + summary: Get a contact change + description: >- + Read a single contact change. + + + While the change is `AWAITING_SIGNATURE` this returns its `payloadToSign` + and `expiresAt`, so a client that lost the create response can recover + the payload to stamp rather than starting a new change. Once the change + is `PROCESSING`, this is also how a client that stopped re-sending the + signed submit learns the outcome — Grid reconciles the underlying + activity to terminal on its own. + operationId: getContactChange + tags: + - Customers + security: + - BasicAuth: [] + responses: + '200': + description: The contact change. + content: + application/json: + schema: + $ref: ../../components/schemas/customers/ContactChange.yaml + examples: + applied: + summary: Applied email change + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:31:12Z' + failed: + summary: Failed phone change + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000021 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: FAILED + failureReason: One or more tied SMS_OTP credentials could not be updated. + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:31:44Z' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: Customer or contact change not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml +delete: + summary: Cancel a contact change + description: >- + Cancel a contact change that has not been submitted yet, moving it to + `CANCELLED`. + + + Only an `AWAITING_SIGNATURE` change can be cancelled. Once the signature + is submitted the change is in the wallet provider's hands and resolves to + `APPLIED` or `FAILED` on its own — Grid does not pretend to un-sign it. + An unsubmitted change also lapses to `EXPIRED` on its own at `expiresAt`, + so cancelling is for abandoning a change deliberately (the customer + backed out, or the platform needs to start one with a different value) + rather than for cleanup. + operationId: cancelContactChange + tags: + - Customers + security: + - BasicAuth: [] + responses: + '200': + description: >- + Contact change cancelled. The body is the change with + `status: "CANCELLED"`. + content: + application/json: + schema: + $ref: ../../components/schemas/customers/ContactChange.yaml + examples: + cancelled: + summary: Cancelled email change + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: CANCELLED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:32:00Z' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: Customer or contact change not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when + the change is not `AWAITING_SIGNATURE` — a submitted change + (`PROCESSING`) has to resolve on its own, and a change that is + already `APPLIED`, `FAILED`, `EXPIRED`, or `CANCELLED` is terminal. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error409.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml new file mode 100644 index 000000000..5b48ff04c --- /dev/null +++ b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml @@ -0,0 +1,142 @@ +parameters: + - name: customerId + in: path + description: System-generated unique customer identifier + required: true + schema: + type: string + - name: changeId + in: path + description: >- + The id of the contact change being submitted (the `id` field returned + from `POST /customers/{customerId}/contact-changes`). + required: true + schema: + type: string +post: + summary: Submit a signed contact change + description: > + Apply a contact change by submitting the customer's signature over its + `payloadToSign`. + + + Build an API-key stamp over `payloadToSign` **byte-for-byte as Grid + returned it**, using the session API keypair of a verified authentication + credential on one of the customer's tied Embedded Wallets, and send the + full stamp as `Grid-Wallet-Signature`. The change itself carries what is + being changed, so there is no request body and no `Request-Id` to echo — + `changeId` in the path is the correlation the other Embedded Wallet + signed-retry endpoints get from `Request-Id`. + + + Grid updates the customer contact field and every tied matching OTP + credential as one logical operation. If any tied credential cannot be + updated, the customer contact field is left unchanged. + + + This endpoint is idempotent, which is what makes the still-processing + response safe to drive: re-sending the same stamp returns + `WalletOperationProcessing` while the wallet-provider activity is in + flight and the `APPLIED` change once it settles, without starting a + second change. A client that stops re-sending loses nothing — Grid + reconciles the activity on its own, and + `GET /customers/{customerId}/contact-changes/{changeId}` reaches the same + answer. + operationId: submitContactChange + tags: + - Customers + security: + - BasicAuth: [] + parameters: + - name: Grid-Wallet-Signature + in: header + required: true + description: >- + Full API-key stamp built over the contact change's `payloadToSign` + with the session API keypair of a verified authentication credential + on one of the customer's tied Embedded Wallets. + schema: + type: string + example: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9 + responses: + '200': + description: >- + Either the applied contact change or a `WalletOperationProcessing` + body with `status: "PROCESSING"`. `200` alone does not distinguish + them — check the body: a `PROCESSING` status means the wallet-provider + activity is still in flight, so re-send this same stamped request + until the change comes back `APPLIED`. A change that is already + `APPLIED` returns that same body on a re-send. + content: + application/json: + schema: + $ref: ../../components/schemas/customers/ContactChangeSubmitResponse.yaml + examples: + applied: + summary: Contact change applied + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000020 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: EMAIL + value: jane.smith@example.com + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:31:12Z' + processing: + summary: Still settling at the wallet provider + value: + status: PROCESSING + message: >- + This contact change is still being processed. Retry the same + request in a moment. + '401': + description: >- + Unauthorized. Returned when `Grid-Wallet-Signature` is missing, + malformed, or was not produced by a verified authentication + credential on one of the customer's tied Embedded Wallets, or when + the stamp does not match this contact change's `payloadToSign`. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error401.yaml + '404': + description: Customer or contact change not found + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error404.yaml + '409': + description: >- + Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when + the change is `EXPIRED`, `CANCELLED`, or `FAILED` — all terminal, so + start a new contact change rather than re-signing this one. A change + that is `PROCESSING` or `APPLIED` is not a conflict and returns `200`. + Also returned with `EMAIL_OTP_CREDENTIAL_SET_CHANGED` or + `SMS_OTP_CREDENTIAL_SET_CHANGED` when the set of tied OTP credentials + bound into `payloadToSign` at create time no longer matches the + account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or + `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by + another credential between create and submit. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error409.yaml + '424': + description: >- + Failed dependency. Returned with `EMAIL_OTP_CREDENTIAL_SYNC_FAILED` + or `SMS_OTP_CREDENTIAL_SYNC_FAILED` when Grid cannot update one or + more tied Embedded Wallet OTP credentials. The customer contact field + is not changed, and the contact change moves to `FAILED`. A failure + that only surfaces after this call returned `PROCESSING` has no status + code to land on and shows up as `FAILED` with a `failureReason` on the + change itself. + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error424.yaml + '500': + description: Internal service error + content: + application/json: + schema: + $ref: ../../components/schemas/errors/Error500.yaml From 98097ec3820cb4e11f3744a8812617e0be7c5e10 Mon Sep 17 00:00:00 2001 From: carsonp6 Date: Fri, 21 Aug 2026 13:03:30 -0700 Subject: [PATCH 2/8] docs: document the contact-change flow in the authentication guide Adds a "changing the contact on file" section next to the other signed credential operations: create, stamp, submit, with the cancel/expire rules and the note that a lost create response is recoverable from the change resource. Repoints "changing the email OTP address" at it, keeping a line that PATCH /customers still accepts email and phoneNumber so integrators live on that path are not left guessing. Adds submit to the still-processing endpoint table and to the callout listing the endpoints whose terminal success shares 200 with PROCESSING, and notes that a submit retry has no body or Request-Id to re-send. Co-Authored-By: Claude Fable 5 --- .../global-accounts/authentication.mdx | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/mintlify/snippets/global-accounts/authentication.mdx b/mintlify/snippets/global-accounts/authentication.mdx index f6813890e..b35a7c881 100644 --- a/mintlify/snippets/global-accounts/authentication.mdx +++ b/mintlify/snippets/global-accounts/authentication.mdx @@ -607,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 changing the contact on file. `PATCH /customers/{customerId}` still accepts `email` and `phoneNumber` with its own in-response challenge, but contact changes supersede that path for wallet customers. ## Managing credentials @@ -650,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 changing the contact on file. ```mermaid sequenceDiagram @@ -702,15 +702,16 @@ Every signed-retry endpoint can return it on the retry step: | `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`. 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. +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. - `verify` and `challenge` already use `200` for their terminal success body (`AuthSession` or the challenge response). For those two, `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. + `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. ### Add an additional credential @@ -807,3 +808,52 @@ A credential is revoked by signing with a session from **a different credential **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 — re-send the identical `DELETE` until you get the `204`, and show the credential as "removing…" in your UI in the meantime rather than assuming success or failure. + +### Changing the contact on file + +The email behind `EMAIL_OTP` and the phone number behind `SMS_OTP` are what the customer logs in with, so changing either is a login-security operation, not a profile edit — it needs the customer's signature, and it re-keys every tied credential of that type. Contact changes get their own resource for that reason: `POST /customers/{customerId}/contact-changes` creates one, and it holds the challenge until it is signed, cancelled, or expires. + + + + ```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 (201):** + + ```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. + + + 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. + + + ```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; re-send the identical request 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. + + + +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. + +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. From a775427c5bfc71571b2b6a4d622a0dd8f53dcd88 Mon Sep 17 00:00:00 2001 From: carsonp6 Date: Fri, 21 Aug 2026 13:25:59 -0700 Subject: [PATCH 3/8] feat: make contact-changes the only door for contact updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A create for a customer with no tied EMAIL_OTP / SMS_OTP credential no longer 400s pointing at PATCH. There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns 201 with it already APPLIED, carrying no payloadToSign and no expiresAt. So a change now arrives in one of two states, and the caller branches on status rather than on what it believes about the customer's credentials — a wallet that gained or lost an OTP credential since the caller last looked flips which state it gets, and one endpoint that decides for them is the point. Also marks email and phoneNumber deprecated on PATCH /customers — in the endpoint description, the CustomerUpdateRequest schema description, and both field descriptions — pointing at contact-changes. Nothing is removed and both fields stay functional during migration. Co-Authored-By: Claude Fable 5 --- mintlify/openapi.yaml | 49 +++++++++---- .../global-accounts/authentication.mdx | 15 +++- openapi.yaml | 49 +++++++++---- .../schemas/customers/ContactChange.yaml | 23 ++++-- .../customers/ContactChangeStatus.yaml | 8 ++- .../customers/CustomerUpdateRequest.yaml | 33 +++++---- .../customers/customers_{customerId}.yaml | 8 +++ ...ustomers_{customerId}_contact-changes.yaml | 70 ++++++++++++------- 8 files changed, 174 insertions(+), 81 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 2b0209e73..e72eedce8 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -653,6 +653,8 @@ paths: description: | Update a customer's metadata by their system-generated ID. + **`email` and `phoneNumber` are deprecated on this endpoint.** Use `POST /customers/{customerId}/contact-changes` instead, which handles both the signed flow for a contact that is a login credential and the immediate apply when it is not, and leaves a readable record of the change either way. Both fields remain functional here during migration; everything else on this endpoint is unaffected. + Most customer updates complete synchronously and return `200` with the updated customer. If the request changes `email` for a customer that has one or more tied Embedded Wallet internal accounts with `EMAIL_OTP` credentials, or changes `phoneNumber` for a customer that has one or more tied Embedded Wallet internal accounts with `SMS_OTP` credentials, the contact update uses the two-step signed-retry flow so the customer's wallet session authorizes the authentication credential update. On the signed retry, Grid updates the customer contact field and every tied matching OTP credential across all tied Embedded Wallets as one logical operation. If any tied credential cannot be updated, the customer contact field is not changed. Update `email` and `phoneNumber` in separate PATCH calls. A request that includes both fields is rejected. @@ -1187,17 +1189,19 @@ paths: schema: type: string post: - summary: Start a contact change + summary: Create a contact change description: | - Start changing an Embedded Wallet customer's email or phone number. + Change a customer's email or phone number. Every contact change goes through this endpoint. `PATCH /customers/{customerId}` still accepts `email` and `phoneNumber` during migration, but those two fields are deprecated in favor of this endpoint. + + A contact that is also a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — can only be changed with the customer's own signature, because changing it re-keys what they log in with. So a change arrives in one of two states: - The email behind an `EMAIL_OTP` credential and the phone number behind an `SMS_OTP` credential are what the customer logs in with, so changing either one is a login-security operation that the customer's own wallet session has to authorize. This endpoint creates the change as a durable resource and returns the `payloadToSign` for it; the change takes effect only after `POST /customers/{customerId}/contact-changes/{changeId}/submit` carries the signature. + - **The customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and returns `201` with the change in `AWAITING_SIGNATURE`. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. - 1. Call this endpoint with the contact `type` and its new `value`. Grid resolves every tied Embedded Wallet OTP credential of that type, binds that set into `payloadToSign`, and returns `201` with the change in `AWAITING_SIGNATURE`. + - **The customer has no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with the change already `APPLIED`, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. - 2. Use the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets to build an API-key stamp over `payloadToSign`, then submit it. Grid updates the customer contact field and every tied matching OTP credential as one logical operation; if any tied credential cannot be updated, the customer contact field is left unchanged. + Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. - A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. + A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. A change that arrives `APPLIED` is terminal on arrival, so it never blocks the next one. Unlike the `202` challenge on `PATCH /customers/{customerId}`, the challenge here survives the response: a client that lost the create response can re-read `payloadToSign` from the change resource instead of starting over. operationId: createContactChange @@ -1224,7 +1228,7 @@ paths: value: '+14155559876' responses: '201': - description: Contact change created in `AWAITING_SIGNATURE`, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. + description: Contact change created. `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by; `APPLIED` when none exists, in which case the change is already done and carries neither field. Branch on `status`. content: application/json: schema: @@ -1254,8 +1258,18 @@ paths: expiresAt: '2026-04-08T15:35:00Z' createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:00Z' + appliedOnCreate: + summary: Applied on create (no tied OTP credential of that type) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000022 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' '400': - description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`, and when the customer has no tied Embedded Wallet OTP credential of the requested type — such a customer has no login credential to re-key, so update its contact fields with `PATCH /customers/{customerId}` instead.' + description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`.' content: application/json: schema: @@ -14187,7 +14201,10 @@ components: additionalProperties: true CustomerUpdateRequest: title: Customer Update Request - description: Request body for `PATCH /customers/{customerId}`. When `email` changes for a customer with tied Embedded Wallet internal accounts, Grid updates the customer email and every tied `EMAIL_OTP` credential through the endpoint's signed-retry flow. When `phoneNumber` changes for a customer with tied Embedded Wallet internal accounts, Grid updates the customer phone number and every tied `SMS_OTP` credential through the same signed-retry flow. Update `email` and `phoneNumber` in separate PATCH calls. + description: |- + Request body for `PATCH /customers/{customerId}`. + + `email` and `phoneNumber` are deprecated here in favor of `POST /customers/{customerId}/contact-changes`; they remain functional during migration. When `email` changes for a customer with tied Embedded Wallet internal accounts, Grid updates the customer email and every tied `EMAIL_OTP` credential through the endpoint's signed-retry flow. When `phoneNumber` changes for a customer with tied Embedded Wallet internal accounts, Grid updates the customer phone number and every tied `SMS_OTP` credential through the same signed-retry flow. Update `email` and `phoneNumber` in separate PATCH calls. type: object required: - customerType @@ -14206,12 +14223,12 @@ components: email: type: string format: email - description: Email address for the customer. For customers with tied Embedded Wallet internal accounts, changing this value also updates every tied `EMAIL_OTP` credential across all tied Embedded Wallets. + description: 'Email address for the customer. **Deprecated — use `POST /customers/{customerId}/contact-changes` with `type: EMAIL`.** Still functional during migration. For customers with tied Embedded Wallet internal accounts, changing this value also updates every tied `EMAIL_OTP` credential across all tied Embedded Wallets.' example: john.doe@example.com phoneNumber: type: string pattern: ^\+[1-9]\d{1,14}$ - description: Phone number for the customer in strict E.164 format. For customers with tied Embedded Wallet internal accounts, changing this value also updates every tied `SMS_OTP` credential across all tied Embedded Wallets. Send phone number and email updates as separate PATCH calls. + description: 'Phone number for the customer in strict E.164 format. **Deprecated — use `POST /customers/{customerId}/contact-changes` with `type: PHONE`.** Still functional during migration. For customers with tied Embedded Wallet internal accounts, changing this value also updates every tied `SMS_OTP` credential across all tied Embedded Wallets. Send phone number and email updates as separate PATCH calls.' example: '+14155551234' umaAddress: type: string @@ -14373,8 +14390,8 @@ components: - CANCELLED description: |- Lifecycle state of a contact change. - | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | - Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. + | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created against a tied OTP credential; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. Also the arrival state when the customer has no tied OTP credential of that type: nothing needs re-keying, so no signature is collected and Grid applies the change on create. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | + A change arrives either `AWAITING_SIGNATURE` or `APPLIED`, depending on whether a tied OTP credential of that type exists. Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. example: AWAITING_SIGNATURE ContactChangeType: title: Contact Change Type @@ -14394,9 +14411,11 @@ components: title: Contact Change type: object description: |- - A pending or completed change to a customer's email or phone number. + A pending or completed change to a customer's email or phone number. Every contact change goes through this resource, whether or not it needs a signature. + + When the contact is a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — changing it is a login-security operation, not a profile edit, so it is authorized by the customer's own wallet session rather than by platform credentials alone. The change then lands as a durable resource the platform can read, retry, and cancel, instead of a challenge that exists only inside one request/response pair. - Changing the contact on an Embedded Wallet customer is a login-security operation, not a profile edit: the email behind `EMAIL_OTP` and the phone number behind `SMS_OTP` are the credentials the customer authenticates with. So the change is authorized by the customer's own wallet session rather than by platform credentials alone, and it lands as a durable resource the platform can read, retry, and cancel — instead of a challenge that exists only inside one request/response pair. + A change therefore arrives in one of two states. With a tied OTP credential of that type, it arrives `AWAITING_SIGNATURE` carrying `payloadToSign` and `expiresAt`, and applies on the signed submit. With no tied credential of that type there is nothing to re-key and no signature to collect, so Grid applies it on create and it arrives `APPLIED` with no `payloadToSign` and no `expiresAt`. Grid applies the customer contact field and every tied matching OTP credential as one logical operation. If any tied credential cannot be updated, the customer contact field is left unchanged and the change goes to `FAILED`. required: diff --git a/mintlify/snippets/global-accounts/authentication.mdx b/mintlify/snippets/global-accounts/authentication.mdx index b35a7c881..3ac268e88 100644 --- a/mintlify/snippets/global-accounts/authentication.mdx +++ b/mintlify/snippets/global-accounts/authentication.mdx @@ -607,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, so changing it re-keys the credential the customer logs in with. Use the contact-change endpoints — see changing the contact on file. `PATCH /customers/{customerId}` still accepts `email` and `phoneNumber` with its own in-response challenge, but contact changes supersede that path for wallet customers. +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 changing the contact on file. `email` and `phoneNumber` on `PATCH /customers/{customerId}` are deprecated in favor of them; they still work during migration. ## Managing credentials @@ -811,7 +811,18 @@ A credential is revoked by signing with a session from **a different credential ### Changing the contact on file -The email behind `EMAIL_OTP` and the phone number behind `SMS_OTP` are what the customer logs in with, so changing either is a login-security operation, not a profile edit — it needs the customer's signature, and it re-keys every tied credential of that type. Contact changes get their own resource for that reason: `POST /customers/{customerId}/contact-changes` creates one, and it holds the challenge until it is signed, cancelled, or expires. +`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`: + +| Arrival `status` | Means | What to do | +|---|---|---| +| `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. | +| `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. | + +**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 rest of this section covers the `AWAITING_SIGNATURE` path. diff --git a/openapi.yaml b/openapi.yaml index 2b0209e73..e72eedce8 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -653,6 +653,8 @@ paths: description: | Update a customer's metadata by their system-generated ID. + **`email` and `phoneNumber` are deprecated on this endpoint.** Use `POST /customers/{customerId}/contact-changes` instead, which handles both the signed flow for a contact that is a login credential and the immediate apply when it is not, and leaves a readable record of the change either way. Both fields remain functional here during migration; everything else on this endpoint is unaffected. + Most customer updates complete synchronously and return `200` with the updated customer. If the request changes `email` for a customer that has one or more tied Embedded Wallet internal accounts with `EMAIL_OTP` credentials, or changes `phoneNumber` for a customer that has one or more tied Embedded Wallet internal accounts with `SMS_OTP` credentials, the contact update uses the two-step signed-retry flow so the customer's wallet session authorizes the authentication credential update. On the signed retry, Grid updates the customer contact field and every tied matching OTP credential across all tied Embedded Wallets as one logical operation. If any tied credential cannot be updated, the customer contact field is not changed. Update `email` and `phoneNumber` in separate PATCH calls. A request that includes both fields is rejected. @@ -1187,17 +1189,19 @@ paths: schema: type: string post: - summary: Start a contact change + summary: Create a contact change description: | - Start changing an Embedded Wallet customer's email or phone number. + Change a customer's email or phone number. Every contact change goes through this endpoint. `PATCH /customers/{customerId}` still accepts `email` and `phoneNumber` during migration, but those two fields are deprecated in favor of this endpoint. + + A contact that is also a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — can only be changed with the customer's own signature, because changing it re-keys what they log in with. So a change arrives in one of two states: - The email behind an `EMAIL_OTP` credential and the phone number behind an `SMS_OTP` credential are what the customer logs in with, so changing either one is a login-security operation that the customer's own wallet session has to authorize. This endpoint creates the change as a durable resource and returns the `payloadToSign` for it; the change takes effect only after `POST /customers/{customerId}/contact-changes/{changeId}/submit` carries the signature. + - **The customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and returns `201` with the change in `AWAITING_SIGNATURE`. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. - 1. Call this endpoint with the contact `type` and its new `value`. Grid resolves every tied Embedded Wallet OTP credential of that type, binds that set into `payloadToSign`, and returns `201` with the change in `AWAITING_SIGNATURE`. + - **The customer has no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with the change already `APPLIED`, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. - 2. Use the session API keypair of a verified authentication credential on one of the customer's tied Embedded Wallets to build an API-key stamp over `payloadToSign`, then submit it. Grid updates the customer contact field and every tied matching OTP credential as one logical operation; if any tied credential cannot be updated, the customer contact field is left unchanged. + Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. - A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. + A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. A change that arrives `APPLIED` is terminal on arrival, so it never blocks the next one. Unlike the `202` challenge on `PATCH /customers/{customerId}`, the challenge here survives the response: a client that lost the create response can re-read `payloadToSign` from the change resource instead of starting over. operationId: createContactChange @@ -1224,7 +1228,7 @@ paths: value: '+14155559876' responses: '201': - description: Contact change created in `AWAITING_SIGNATURE`, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. + description: Contact change created. `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by; `APPLIED` when none exists, in which case the change is already done and carries neither field. Branch on `status`. content: application/json: schema: @@ -1254,8 +1258,18 @@ paths: expiresAt: '2026-04-08T15:35:00Z' createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:00Z' + appliedOnCreate: + summary: Applied on create (no tied OTP credential of that type) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000022 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' '400': - description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`, and when the customer has no tied Embedded Wallet OTP credential of the requested type — such a customer has no login credential to re-key, so update its contact fields with `PATCH /customers/{customerId}` instead.' + description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`.' content: application/json: schema: @@ -14187,7 +14201,10 @@ components: additionalProperties: true CustomerUpdateRequest: title: Customer Update Request - description: Request body for `PATCH /customers/{customerId}`. When `email` changes for a customer with tied Embedded Wallet internal accounts, Grid updates the customer email and every tied `EMAIL_OTP` credential through the endpoint's signed-retry flow. When `phoneNumber` changes for a customer with tied Embedded Wallet internal accounts, Grid updates the customer phone number and every tied `SMS_OTP` credential through the same signed-retry flow. Update `email` and `phoneNumber` in separate PATCH calls. + description: |- + Request body for `PATCH /customers/{customerId}`. + + `email` and `phoneNumber` are deprecated here in favor of `POST /customers/{customerId}/contact-changes`; they remain functional during migration. When `email` changes for a customer with tied Embedded Wallet internal accounts, Grid updates the customer email and every tied `EMAIL_OTP` credential through the endpoint's signed-retry flow. When `phoneNumber` changes for a customer with tied Embedded Wallet internal accounts, Grid updates the customer phone number and every tied `SMS_OTP` credential through the same signed-retry flow. Update `email` and `phoneNumber` in separate PATCH calls. type: object required: - customerType @@ -14206,12 +14223,12 @@ components: email: type: string format: email - description: Email address for the customer. For customers with tied Embedded Wallet internal accounts, changing this value also updates every tied `EMAIL_OTP` credential across all tied Embedded Wallets. + description: 'Email address for the customer. **Deprecated — use `POST /customers/{customerId}/contact-changes` with `type: EMAIL`.** Still functional during migration. For customers with tied Embedded Wallet internal accounts, changing this value also updates every tied `EMAIL_OTP` credential across all tied Embedded Wallets.' example: john.doe@example.com phoneNumber: type: string pattern: ^\+[1-9]\d{1,14}$ - description: Phone number for the customer in strict E.164 format. For customers with tied Embedded Wallet internal accounts, changing this value also updates every tied `SMS_OTP` credential across all tied Embedded Wallets. Send phone number and email updates as separate PATCH calls. + description: 'Phone number for the customer in strict E.164 format. **Deprecated — use `POST /customers/{customerId}/contact-changes` with `type: PHONE`.** Still functional during migration. For customers with tied Embedded Wallet internal accounts, changing this value also updates every tied `SMS_OTP` credential across all tied Embedded Wallets. Send phone number and email updates as separate PATCH calls.' example: '+14155551234' umaAddress: type: string @@ -14373,8 +14390,8 @@ components: - CANCELLED description: |- Lifecycle state of a contact change. - | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | - Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. + | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created against a tied OTP credential; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. Also the arrival state when the customer has no tied OTP credential of that type: nothing needs re-keying, so no signature is collected and Grid applies the change on create. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | + A change arrives either `AWAITING_SIGNATURE` or `APPLIED`, depending on whether a tied OTP credential of that type exists. Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. example: AWAITING_SIGNATURE ContactChangeType: title: Contact Change Type @@ -14394,9 +14411,11 @@ components: title: Contact Change type: object description: |- - A pending or completed change to a customer's email or phone number. + A pending or completed change to a customer's email or phone number. Every contact change goes through this resource, whether or not it needs a signature. + + When the contact is a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — changing it is a login-security operation, not a profile edit, so it is authorized by the customer's own wallet session rather than by platform credentials alone. The change then lands as a durable resource the platform can read, retry, and cancel, instead of a challenge that exists only inside one request/response pair. - Changing the contact on an Embedded Wallet customer is a login-security operation, not a profile edit: the email behind `EMAIL_OTP` and the phone number behind `SMS_OTP` are the credentials the customer authenticates with. So the change is authorized by the customer's own wallet session rather than by platform credentials alone, and it lands as a durable resource the platform can read, retry, and cancel — instead of a challenge that exists only inside one request/response pair. + A change therefore arrives in one of two states. With a tied OTP credential of that type, it arrives `AWAITING_SIGNATURE` carrying `payloadToSign` and `expiresAt`, and applies on the signed submit. With no tied credential of that type there is nothing to re-key and no signature to collect, so Grid applies it on create and it arrives `APPLIED` with no `payloadToSign` and no `expiresAt`. Grid applies the customer contact field and every tied matching OTP credential as one logical operation. If any tied credential cannot be updated, the customer contact field is left unchanged and the change goes to `FAILED`. required: diff --git a/openapi/components/schemas/customers/ContactChange.yaml b/openapi/components/schemas/customers/ContactChange.yaml index 5cdc62498..fa3f7a919 100644 --- a/openapi/components/schemas/customers/ContactChange.yaml +++ b/openapi/components/schemas/customers/ContactChange.yaml @@ -1,18 +1,27 @@ title: Contact Change type: object description: >- - A pending or completed change to a customer's email or phone number. + A pending or completed change to a customer's email or phone number. Every + contact change goes through this resource, whether or not it needs a + signature. - Changing the contact on an Embedded Wallet customer is a login-security - operation, not a profile edit: the email behind `EMAIL_OTP` and the phone - number behind `SMS_OTP` are the credentials the customer authenticates - with. So the change is authorized by the customer's own wallet session - rather than by platform credentials alone, and it lands as a durable - resource the platform can read, retry, and cancel — instead of a + When the contact is a login credential — the email behind `EMAIL_OTP`, the + phone number behind `SMS_OTP` — changing it is a login-security operation, + not a profile edit, so it is authorized by the customer's own wallet + session rather than by platform credentials alone. The change then lands as + a durable resource the platform can read, retry, and cancel, instead of a challenge that exists only inside one request/response pair. + A change therefore arrives in one of two states. With a tied OTP credential + of that type, it arrives `AWAITING_SIGNATURE` carrying `payloadToSign` and + `expiresAt`, and applies on the signed submit. With no tied credential of + that type there is nothing to re-key and no signature to collect, so Grid + applies it on create and it arrives `APPLIED` with no `payloadToSign` and + no `expiresAt`. + + Grid applies the customer contact field and every tied matching OTP credential as one logical operation. If any tied credential cannot be updated, the customer contact field is left unchanged and the change goes diff --git a/openapi/components/schemas/customers/ContactChangeStatus.yaml b/openapi/components/schemas/customers/ContactChangeStatus.yaml index 6d5f4f51f..a1c878cf4 100644 --- a/openapi/components/schemas/customers/ContactChangeStatus.yaml +++ b/openapi/components/schemas/customers/ContactChangeStatus.yaml @@ -12,13 +12,15 @@ description: >- | Value | Terminal | Meaning | |-------|----------|---------| - | `AWAITING_SIGNATURE` | no | Created; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | + | `AWAITING_SIGNATURE` | no | Created against a tied OTP credential; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | - | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. | + | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. Also the arrival state when the customer has no tied OTP credential of that type: nothing needs re-keying, so no signature is collected and Grid applies the change on create. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | - Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` + A change arrives either `AWAITING_SIGNATURE` or `APPLIED`, depending on + whether a tied OTP credential of that type exists. Only + `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. example: AWAITING_SIGNATURE diff --git a/openapi/components/schemas/customers/CustomerUpdateRequest.yaml b/openapi/components/schemas/customers/CustomerUpdateRequest.yaml index 12df32e28..7a8a6ed8f 100644 --- a/openapi/components/schemas/customers/CustomerUpdateRequest.yaml +++ b/openapi/components/schemas/customers/CustomerUpdateRequest.yaml @@ -1,12 +1,17 @@ title: Customer Update Request description: >- - Request body for `PATCH /customers/{customerId}`. When `email` changes for a - customer with tied Embedded Wallet internal accounts, Grid updates the - customer email and every tied `EMAIL_OTP` credential through the endpoint's - signed-retry flow. When `phoneNumber` changes for a customer with tied - Embedded Wallet internal accounts, Grid updates the customer phone number and - every tied `SMS_OTP` credential through the same signed-retry flow. Update - `email` and `phoneNumber` in separate PATCH calls. + Request body for `PATCH /customers/{customerId}`. + + + `email` and `phoneNumber` are deprecated here in favor of `POST + /customers/{customerId}/contact-changes`; they remain functional during + migration. When `email` changes for a customer with tied Embedded Wallet + internal accounts, Grid updates the customer email and every tied + `EMAIL_OTP` credential through the endpoint's signed-retry flow. When + `phoneNumber` changes for a customer with tied Embedded Wallet internal + accounts, Grid updates the customer phone number and every tied `SMS_OTP` + credential through the same signed-retry flow. Update `email` and + `phoneNumber` in separate PATCH calls. type: object required: - customerType @@ -30,7 +35,9 @@ properties: type: string format: email description: >- - Email address for the customer. For customers with tied Embedded Wallet + Email address for the customer. **Deprecated — use `POST + /customers/{customerId}/contact-changes` with `type: EMAIL`.** Still + functional during migration. For customers with tied Embedded Wallet internal accounts, changing this value also updates every tied `EMAIL_OTP` credential across all tied Embedded Wallets. example: john.doe@example.com @@ -38,10 +45,12 @@ properties: type: string pattern: '^\+[1-9]\d{1,14}$' description: >- - Phone number for the customer in strict E.164 format. For customers with - tied Embedded Wallet internal accounts, changing this value also updates - every tied `SMS_OTP` credential across all tied Embedded Wallets. Send - phone number and email updates as separate PATCH calls. + Phone number for the customer in strict E.164 format. **Deprecated — + use `POST /customers/{customerId}/contact-changes` with `type: PHONE`.** + Still functional during migration. For customers with tied Embedded + Wallet internal accounts, changing this value also updates every tied + `SMS_OTP` credential across all tied Embedded Wallets. Send phone + number and email updates as separate PATCH calls. example: '+14155551234' umaAddress: type: string diff --git a/openapi/paths/customers/customers_{customerId}.yaml b/openapi/paths/customers/customers_{customerId}.yaml index 7f72bd663..77b5d72f3 100644 --- a/openapi/paths/customers/customers_{customerId}.yaml +++ b/openapi/paths/customers/customers_{customerId}.yaml @@ -44,6 +44,14 @@ patch: Update a customer's metadata by their system-generated ID. + **`email` and `phoneNumber` are deprecated on this endpoint.** Use `POST + /customers/{customerId}/contact-changes` instead, which handles both the + signed flow for a contact that is a login credential and the immediate + apply when it is not, and leaves a readable record of the change either + way. Both fields remain functional here during migration; everything + else on this endpoint is unaffected. + + Most customer updates complete synchronously and return `200` with the updated customer. If the request changes `email` for a customer that has one or more tied Embedded Wallet internal accounts with `EMAIL_OTP` diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml index b72813585..d5bc2bf97 100644 --- a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml +++ b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml @@ -6,40 +6,46 @@ parameters: schema: type: string post: - summary: Start a contact change + summary: Create a contact change description: > - Start changing an Embedded Wallet customer's email or phone number. + Change a customer's email or phone number. Every contact change goes + through this endpoint. `PATCH /customers/{customerId}` still accepts + `email` and `phoneNumber` during migration, but those two fields are + deprecated in favor of this endpoint. - The email behind an `EMAIL_OTP` credential and the phone number behind an - `SMS_OTP` credential are what the customer logs in with, so changing - either one is a login-security operation that the customer's own wallet - session has to authorize. This endpoint creates the change as a durable - resource and returns the `payloadToSign` for it; the change takes effect - only after `POST - /customers/{customerId}/contact-changes/{changeId}/submit` carries the - signature. + A contact that is also a login credential — the email behind `EMAIL_OTP`, + the phone number behind `SMS_OTP` — can only be changed with the + customer's own signature, because changing it re-keys what they log in + with. So a change arrives in one of two states: - 1. Call this endpoint with the contact `type` and its new `value`. Grid - resolves every tied Embedded Wallet OTP credential of that type, binds - that set into `payloadToSign`, and returns `201` with the change in - `AWAITING_SIGNATURE`. + - **The customer has a tied Embedded Wallet OTP credential of that + type.** Grid resolves every tied credential, binds that set into + `payloadToSign`, and returns `201` with the change in + `AWAITING_SIGNATURE`. Build an API-key stamp over `payloadToSign` with + the session API keypair of a verified credential on one of the customer's + tied Embedded Wallets, then apply the change with `POST + /customers/{customerId}/contact-changes/{changeId}/submit`. - 2. Use the session API keypair of a verified authentication credential on - one of the customer's tied Embedded Wallets to build an API-key stamp - over `payloadToSign`, then submit it. Grid updates the customer contact - field and every tied matching OTP credential as one logical operation; if - any tied credential cannot be updated, the customer contact field is left - unchanged. + - **The customer has no tied OTP credential of that type.** There is + nothing to re-key and no signature to collect, so Grid applies the change + on create and returns `201` with the change already `APPLIED`, carrying + no `payloadToSign` and no `expiresAt`. Nothing further to call. + + + Branch on the returned `status` rather than on what you believe about the + customer's credentials: a wallet that gained or lost an OTP credential + since you last looked changes which arrival state you get. A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or - abandon with `DELETE`. + abandon with `DELETE`. A change that arrives `APPLIED` is terminal on + arrival, so it never blocks the next one. Unlike the `202` challenge on `PATCH /customers/{customerId}`, the @@ -71,8 +77,11 @@ post: responses: '201': description: >- - Contact change created in `AWAITING_SIGNATURE`, carrying the - `payloadToSign` to stamp and the `expiresAt` deadline to submit by. + Contact change created. `AWAITING_SIGNATURE` when a tied OTP + credential of that type exists, carrying the `payloadToSign` to stamp + and the `expiresAt` deadline to submit by; `APPLIED` when none + exists, in which case the change is already done and carries neither + field. Branch on `status`. content: application/json: schema: @@ -102,14 +111,21 @@ post: expiresAt: '2026-04-08T15:35:00Z' createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:00Z' + appliedOnCreate: + summary: Applied on create (no tied OTP credential of that type) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000022 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' '400': description: >- Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for - `type: PHONE`, and when the customer has no tied Embedded Wallet OTP - credential of the requested type — such a customer has no login - credential to re-key, so update its contact fields with - `PATCH /customers/{customerId}` instead. + `type: PHONE`. content: application/json: schema: From 9100d022c9954660e59ee8b8648ab3deb00d258a Mon Sep 17 00:00:00 2001 From: carsonp6 Date: Fri, 21 Aug 2026 13:29:36 -0700 Subject: [PATCH 4/8] docs(spec): say what submit does for a change that arrived APPLIED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single-door create means a change can reach APPLIED without ever having a payloadToSign, which the submit contract didn't cover: the 200 clause said an APPLIED change returns its own body, while the 401 clause said a stamp that doesn't match payloadToSign is unauthorized. Both read as applying. Resolve it toward the blind retry being safe — an applied change has nothing to verify against and nothing to mutate, so the stamp isn't checked and the call returns the applied change either way. Co-Authored-By: Claude Fable 5 --- mintlify/openapi.yaml | 2 +- openapi.yaml | 2 +- ...mers_{customerId}_contact-changes_{changeId}_submit.yaml | 6 +++++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index e72eedce8..867442c99 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -1564,7 +1564,7 @@ paths: example: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9 responses: '200': - description: 'Either the applied contact change or a `WalletOperationProcessing` body with `status: "PROCESSING"`. `200` alone does not distinguish them — check the body: a `PROCESSING` status means the wallet-provider activity is still in flight, so re-send this same stamped request until the change comes back `APPLIED`. A change that is already `APPLIED` returns that same body on a re-send.' + description: 'Either the applied contact change or a `WalletOperationProcessing` body with `status: "PROCESSING"`. `200` alone does not distinguish them — check the body: a `PROCESSING` status means the wallet-provider activity is still in flight, so re-send this same stamped request until the change comes back `APPLIED`. A change that is already `APPLIED` returns that same body, so a blind retry is always safe — including for a change that arrived `APPLIED` on create and so never had a `payloadToSign`. There is nothing to verify against and nothing to mutate on an applied change, so the stamp is not checked in that case.' content: application/json: schema: diff --git a/openapi.yaml b/openapi.yaml index e72eedce8..867442c99 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1564,7 +1564,7 @@ paths: example: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9 responses: '200': - description: 'Either the applied contact change or a `WalletOperationProcessing` body with `status: "PROCESSING"`. `200` alone does not distinguish them — check the body: a `PROCESSING` status means the wallet-provider activity is still in flight, so re-send this same stamped request until the change comes back `APPLIED`. A change that is already `APPLIED` returns that same body on a re-send.' + description: 'Either the applied contact change or a `WalletOperationProcessing` body with `status: "PROCESSING"`. `200` alone does not distinguish them — check the body: a `PROCESSING` status means the wallet-provider activity is still in flight, so re-send this same stamped request until the change comes back `APPLIED`. A change that is already `APPLIED` returns that same body, so a blind retry is always safe — including for a change that arrived `APPLIED` on create and so never had a `payloadToSign`. There is nothing to verify against and nothing to mutate on an applied change, so the stamp is not checked in that case.' content: application/json: schema: diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml index 5b48ff04c..19e74a548 100644 --- a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml +++ b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml @@ -66,7 +66,11 @@ post: them — check the body: a `PROCESSING` status means the wallet-provider activity is still in flight, so re-send this same stamped request until the change comes back `APPLIED`. A change that is already - `APPLIED` returns that same body on a re-send. + `APPLIED` returns that same body, so a blind retry is always safe — + including for a change that arrived `APPLIED` on create and so never + had a `payloadToSign`. There is nothing to verify against and nothing + to mutate on an applied change, so the stamp is not checked in that + case. content: application/json: schema: From 2a92c73478da6b796e65d869630615db994e64fa Mon Sep 17 00:00:00 2001 From: carsonp6 Date: Fri, 21 Aug 2026 14:29:15 -0700 Subject: [PATCH 5/8] feat: record a failed instant-apply as a FAILED contact change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A create whose inline apply fails now returns 201 with the change recorded as FAILED plus a failureReason, instead of failing the request and leaving no trace. Every attempt on a customer is visible in the list, successful or not — which is most of the point of routing contact updates through a resource. So create has three arrival states, spelled out on the endpoint description, the 201 description, ContactChange, ContactChangeStatus, and the auth guide: AWAITING_SIGNATURE with a tied OTP credential, and APPLIED or FAILED without one. Status code follows the repo's existing split rather than reviewer instinct alone: POST /agents/{agentId}/actions/{actionId}/approve and POST /agents/me/quotes/{quoteId}/execute both return 2xx carrying an AgentAction that may be FAILED, and reserve 4xx for a request that can't be accepted. AgentActionStatus draws the same line in its own vocabulary — REJECTED for a request turned down, FAILED for one that ran and didn't work. 201 here, since the resource is created. Keeps the uniqueness-collision and already-pending cases as a plain 409 with no resource, and says why at the 409: a rejected request leaves nothing to record because nothing was attempted. A 201 means the change was recorded, not that it worked. Co-Authored-By: Claude Fable 5 --- mintlify/openapi.yaml | 36 ++++++--- .../global-accounts/authentication.mdx | 3 + openapi.yaml | 36 ++++++--- .../schemas/customers/ContactChange.yaml | 14 ++-- .../customers/ContactChangeStatus.yaml | 11 +-- ...ustomers_{customerId}_contact-changes.yaml | 74 +++++++++++++------ 6 files changed, 124 insertions(+), 50 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 867442c99..d22a641aa 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -1193,15 +1193,19 @@ paths: description: | Change a customer's email or phone number. Every contact change goes through this endpoint. `PATCH /customers/{customerId}` still accepts `email` and `phoneNumber` during migration, but those two fields are deprecated in favor of this endpoint. - A contact that is also a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — can only be changed with the customer's own signature, because changing it re-keys what they log in with. So a change arrives in one of two states: + A contact that is also a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — can only be changed with the customer's own signature, because changing it re-keys what they log in with. So a change arrives in one of three states: - - **The customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and returns `201` with the change in `AWAITING_SIGNATURE`. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. + - **`AWAITING_SIGNATURE` — the customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and returns `201` with the change awaiting its signature. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. - - **The customer has no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with the change already `APPLIED`, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. + - **`APPLIED` — no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with it already done, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. + + - **`FAILED` — no tied OTP credential of that type, and applying it didn't work.** Still `201`: the attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` says what went wrong, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. - A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. A change that arrives `APPLIED` is terminal on arrival, so it never blocks the next one. + A `201` therefore means "the change was recorded", not "the change worked". A `4xx` means the request itself was refused and no contact change exists — nothing was attempted, so there is nothing to record. + + A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. A change that arrives terminal — `APPLIED` or `FAILED` — never blocks the next one. Unlike the `202` challenge on `PATCH /customers/{customerId}`, the challenge here survives the response: a client that lost the create response can re-read `payloadToSign` from the change resource instead of starting over. operationId: createContactChange @@ -1228,7 +1232,7 @@ paths: value: '+14155559876' responses: '201': - description: Contact change created. `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by; `APPLIED` when none exists, in which case the change is already done and carries neither field. Branch on `status`. + description: 'Contact change recorded. `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. When none exists Grid applies the change inline and it arrives terminal, carrying neither field: `APPLIED` on success, or `FAILED` with a `failureReason` if applying it didn''t work, which is still recorded so the attempt is visible in the customer''s contact-change history. Branch on `status`.' content: application/json: schema: @@ -1268,6 +1272,17 @@ paths: status: APPLIED createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:00Z' + failedOnCreate: + summary: Recorded but not applied (inline apply failed) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000023 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: FAILED + failureReason: The payment provider rejected the phone number update. + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:02Z' '400': description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`.' content: @@ -1287,7 +1302,10 @@ paths: schema: $ref: '#/components/schemas/Error404' '409': - description: Conflict. Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. + description: |- + Conflict, and no contact change is created — the request is refused before anything is attempted, so there is no failed attempt to record. This is the boundary against a `201 FAILED`: a rejected request leaves no resource, a failed attempt leaves one. + + Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. content: application/json: schema: @@ -14390,8 +14408,8 @@ components: - CANCELLED description: |- Lifecycle state of a contact change. - | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created against a tied OTP credential; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. Also the arrival state when the customer has no tied OTP credential of that type: nothing needs re-keying, so no signature is collected and Grid applies the change on create. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | - A change arrives either `AWAITING_SIGNATURE` or `APPLIED`, depending on whether a tied OTP credential of that type exists. Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. + | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created against a tied OTP credential; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. Also the arrival state when the customer has no tied OTP credential of that type: nothing needs re-keying, so no signature is collected and Grid applies the change on create. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. Also an arrival state: a change with no tied OTP credential is applied on create, and a create whose inline apply fails is still recorded here rather than vanishing into an error. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | + A change arrives `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, and terminal — `APPLIED` or `FAILED` — when none does, since Grid applies those inline on create. Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. example: AWAITING_SIGNATURE ContactChangeType: title: Contact Change Type @@ -14415,7 +14433,7 @@ components: When the contact is a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — changing it is a login-security operation, not a profile edit, so it is authorized by the customer's own wallet session rather than by platform credentials alone. The change then lands as a durable resource the platform can read, retry, and cancel, instead of a challenge that exists only inside one request/response pair. - A change therefore arrives in one of two states. With a tied OTP credential of that type, it arrives `AWAITING_SIGNATURE` carrying `payloadToSign` and `expiresAt`, and applies on the signed submit. With no tied credential of that type there is nothing to re-key and no signature to collect, so Grid applies it on create and it arrives `APPLIED` with no `payloadToSign` and no `expiresAt`. + A change therefore arrives in one of three states. With a tied OTP credential of that type, it arrives `AWAITING_SIGNATURE` carrying `payloadToSign` and `expiresAt`, and applies on the signed submit. With no tied credential of that type there is nothing to re-key and no signature to collect, so Grid applies it on create and it arrives terminal with neither field — `APPLIED` on success, `FAILED` with a `failureReason` if applying it didn't work. A failed attempt is recorded rather than discarded, so every attempt on a customer is visible in the list, successful or not. Grid applies the customer contact field and every tied matching OTP credential as one logical operation. If any tied credential cannot be updated, the customer contact field is left unchanged and the change goes to `FAILED`. required: diff --git a/mintlify/snippets/global-accounts/authentication.mdx b/mintlify/snippets/global-accounts/authentication.mdx index 3ac268e88..46fd4aaf8 100644 --- a/mintlify/snippets/global-accounts/authentication.mdx +++ b/mintlify/snippets/global-accounts/authentication.mdx @@ -819,9 +819,12 @@ It's a single endpoint because whether a signature is needed isn't something you |---|---|---| | `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. | | `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. | +| `FAILED` | Same path, but applying it didn't work. `failureReason` says why; the contact is unchanged. | Show the reason and 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. +A `201` means the change was **recorded**, not that it worked — a failed attempt still gets a resource, so it shows up in the customer's history next to the successful ones. A `4xx` is different: the request was refused before anything was attempted, so no change exists at all. That's 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. diff --git a/openapi.yaml b/openapi.yaml index 867442c99..d22a641aa 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1193,15 +1193,19 @@ paths: description: | Change a customer's email or phone number. Every contact change goes through this endpoint. `PATCH /customers/{customerId}` still accepts `email` and `phoneNumber` during migration, but those two fields are deprecated in favor of this endpoint. - A contact that is also a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — can only be changed with the customer's own signature, because changing it re-keys what they log in with. So a change arrives in one of two states: + A contact that is also a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — can only be changed with the customer's own signature, because changing it re-keys what they log in with. So a change arrives in one of three states: - - **The customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and returns `201` with the change in `AWAITING_SIGNATURE`. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. + - **`AWAITING_SIGNATURE` — the customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and returns `201` with the change awaiting its signature. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. - - **The customer has no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with the change already `APPLIED`, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. + - **`APPLIED` — no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with it already done, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. + + - **`FAILED` — no tied OTP credential of that type, and applying it didn't work.** Still `201`: the attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` says what went wrong, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. - A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. A change that arrives `APPLIED` is terminal on arrival, so it never blocks the next one. + A `201` therefore means "the change was recorded", not "the change worked". A `4xx` means the request itself was refused and no contact change exists — nothing was attempted, so there is nothing to record. + + A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. A change that arrives terminal — `APPLIED` or `FAILED` — never blocks the next one. Unlike the `202` challenge on `PATCH /customers/{customerId}`, the challenge here survives the response: a client that lost the create response can re-read `payloadToSign` from the change resource instead of starting over. operationId: createContactChange @@ -1228,7 +1232,7 @@ paths: value: '+14155559876' responses: '201': - description: Contact change created. `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by; `APPLIED` when none exists, in which case the change is already done and carries neither field. Branch on `status`. + description: 'Contact change recorded. `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. When none exists Grid applies the change inline and it arrives terminal, carrying neither field: `APPLIED` on success, or `FAILED` with a `failureReason` if applying it didn''t work, which is still recorded so the attempt is visible in the customer''s contact-change history. Branch on `status`.' content: application/json: schema: @@ -1268,6 +1272,17 @@ paths: status: APPLIED createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:00Z' + failedOnCreate: + summary: Recorded but not applied (inline apply failed) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000023 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: FAILED + failureReason: The payment provider rejected the phone number update. + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:02Z' '400': description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`.' content: @@ -1287,7 +1302,10 @@ paths: schema: $ref: '#/components/schemas/Error404' '409': - description: Conflict. Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. + description: |- + Conflict, and no contact change is created — the request is refused before anything is attempted, so there is no failed attempt to record. This is the boundary against a `201 FAILED`: a rejected request leaves no resource, a failed attempt leaves one. + + Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. content: application/json: schema: @@ -14390,8 +14408,8 @@ components: - CANCELLED description: |- Lifecycle state of a contact change. - | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created against a tied OTP credential; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. Also the arrival state when the customer has no tied OTP credential of that type: nothing needs re-keying, so no signature is collected and Grid applies the change on create. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | - A change arrives either `AWAITING_SIGNATURE` or `APPLIED`, depending on whether a tied OTP credential of that type exists. Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. + | Value | Terminal | Meaning | |-------|----------|---------| | `AWAITING_SIGNATURE` | no | Created against a tied OTP credential; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. Also the arrival state when the customer has no tied OTP credential of that type: nothing needs re-keying, so no signature is collected and Grid applies the change on create. | | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. Also an arrival state: a change with no tied OTP credential is applied on create, and a create whose inline apply fails is still recorded here rather than vanishing into an error. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | + A change arrives `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, and terminal — `APPLIED` or `FAILED` — when none does, since Grid applies those inline on create. Only `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never leaves a terminal state. example: AWAITING_SIGNATURE ContactChangeType: title: Contact Change Type @@ -14415,7 +14433,7 @@ components: When the contact is a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — changing it is a login-security operation, not a profile edit, so it is authorized by the customer's own wallet session rather than by platform credentials alone. The change then lands as a durable resource the platform can read, retry, and cancel, instead of a challenge that exists only inside one request/response pair. - A change therefore arrives in one of two states. With a tied OTP credential of that type, it arrives `AWAITING_SIGNATURE` carrying `payloadToSign` and `expiresAt`, and applies on the signed submit. With no tied credential of that type there is nothing to re-key and no signature to collect, so Grid applies it on create and it arrives `APPLIED` with no `payloadToSign` and no `expiresAt`. + A change therefore arrives in one of three states. With a tied OTP credential of that type, it arrives `AWAITING_SIGNATURE` carrying `payloadToSign` and `expiresAt`, and applies on the signed submit. With no tied credential of that type there is nothing to re-key and no signature to collect, so Grid applies it on create and it arrives terminal with neither field — `APPLIED` on success, `FAILED` with a `failureReason` if applying it didn't work. A failed attempt is recorded rather than discarded, so every attempt on a customer is visible in the list, successful or not. Grid applies the customer contact field and every tied matching OTP credential as one logical operation. If any tied credential cannot be updated, the customer contact field is left unchanged and the change goes to `FAILED`. required: diff --git a/openapi/components/schemas/customers/ContactChange.yaml b/openapi/components/schemas/customers/ContactChange.yaml index fa3f7a919..e90241db0 100644 --- a/openapi/components/schemas/customers/ContactChange.yaml +++ b/openapi/components/schemas/customers/ContactChange.yaml @@ -14,12 +14,14 @@ description: >- challenge that exists only inside one request/response pair. - A change therefore arrives in one of two states. With a tied OTP credential - of that type, it arrives `AWAITING_SIGNATURE` carrying `payloadToSign` and - `expiresAt`, and applies on the signed submit. With no tied credential of - that type there is nothing to re-key and no signature to collect, so Grid - applies it on create and it arrives `APPLIED` with no `payloadToSign` and - no `expiresAt`. + A change therefore arrives in one of three states. With a tied OTP + credential of that type, it arrives `AWAITING_SIGNATURE` carrying + `payloadToSign` and `expiresAt`, and applies on the signed submit. With no + tied credential of that type there is nothing to re-key and no signature to + collect, so Grid applies it on create and it arrives terminal with neither + field — `APPLIED` on success, `FAILED` with a `failureReason` if applying it + didn't work. A failed attempt is recorded rather than discarded, so every + attempt on a customer is visible in the list, successful or not. Grid applies the customer contact field and every tied matching OTP diff --git a/openapi/components/schemas/customers/ContactChangeStatus.yaml b/openapi/components/schemas/customers/ContactChangeStatus.yaml index a1c878cf4..4b77ac775 100644 --- a/openapi/components/schemas/customers/ContactChangeStatus.yaml +++ b/openapi/components/schemas/customers/ContactChangeStatus.yaml @@ -15,12 +15,13 @@ description: >- | `AWAITING_SIGNATURE` | no | Created against a tied OTP credential; waiting for the signed submit. `payloadToSign` and `expiresAt` are populated, and the change can still be cancelled. | | `PROCESSING` | no | Submitted, and the wallet-provider activity is still in flight. Re-send the identical signed submit until it settles; Grid also reconciles it on its own. | | `APPLIED` | **yes** | The customer contact field and every tied matching OTP credential were updated. Also the arrival state when the customer has no tied OTP credential of that type: nothing needs re-keying, so no signature is collected and Grid applies the change on create. | - | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. | + | `FAILED` | **yes** | The change could not be applied; see `failureReason`. The customer contact field is unchanged. Start a new contact change. Also an arrival state: a change with no tied OTP credential is applied on create, and a create whose inline apply fails is still recorded here rather than vanishing into an error. | | `EXPIRED` | **yes** | `expiresAt` passed with no signed submit. Start a new contact change. | | `CANCELLED` | **yes** | Cancelled via `DELETE` while still `AWAITING_SIGNATURE`. | - A change arrives either `AWAITING_SIGNATURE` or `APPLIED`, depending on - whether a tied OTP credential of that type exists. Only - `AWAITING_SIGNATURE` accepts a submit, and only `AWAITING_SIGNATURE` - accepts a cancel. A change never leaves a terminal state. + A change arrives `AWAITING_SIGNATURE` when a tied OTP credential of that + type exists, and terminal — `APPLIED` or `FAILED` — when none does, since + Grid applies those inline on create. Only `AWAITING_SIGNATURE` accepts a + submit, and only `AWAITING_SIGNATURE` accepts a cancel. A change never + leaves a terminal state. example: AWAITING_SIGNATURE diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml index d5bc2bf97..952418f35 100644 --- a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml +++ b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml @@ -17,22 +17,30 @@ post: A contact that is also a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — can only be changed with the customer's own signature, because changing it re-keys what they log in - with. So a change arrives in one of two states: + with. So a change arrives in one of three states: - - **The customer has a tied Embedded Wallet OTP credential of that - type.** Grid resolves every tied credential, binds that set into - `payloadToSign`, and returns `201` with the change in - `AWAITING_SIGNATURE`. Build an API-key stamp over `payloadToSign` with - the session API keypair of a verified credential on one of the customer's + - **`AWAITING_SIGNATURE` — the customer has a tied Embedded Wallet OTP + credential of that type.** Grid resolves every tied credential, binds + that set into `payloadToSign`, and returns `201` with the change awaiting + its signature. Build an API-key stamp over `payloadToSign` with the + session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. - - **The customer has no tied OTP credential of that type.** There is - nothing to re-key and no signature to collect, so Grid applies the change - on create and returns `201` with the change already `APPLIED`, carrying - no `payloadToSign` and no `expiresAt`. Nothing further to call. + - **`APPLIED` — no tied OTP credential of that type.** There is nothing + to re-key and no signature to collect, so Grid applies the change on + create and returns `201` with it already done, carrying no + `payloadToSign` and no `expiresAt`. Nothing further to call. + + + - **`FAILED` — no tied OTP credential of that type, and applying it + didn't work.** Still `201`: the attempt happened and is recorded, so it + shows up in `GET /customers/{customerId}/contact-changes` alongside the + successful ones. `failureReason` says what went wrong, the customer + contact field is unchanged, and the fix is a new contact change rather + than anything on this one. Branch on the returned `status` rather than on what you believe about the @@ -40,12 +48,17 @@ post: since you last looked changes which arrival state you get. + A `201` therefore means "the change was recorded", not "the change + worked". A `4xx` means the request itself was refused and no contact + change exists — nothing was attempted, so there is nothing to record. + + A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or - abandon with `DELETE`. A change that arrives `APPLIED` is terminal on - arrival, so it never blocks the next one. + abandon with `DELETE`. A change that arrives terminal — `APPLIED` or + `FAILED` — never blocks the next one. Unlike the `202` challenge on `PATCH /customers/{customerId}`, the @@ -77,11 +90,13 @@ post: responses: '201': description: >- - Contact change created. `AWAITING_SIGNATURE` when a tied OTP + Contact change recorded. `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, carrying the `payloadToSign` to stamp - and the `expiresAt` deadline to submit by; `APPLIED` when none - exists, in which case the change is already done and carries neither - field. Branch on `status`. + and the `expiresAt` deadline to submit by. When none exists Grid + applies the change inline and it arrives terminal, carrying neither + field: `APPLIED` on success, or `FAILED` with a `failureReason` if + applying it didn't work, which is still recorded so the attempt is + visible in the customer's contact-change history. Branch on `status`. content: application/json: schema: @@ -121,6 +136,17 @@ post: status: APPLIED createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:00Z' + failedOnCreate: + summary: Recorded but not applied (inline apply failed) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000023 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: FAILED + failureReason: The payment provider rejected the phone number update. + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:02Z' '400': description: >- Bad request. Returned with `INVALID_INPUT` when `value` is not a valid @@ -144,11 +170,17 @@ post: $ref: ../../components/schemas/errors/Error404.yaml '409': description: >- - Conflict. Returned with `CONTACT_CHANGE_PENDING` when the customer - already has an active contact change of this type — `details` - carries `contactChangeId` so the caller can read or cancel it — - with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is - already associated with an `EMAIL_OTP` credential, or with + Conflict, and no contact change is created — the request is refused + before anything is attempted, so there is no failed attempt to + record. This is the boundary against a `201 FAILED`: a rejected + request leaves no resource, a failed attempt leaves one. + + + Returned with `CONTACT_CHANGE_PENDING` when the customer already has + an active contact change of this type — `details` carries + `contactChangeId` so the caller can read or cancel it — with + `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already + associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. From 65ff1990daab5f3e351000ac01d8373b715946f0 Mon Sep 17 00:00:00 2001 From: carsonp6 Date: Fri, 21 Aug 2026 16:40:50 -0700 Subject: [PATCH 6/8] fix(spec): make failureReason a code, and block the credential-add race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects found by cross-checking against the pending-credentials design. failureReason was documented as human prose with a sentence for an example, which is not what that field name means in this API: StablecoinOperation calls it a "stable internal failure code" with PROVIDER_TRANSFER_FAILED, OutgoingTransaction $refs a failure-reason enum, and error-handling.mdx documents it as the machine token integrators branch on. So the field keeps its name and becomes what the name already promises — a stable code, in Grid's own vocabulary rather than a pass-through of the provider's, which also gives a FAILED operation webhook a code to carry without deriving one from prose. Adding an EMAIL_OTP or SMS_OTP credential and changing the contact behind one both stamp the same underlying user attribute — visible right in the spec, where POST /auth/credentials and a contact change publish byte-identical ACTIVITY_TYPE_UPDATE_USER_EMAIL payloads. Two of those in flight at once means whichever settles last silently wins. Create and submit now both 409 with AUTH_CREDENTIAL_OPERATION_IN_FLIGHT while a matching credential operation is pending; on submit the change stays AWAITING_SIGNATURE so the same stamp works once it settles. The mirror guard belongs on the credential-add side. Co-Authored-By: Claude Fable 5 --- mintlify/openapi.yaml | 19 +++++++++++++------ .../global-accounts/authentication.mdx | 2 +- openapi.yaml | 19 +++++++++++++------ .../schemas/customers/ContactChange.yaml | 8 ++++++-- .../components/schemas/errors/Error409.yaml | 2 ++ ...ustomers_{customerId}_contact-changes.yaml | 18 ++++++++++++++---- ...ustomerId}_contact-changes_{changeId}.yaml | 2 +- ...Id}_contact-changes_{changeId}_submit.yaml | 9 +++++++++ 8 files changed, 59 insertions(+), 20 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index d22a641aa..ff97492bc 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -1199,7 +1199,7 @@ paths: - **`APPLIED` — no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with it already done, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. - - **`FAILED` — no tied OTP credential of that type, and applying it didn't work.** Still `201`: the attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` says what went wrong, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. + - **`FAILED` — no tied OTP credential of that type, and applying it didn't work.** Still `201`: the attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` carries a stable code you can branch on, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. @@ -1280,7 +1280,7 @@ paths: type: PHONE value: '+14155559876' status: FAILED - failureReason: The payment provider rejected the phone number update. + failureReason: CONTACT_UPDATE_REJECTED createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:02Z' '400': @@ -1306,6 +1306,8 @@ paths: Conflict, and no contact change is created — the request is refused before anything is attempted, so there is no failed attempt to record. This is the boundary against a `201 FAILED`: a rejected request leaves no resource, a failed attempt leaves one. Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. + + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when an `EMAIL_OTP` or `SMS_OTP` credential operation of the matching type is still in flight on one of the customer's tied Embedded Wallets. Adding an OTP credential and changing the contact behind one both rewrite the same underlying user attribute, so letting the two run concurrently would leave whichever finished last silently winning. Wait for the credential operation to reach a terminal state, then create the contact change. content: application/json: schema: @@ -1469,7 +1471,7 @@ paths: type: PHONE value: '+14155559876' status: FAILED - failureReason: One or more tied SMS_OTP credentials could not be updated. + failureReason: SMS_OTP_CREDENTIAL_SYNC_FAILED createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:31:44Z' '401': @@ -1616,7 +1618,10 @@ paths: schema: $ref: '#/components/schemas/Error404' '409': - description: Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is `EXPIRED`, `CANCELLED`, or `FAILED` — all terminal, so start a new contact change rather than re-signing this one. A change that is `PROCESSING` or `APPLIED` is not a conflict and returns `200`. Also returned with `EMAIL_OTP_CREDENTIAL_SET_CHANGED` or `SMS_OTP_CREDENTIAL_SET_CHANGED` when the set of tied OTP credentials bound into `payloadToSign` at create time no longer matches the account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by another credential between create and submit. + description: |- + Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is `EXPIRED`, `CANCELLED`, or `FAILED` — all terminal, so start a new contact change rather than re-signing this one. A change that is `PROCESSING` or `APPLIED` is not a conflict and returns `200`. Also returned with `EMAIL_OTP_CREDENTIAL_SET_CHANGED` or `SMS_OTP_CREDENTIAL_SET_CHANGED` when the set of tied OTP credentials bound into `payloadToSign` at create time no longer matches the account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by another credential between create and submit. + + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when a matching OTP credential operation started after this change was created and is still in flight — the two rewrite the same underlying user attribute, so this change waits rather than racing it. The change stays `AWAITING_SIGNATURE` and the same stamp can be submitted again once the credential operation settles, provided `expiresAt` has not passed. content: application/json: schema: @@ -14114,6 +14119,7 @@ components: | SMS_OTP_CREDENTIAL_SET_CHANGED | Tied SMS_OTP credential set changed after the signed-retry challenge was issued | | CONTACT_CHANGE_PENDING | The customer already has an active contact change of this type. `details.contactChangeId` names it; submit or cancel it before starting another | | CONTACT_CHANGE_NOT_AWAITING_SIGNATURE | The contact change is not `AWAITING_SIGNATURE`, so it can no longer be signed or cancelled | + | AUTH_CREDENTIAL_OPERATION_IN_FLIGHT | An authentication-credential operation on the same internal account is still in flight and targets the same underlying attribute as this request. Wait for it to reach a terminal state, then retry | | PASSKEY_ALREADY_ENROLLED | The customer already has an enrolled passkey factor; only one passkey per customer is supported. Delete the existing one before enrolling another | | SCA_SESSION_REQUIRED | The customer's Strong Customer Authentication login session is missing or expired. Re-authenticate the customer, then retry the request. Distinct from a `401`, which means the platform's own API credentials were rejected | | BENEFICIARY_TRUSTED | The external account is currently a trusted beneficiary, so it cannot be deleted. Untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete | @@ -14131,6 +14137,7 @@ components: - SMS_OTP_CREDENTIAL_SET_CHANGED - CONTACT_CHANGE_PENDING - CONTACT_CHANGE_NOT_AWAITING_SIGNATURE + - AUTH_CREDENTIAL_OPERATION_IN_FLIGHT - PASSKEY_ALREADY_ENROLLED - SCA_SESSION_REQUIRED - BENEFICIARY_TRUSTED @@ -14474,8 +14481,8 @@ components: example: '2026-04-08T15:35:00Z' failureReason: type: string - description: Human-readable explanation of why the change could not be applied. Present only when `status` is `FAILED`. - example: One or more tied SMS_OTP credentials could not be updated. + description: Stable failure code for a `FAILED` change, safe to branch on — an integrator needs to tell "the new value was rejected" from "a tied credential could not be updated" without matching on prose. Grid's own vocabulary, not a pass-through of whatever the underlying provider called it, and the same code a `FAILED` operation webhook would carry. Present only when `status` is `FAILED`. + example: SMS_OTP_CREDENTIAL_SYNC_FAILED createdAt: type: string format: date-time diff --git a/mintlify/snippets/global-accounts/authentication.mdx b/mintlify/snippets/global-accounts/authentication.mdx index 46fd4aaf8..add26368c 100644 --- a/mintlify/snippets/global-accounts/authentication.mdx +++ b/mintlify/snippets/global-accounts/authentication.mdx @@ -819,7 +819,7 @@ It's a single endpoint because whether a signature is needed isn't something you |---|---|---| | `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. | | `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. | -| `FAILED` | Same path, but applying it didn't work. `failureReason` says why; the contact is unchanged. | Show the reason and create a new change. Don't retry this one — it's terminal. | +| `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. diff --git a/openapi.yaml b/openapi.yaml index d22a641aa..ff97492bc 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1199,7 +1199,7 @@ paths: - **`APPLIED` — no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with it already done, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. - - **`FAILED` — no tied OTP credential of that type, and applying it didn't work.** Still `201`: the attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` says what went wrong, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. + - **`FAILED` — no tied OTP credential of that type, and applying it didn't work.** Still `201`: the attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` carries a stable code you can branch on, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. @@ -1280,7 +1280,7 @@ paths: type: PHONE value: '+14155559876' status: FAILED - failureReason: The payment provider rejected the phone number update. + failureReason: CONTACT_UPDATE_REJECTED createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:02Z' '400': @@ -1306,6 +1306,8 @@ paths: Conflict, and no contact change is created — the request is refused before anything is attempted, so there is no failed attempt to record. This is the boundary against a `201 FAILED`: a rejected request leaves no resource, a failed attempt leaves one. Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. + + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when an `EMAIL_OTP` or `SMS_OTP` credential operation of the matching type is still in flight on one of the customer's tied Embedded Wallets. Adding an OTP credential and changing the contact behind one both rewrite the same underlying user attribute, so letting the two run concurrently would leave whichever finished last silently winning. Wait for the credential operation to reach a terminal state, then create the contact change. content: application/json: schema: @@ -1469,7 +1471,7 @@ paths: type: PHONE value: '+14155559876' status: FAILED - failureReason: One or more tied SMS_OTP credentials could not be updated. + failureReason: SMS_OTP_CREDENTIAL_SYNC_FAILED createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:31:44Z' '401': @@ -1616,7 +1618,10 @@ paths: schema: $ref: '#/components/schemas/Error404' '409': - description: Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is `EXPIRED`, `CANCELLED`, or `FAILED` — all terminal, so start a new contact change rather than re-signing this one. A change that is `PROCESSING` or `APPLIED` is not a conflict and returns `200`. Also returned with `EMAIL_OTP_CREDENTIAL_SET_CHANGED` or `SMS_OTP_CREDENTIAL_SET_CHANGED` when the set of tied OTP credentials bound into `payloadToSign` at create time no longer matches the account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by another credential between create and submit. + description: |- + Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is `EXPIRED`, `CANCELLED`, or `FAILED` — all terminal, so start a new contact change rather than re-signing this one. A change that is `PROCESSING` or `APPLIED` is not a conflict and returns `200`. Also returned with `EMAIL_OTP_CREDENTIAL_SET_CHANGED` or `SMS_OTP_CREDENTIAL_SET_CHANGED` when the set of tied OTP credentials bound into `payloadToSign` at create time no longer matches the account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by another credential between create and submit. + + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when a matching OTP credential operation started after this change was created and is still in flight — the two rewrite the same underlying user attribute, so this change waits rather than racing it. The change stays `AWAITING_SIGNATURE` and the same stamp can be submitted again once the credential operation settles, provided `expiresAt` has not passed. content: application/json: schema: @@ -14114,6 +14119,7 @@ components: | SMS_OTP_CREDENTIAL_SET_CHANGED | Tied SMS_OTP credential set changed after the signed-retry challenge was issued | | CONTACT_CHANGE_PENDING | The customer already has an active contact change of this type. `details.contactChangeId` names it; submit or cancel it before starting another | | CONTACT_CHANGE_NOT_AWAITING_SIGNATURE | The contact change is not `AWAITING_SIGNATURE`, so it can no longer be signed or cancelled | + | AUTH_CREDENTIAL_OPERATION_IN_FLIGHT | An authentication-credential operation on the same internal account is still in flight and targets the same underlying attribute as this request. Wait for it to reach a terminal state, then retry | | PASSKEY_ALREADY_ENROLLED | The customer already has an enrolled passkey factor; only one passkey per customer is supported. Delete the existing one before enrolling another | | SCA_SESSION_REQUIRED | The customer's Strong Customer Authentication login session is missing or expired. Re-authenticate the customer, then retry the request. Distinct from a `401`, which means the platform's own API credentials were rejected | | BENEFICIARY_TRUSTED | The external account is currently a trusted beneficiary, so it cannot be deleted. Untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete | @@ -14131,6 +14137,7 @@ components: - SMS_OTP_CREDENTIAL_SET_CHANGED - CONTACT_CHANGE_PENDING - CONTACT_CHANGE_NOT_AWAITING_SIGNATURE + - AUTH_CREDENTIAL_OPERATION_IN_FLIGHT - PASSKEY_ALREADY_ENROLLED - SCA_SESSION_REQUIRED - BENEFICIARY_TRUSTED @@ -14474,8 +14481,8 @@ components: example: '2026-04-08T15:35:00Z' failureReason: type: string - description: Human-readable explanation of why the change could not be applied. Present only when `status` is `FAILED`. - example: One or more tied SMS_OTP credentials could not be updated. + description: Stable failure code for a `FAILED` change, safe to branch on — an integrator needs to tell "the new value was rejected" from "a tied credential could not be updated" without matching on prose. Grid's own vocabulary, not a pass-through of whatever the underlying provider called it, and the same code a `FAILED` operation webhook would carry. Present only when `status` is `FAILED`. + example: SMS_OTP_CREDENTIAL_SYNC_FAILED createdAt: type: string format: date-time diff --git a/openapi/components/schemas/customers/ContactChange.yaml b/openapi/components/schemas/customers/ContactChange.yaml index e90241db0..e7bb4062d 100644 --- a/openapi/components/schemas/customers/ContactChange.yaml +++ b/openapi/components/schemas/customers/ContactChange.yaml @@ -82,9 +82,13 @@ properties: failureReason: type: string description: >- - Human-readable explanation of why the change could not be applied. + Stable failure code for a `FAILED` change, safe to branch on — an + integrator needs to tell "the new value was rejected" from "a tied + credential could not be updated" without matching on prose. Grid's own + vocabulary, not a pass-through of whatever the underlying provider + called it, and the same code a `FAILED` operation webhook would carry. Present only when `status` is `FAILED`. - example: One or more tied SMS_OTP credentials could not be updated. + example: SMS_OTP_CREDENTIAL_SYNC_FAILED createdAt: type: string format: date-time diff --git a/openapi/components/schemas/errors/Error409.yaml b/openapi/components/schemas/errors/Error409.yaml index def189714..7f5dbca2b 100644 --- a/openapi/components/schemas/errors/Error409.yaml +++ b/openapi/components/schemas/errors/Error409.yaml @@ -23,6 +23,7 @@ properties: | SMS_OTP_CREDENTIAL_SET_CHANGED | Tied SMS_OTP credential set changed after the signed-retry challenge was issued | | CONTACT_CHANGE_PENDING | The customer already has an active contact change of this type. `details.contactChangeId` names it; submit or cancel it before starting another | | CONTACT_CHANGE_NOT_AWAITING_SIGNATURE | The contact change is not `AWAITING_SIGNATURE`, so it can no longer be signed or cancelled | + | AUTH_CREDENTIAL_OPERATION_IN_FLIGHT | An authentication-credential operation on the same internal account is still in flight and targets the same underlying attribute as this request. Wait for it to reach a terminal state, then retry | | PASSKEY_ALREADY_ENROLLED | The customer already has an enrolled passkey factor; only one passkey per customer is supported. Delete the existing one before enrolling another | | SCA_SESSION_REQUIRED | The customer's Strong Customer Authentication login session is missing or expired. Re-authenticate the customer, then retry the request. Distinct from a `401`, which means the platform's own API credentials were rejected | | BENEFICIARY_TRUSTED | The external account is currently a trusted beneficiary, so it cannot be deleted. Untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete | @@ -40,6 +41,7 @@ properties: - SMS_OTP_CREDENTIAL_SET_CHANGED - CONTACT_CHANGE_PENDING - CONTACT_CHANGE_NOT_AWAITING_SIGNATURE + - AUTH_CREDENTIAL_OPERATION_IN_FLIGHT - PASSKEY_ALREADY_ENROLLED - SCA_SESSION_REQUIRED - BENEFICIARY_TRUSTED diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml index 952418f35..f29b26f27 100644 --- a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml +++ b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml @@ -38,9 +38,9 @@ post: - **`FAILED` — no tied OTP credential of that type, and applying it didn't work.** Still `201`: the attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the - successful ones. `failureReason` says what went wrong, the customer - contact field is unchanged, and the fix is a new contact change rather - than anything on this one. + successful ones. `failureReason` carries a stable code you can branch on, + the customer contact field is unchanged, and the fix is a new contact + change rather than anything on this one. Branch on the returned `status` rather than on what you believe about the @@ -144,7 +144,7 @@ post: type: PHONE value: '+14155559876' status: FAILED - failureReason: The payment provider rejected the phone number update. + failureReason: CONTACT_UPDATE_REJECTED createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:02Z' '400': @@ -184,6 +184,16 @@ post: `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. + + + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when an + `EMAIL_OTP` or `SMS_OTP` credential operation of the matching type is + still in flight on one of the customer's tied Embedded Wallets. + Adding an OTP credential and changing the contact behind one both + rewrite the same underlying user attribute, so letting the two run + concurrently would leave whichever finished last silently winning. + Wait for the credential operation to reach a terminal state, then + create the contact change. content: application/json: schema: diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}.yaml index d97b9baa4..faa230918 100644 --- a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}.yaml +++ b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}.yaml @@ -56,7 +56,7 @@ get: type: PHONE value: '+14155559876' status: FAILED - failureReason: One or more tied SMS_OTP credentials could not be updated. + failureReason: SMS_OTP_CREDENTIAL_SYNC_FAILED createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:31:44Z' '401': diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml index 19e74a548..b90203b7f 100644 --- a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml +++ b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml @@ -121,6 +121,15 @@ post: account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by another credential between create and submit. + + + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when a + matching OTP credential operation started after this change was + created and is still in flight — the two rewrite the same underlying + user attribute, so this change waits rather than racing it. The + change stays `AWAITING_SIGNATURE` and the same stamp can be submitted + again once the credential operation settles, provided `expiresAt` has + not passed. content: application/json: schema: From 40b1f0ea7b1408b0283f67888ecf34afba136cd5 Mon Sep 17 00:00:00 2001 From: carsonp6 Date: Fri, 21 Aug 2026 16:47:37 -0700 Subject: [PATCH 7/8] fix(spec): pin the credential-operation interlock to submitted operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only a submitted-and-unsettled credential operation blocks a contact change. An issued-but-unsigned credential challenge has not forwarded anything to the provider, so the shared user attribute is untouched and there is nothing to race. Blocking on it would instead let two abandoned challenges, one per side, lock the customer out of both flows until they expired — neither side able to tell "working" from "abandoned". Cross-checked with the pending-credentials design, which holds the mirror guard on the same terms; both sides block only on submitted operations and neither terminally fails the blocked side. Also documents the reverse order for integrators: an applied contact change invalidates an outstanding credential-add challenge, because the payload the client stamped names the old contact. Sequence the two rather than overlapping them. Co-Authored-By: Claude Fable 5 --- mintlify/openapi.yaml | 8 ++++--- .../global-accounts/authentication.mdx | 2 ++ openapi.yaml | 8 ++++--- .../components/schemas/errors/Error409.yaml | 2 +- ...ustomers_{customerId}_contact-changes.yaml | 21 ++++++++++++------- ...Id}_contact-changes_{changeId}_submit.yaml | 8 ++++--- 6 files changed, 32 insertions(+), 17 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index ff97492bc..7be63dbc2 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -1307,7 +1307,9 @@ paths: Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. - Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when an `EMAIL_OTP` or `SMS_OTP` credential operation of the matching type is still in flight on one of the customer's tied Embedded Wallets. Adding an OTP credential and changing the contact behind one both rewrite the same underlying user attribute, so letting the two run concurrently would leave whichever finished last silently winning. Wait for the credential operation to reach a terminal state, then create the contact change. + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when an `EMAIL_OTP` or `SMS_OTP` credential operation of the matching type has been submitted and not yet settled on one of the customer's tied Embedded Wallets. Adding an OTP credential and changing the contact behind one both rewrite the same underlying user attribute, so letting two submitted operations run concurrently would leave whichever finished last silently winning. Wait for the credential operation to reach a terminal state, then create the contact change. + + Only a *submitted* credential operation blocks. A credential whose challenge has been issued but not yet signed has not touched the attribute, so it does not block — otherwise two abandoned challenges, one on each side, would lock the customer out of both flows until they expired. content: application/json: schema: @@ -1621,7 +1623,7 @@ paths: description: |- Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is `EXPIRED`, `CANCELLED`, or `FAILED` — all terminal, so start a new contact change rather than re-signing this one. A change that is `PROCESSING` or `APPLIED` is not a conflict and returns `200`. Also returned with `EMAIL_OTP_CREDENTIAL_SET_CHANGED` or `SMS_OTP_CREDENTIAL_SET_CHANGED` when the set of tied OTP credentials bound into `payloadToSign` at create time no longer matches the account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by another credential between create and submit. - Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when a matching OTP credential operation started after this change was created and is still in flight — the two rewrite the same underlying user attribute, so this change waits rather than racing it. The change stays `AWAITING_SIGNATURE` and the same stamp can be submitted again once the credential operation settles, provided `expiresAt` has not passed. + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when a matching OTP credential operation was submitted after this change was created and has not yet settled — the two rewrite the same underlying user attribute, so this change waits rather than racing it. The change stays `AWAITING_SIGNATURE` and the same stamp can be submitted again once the credential operation settles, provided `expiresAt` has not passed; a transient overlap never costs the customer their signature. As on create, only a submitted credential operation blocks — an unsigned credential challenge has not touched the attribute. content: application/json: schema: @@ -14119,7 +14121,7 @@ components: | SMS_OTP_CREDENTIAL_SET_CHANGED | Tied SMS_OTP credential set changed after the signed-retry challenge was issued | | CONTACT_CHANGE_PENDING | The customer already has an active contact change of this type. `details.contactChangeId` names it; submit or cancel it before starting another | | CONTACT_CHANGE_NOT_AWAITING_SIGNATURE | The contact change is not `AWAITING_SIGNATURE`, so it can no longer be signed or cancelled | - | AUTH_CREDENTIAL_OPERATION_IN_FLIGHT | An authentication-credential operation on the same internal account is still in flight and targets the same underlying attribute as this request. Wait for it to reach a terminal state, then retry | + | AUTH_CREDENTIAL_OPERATION_IN_FLIGHT | An authentication-credential operation on the same internal account has been submitted, has not yet settled, and targets the same underlying attribute as this request. Wait for it to reach a terminal state, then retry. A credential whose challenge is issued but unsigned does not block — it has not touched the attribute | | PASSKEY_ALREADY_ENROLLED | The customer already has an enrolled passkey factor; only one passkey per customer is supported. Delete the existing one before enrolling another | | SCA_SESSION_REQUIRED | The customer's Strong Customer Authentication login session is missing or expired. Re-authenticate the customer, then retry the request. Distinct from a `401`, which means the platform's own API credentials were rejected | | BENEFICIARY_TRUSTED | The external account is currently a trusted beneficiary, so it cannot be deleted. Untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete | diff --git a/mintlify/snippets/global-accounts/authentication.mdx b/mintlify/snippets/global-accounts/authentication.mdx index add26368c..903ccf554 100644 --- a/mintlify/snippets/global-accounts/authentication.mdx +++ b/mintlify/snippets/global-accounts/authentication.mdx @@ -870,4 +870,6 @@ The rest of this section covers the `AWAITING_SIGNATURE` path. 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. diff --git a/openapi.yaml b/openapi.yaml index ff97492bc..7be63dbc2 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1307,7 +1307,9 @@ paths: Returned with `CONTACT_CHANGE_PENDING` when the customer already has an active contact change of this type — `details` carries `contactChangeId` so the caller can read or cancel it — with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` when the supplied email is already associated with an `EMAIL_OTP` credential, or with `SMS_OTP_PHONE_ALREADY_EXISTS` when the supplied phone number is already associated with an `SMS_OTP` credential, on this or another internal account. - Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when an `EMAIL_OTP` or `SMS_OTP` credential operation of the matching type is still in flight on one of the customer's tied Embedded Wallets. Adding an OTP credential and changing the contact behind one both rewrite the same underlying user attribute, so letting the two run concurrently would leave whichever finished last silently winning. Wait for the credential operation to reach a terminal state, then create the contact change. + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when an `EMAIL_OTP` or `SMS_OTP` credential operation of the matching type has been submitted and not yet settled on one of the customer's tied Embedded Wallets. Adding an OTP credential and changing the contact behind one both rewrite the same underlying user attribute, so letting two submitted operations run concurrently would leave whichever finished last silently winning. Wait for the credential operation to reach a terminal state, then create the contact change. + + Only a *submitted* credential operation blocks. A credential whose challenge has been issued but not yet signed has not touched the attribute, so it does not block — otherwise two abandoned challenges, one on each side, would lock the customer out of both flows until they expired. content: application/json: schema: @@ -1621,7 +1623,7 @@ paths: description: |- Conflict. Returned with `CONTACT_CHANGE_NOT_AWAITING_SIGNATURE` when the change is `EXPIRED`, `CANCELLED`, or `FAILED` — all terminal, so start a new contact change rather than re-signing this one. A change that is `PROCESSING` or `APPLIED` is not a conflict and returns `200`. Also returned with `EMAIL_OTP_CREDENTIAL_SET_CHANGED` or `SMS_OTP_CREDENTIAL_SET_CHANGED` when the set of tied OTP credentials bound into `payloadToSign` at create time no longer matches the account, and with `EMAIL_OTP_EMAIL_ALREADY_EXISTS` or `SMS_OTP_PHONE_ALREADY_EXISTS` when the new value was claimed by another credential between create and submit. - Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when a matching OTP credential operation started after this change was created and is still in flight — the two rewrite the same underlying user attribute, so this change waits rather than racing it. The change stays `AWAITING_SIGNATURE` and the same stamp can be submitted again once the credential operation settles, provided `expiresAt` has not passed. + Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when a matching OTP credential operation was submitted after this change was created and has not yet settled — the two rewrite the same underlying user attribute, so this change waits rather than racing it. The change stays `AWAITING_SIGNATURE` and the same stamp can be submitted again once the credential operation settles, provided `expiresAt` has not passed; a transient overlap never costs the customer their signature. As on create, only a submitted credential operation blocks — an unsigned credential challenge has not touched the attribute. content: application/json: schema: @@ -14119,7 +14121,7 @@ components: | SMS_OTP_CREDENTIAL_SET_CHANGED | Tied SMS_OTP credential set changed after the signed-retry challenge was issued | | CONTACT_CHANGE_PENDING | The customer already has an active contact change of this type. `details.contactChangeId` names it; submit or cancel it before starting another | | CONTACT_CHANGE_NOT_AWAITING_SIGNATURE | The contact change is not `AWAITING_SIGNATURE`, so it can no longer be signed or cancelled | - | AUTH_CREDENTIAL_OPERATION_IN_FLIGHT | An authentication-credential operation on the same internal account is still in flight and targets the same underlying attribute as this request. Wait for it to reach a terminal state, then retry | + | AUTH_CREDENTIAL_OPERATION_IN_FLIGHT | An authentication-credential operation on the same internal account has been submitted, has not yet settled, and targets the same underlying attribute as this request. Wait for it to reach a terminal state, then retry. A credential whose challenge is issued but unsigned does not block — it has not touched the attribute | | PASSKEY_ALREADY_ENROLLED | The customer already has an enrolled passkey factor; only one passkey per customer is supported. Delete the existing one before enrolling another | | SCA_SESSION_REQUIRED | The customer's Strong Customer Authentication login session is missing or expired. Re-authenticate the customer, then retry the request. Distinct from a `401`, which means the platform's own API credentials were rejected | | BENEFICIARY_TRUSTED | The external account is currently a trusted beneficiary, so it cannot be deleted. Untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete | diff --git a/openapi/components/schemas/errors/Error409.yaml b/openapi/components/schemas/errors/Error409.yaml index 7f5dbca2b..34379634b 100644 --- a/openapi/components/schemas/errors/Error409.yaml +++ b/openapi/components/schemas/errors/Error409.yaml @@ -23,7 +23,7 @@ properties: | SMS_OTP_CREDENTIAL_SET_CHANGED | Tied SMS_OTP credential set changed after the signed-retry challenge was issued | | CONTACT_CHANGE_PENDING | The customer already has an active contact change of this type. `details.contactChangeId` names it; submit or cancel it before starting another | | CONTACT_CHANGE_NOT_AWAITING_SIGNATURE | The contact change is not `AWAITING_SIGNATURE`, so it can no longer be signed or cancelled | - | AUTH_CREDENTIAL_OPERATION_IN_FLIGHT | An authentication-credential operation on the same internal account is still in flight and targets the same underlying attribute as this request. Wait for it to reach a terminal state, then retry | + | AUTH_CREDENTIAL_OPERATION_IN_FLIGHT | An authentication-credential operation on the same internal account has been submitted, has not yet settled, and targets the same underlying attribute as this request. Wait for it to reach a terminal state, then retry. A credential whose challenge is issued but unsigned does not block — it has not touched the attribute | | PASSKEY_ALREADY_ENROLLED | The customer already has an enrolled passkey factor; only one passkey per customer is supported. Delete the existing one before enrolling another | | SCA_SESSION_REQUIRED | The customer's Strong Customer Authentication login session is missing or expired. Re-authenticate the customer, then retry the request. Distinct from a `401`, which means the platform's own API credentials were rejected | | BENEFICIARY_TRUSTED | The external account is currently a trusted beneficiary, so it cannot be deleted. Untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete | diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml index f29b26f27..77bc4dd2c 100644 --- a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml +++ b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml @@ -187,13 +187,20 @@ post: Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when an - `EMAIL_OTP` or `SMS_OTP` credential operation of the matching type is - still in flight on one of the customer's tied Embedded Wallets. - Adding an OTP credential and changing the contact behind one both - rewrite the same underlying user attribute, so letting the two run - concurrently would leave whichever finished last silently winning. - Wait for the credential operation to reach a terminal state, then - create the contact change. + `EMAIL_OTP` or `SMS_OTP` credential operation of the matching type has + been submitted and not yet settled on one of the customer's tied + Embedded Wallets. Adding an OTP credential and changing the contact + behind one both rewrite the same underlying user attribute, so letting + two submitted operations run concurrently would leave whichever + finished last silently winning. Wait for the credential operation to + reach a terminal state, then create the contact change. + + + Only a *submitted* credential operation blocks. A credential whose + challenge has been issued but not yet signed has not touched the + attribute, so it does not block — otherwise two abandoned challenges, + one on each side, would lock the customer out of both flows until + they expired. content: application/json: schema: diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml index b90203b7f..485d7a318 100644 --- a/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml +++ b/openapi/paths/customers/customers_{customerId}_contact-changes_{changeId}_submit.yaml @@ -124,12 +124,14 @@ post: Also returned with `AUTH_CREDENTIAL_OPERATION_IN_FLIGHT` when a - matching OTP credential operation started after this change was - created and is still in flight — the two rewrite the same underlying + matching OTP credential operation was submitted after this change was + created and has not yet settled — the two rewrite the same underlying user attribute, so this change waits rather than racing it. The change stays `AWAITING_SIGNATURE` and the same stamp can be submitted again once the credential operation settles, provided `expiresAt` has - not passed. + not passed; a transient overlap never costs the customer their + signature. As on create, only a submitted credential operation blocks + — an unsigned credential challenge has not touched the attribute. content: application/json: schema: From 788877d27aced0574ad815432c0cc29c24f4b810 Mon Sep 17 00:00:00 2001 From: carsonp6 Date: Fri, 21 Aug 2026 16:57:31 -0700 Subject: [PATCH 8/8] feat(spec): return 202 when a contact change needs a signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keeps the house convention — 202 whenever the client's signature is required before Grid can proceed — so create now lines up with the other Embedded Wallet endpoints on status code while keeping the resource body this design added. An AWAITING_SIGNATURE arrival is 202 with the ContactChange; terminal arrivals stay 201. The two codes answer different questions, and the endpoint now says so: 202 means accepted and waiting on the customer's signature, 201 means created and settled with nothing further required — where "settled" includes a recorded FAILED attempt, so a 201 still means recorded rather than worked. 4xx stays refused-and-unrecorded. Also drops the old "unlike the 202 on PATCH /customers" framing, which no longer distinguishes anything now that both return 202. The difference that survives is the body: a change resource whose payloadToSign can be re-read after a lost response, rather than a bare challenge that exists only in that one response. Co-Authored-By: Claude Fable 5 --- mintlify/openapi.yaml | 67 +++++++------ .../global-accounts/authentication.mdx | 14 +-- openapi.yaml | 67 +++++++------ ...ustomers_{customerId}_contact-changes.yaml | 93 ++++++++++++------- 4 files changed, 144 insertions(+), 97 deletions(-) diff --git a/mintlify/openapi.yaml b/mintlify/openapi.yaml index 7be63dbc2..deb909cf0 100644 --- a/mintlify/openapi.yaml +++ b/mintlify/openapi.yaml @@ -1195,19 +1195,23 @@ paths: A contact that is also a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — can only be changed with the customer's own signature, because changing it re-keys what they log in with. So a change arrives in one of three states: - - **`AWAITING_SIGNATURE` — the customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and returns `201` with the change awaiting its signature. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. + - **`202` + `AWAITING_SIGNATURE` — the customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and accepts the change pending the customer's signature. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. - - **`APPLIED` — no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with it already done, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. + - **`201` + `APPLIED` — no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and it arrives already done, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. - - **`FAILED` — no tied OTP credential of that type, and applying it didn't work.** Still `201`: the attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` carries a stable code you can branch on, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. + - **`201` + `FAILED` — no tied OTP credential of that type, and applying it didn't work.** The attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` carries a stable code you can branch on, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. - Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. + So the status code tells you whether you still owe Grid something, and `status` tells you what happened: + + - **`202`** — accepted, and your signature is required before it takes effect. The change is `AWAITING_SIGNATURE`. + - **`201`** — created and settled. Nothing further is required of you, though "settled" includes a recorded `FAILED` attempt, so a `201` means the change was recorded rather than that it worked. + - **`4xx`** — refused. No contact change exists, because nothing was attempted and so there is nothing to record. - A `201` therefore means "the change was recorded", not "the change worked". A `4xx` means the request itself was refused and no contact change exists — nothing was attempted, so there is nothing to record. + Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. A change that arrives terminal — `APPLIED` or `FAILED` — never blocks the next one. - Unlike the `202` challenge on `PATCH /customers/{customerId}`, the challenge here survives the response: a client that lost the create response can re-read `payloadToSign` from the change resource instead of starting over. + The `202` follows the same convention as every other Embedded Wallet endpoint that needs a signature first, but unlike the `202` on `PATCH /customers/{customerId}` its body is the change resource rather than a bare challenge — so the challenge survives the response. A client that lost the create response can re-read `payloadToSign` from the resource instead of starting over. operationId: createContactChange tags: - Customers @@ -1232,7 +1236,35 @@ paths: value: '+14155559876' responses: '201': - description: 'Contact change recorded. `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. When none exists Grid applies the change inline and it arrives terminal, carrying neither field: `APPLIED` on success, or `FAILED` with a `failureReason` if applying it didn''t work, which is still recorded so the attempt is visible in the customer''s contact-change history. Branch on `status`.' + description: Contact change created and settled — no signature was required because the customer has no tied OTP credential of that type, so Grid applied it inline. `APPLIED` on success, or `FAILED` with a `failureReason` when applying it didn't work, which is still recorded so the attempt stays visible in the customer's contact-change history. Either way the change is terminal on arrival and there is nothing further to call. Branch on `status`. + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChange' + examples: + appliedOnCreate: + summary: Applied on create (no tied OTP credential of that type) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000022 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' + failedOnCreate: + summary: Recorded but not applied (inline apply failed) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000023 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: FAILED + failureReason: CONTACT_UPDATE_REJECTED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:02Z' + '202': + description: Contact change accepted, and the customer's signature is required before it takes effect. The customer has a tied OTP credential of that type, so the change is `AWAITING_SIGNATURE` and carries the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. Apply it with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. content: application/json: schema: @@ -1262,27 +1294,6 @@ paths: expiresAt: '2026-04-08T15:35:00Z' createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:00Z' - appliedOnCreate: - summary: Applied on create (no tied OTP credential of that type) - value: - id: ContactChange:019542f5-b3e7-1d02-0000-000000000022 - customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 - type: PHONE - value: '+14155559876' - status: APPLIED - createdAt: '2026-04-08T15:30:00Z' - updatedAt: '2026-04-08T15:30:00Z' - failedOnCreate: - summary: Recorded but not applied (inline apply failed) - value: - id: ContactChange:019542f5-b3e7-1d02-0000-000000000023 - customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 - type: PHONE - value: '+14155559876' - status: FAILED - failureReason: CONTACT_UPDATE_REJECTED - createdAt: '2026-04-08T15:30:00Z' - updatedAt: '2026-04-08T15:30:02Z' '400': description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`.' content: diff --git a/mintlify/snippets/global-accounts/authentication.mdx b/mintlify/snippets/global-accounts/authentication.mdx index 903ccf554..1f30e6909 100644 --- a/mintlify/snippets/global-accounts/authentication.mdx +++ b/mintlify/snippets/global-accounts/authentication.mdx @@ -815,15 +815,15 @@ A credential is revoked by signing with a session from **a different credential 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`: -| Arrival `status` | Means | What to do | -|---|---|---| -| `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. | -| `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. | -| `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. | +| 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. -A `201` means the change was **recorded**, not that it worked — a failed attempt still gets a resource, so it shows up in the customer's history next to the successful ones. A `4xx` is different: the request was refused before anything was attempted, so no change exists at all. That's why a duplicate value or an in-flight change of the same type comes back `409` rather than `201 FAILED`. +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. @@ -836,7 +836,7 @@ The rest of this section covers the `AWAITING_SIGNATURE` path. -d '{ "type": "EMAIL", "value": "jane.smith@example.com" }' ``` - **Response (201):** + **Response (202):** ```json { diff --git a/openapi.yaml b/openapi.yaml index 7be63dbc2..deb909cf0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1195,19 +1195,23 @@ paths: A contact that is also a login credential — the email behind `EMAIL_OTP`, the phone number behind `SMS_OTP` — can only be changed with the customer's own signature, because changing it re-keys what they log in with. So a change arrives in one of three states: - - **`AWAITING_SIGNATURE` — the customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and returns `201` with the change awaiting its signature. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. + - **`202` + `AWAITING_SIGNATURE` — the customer has a tied Embedded Wallet OTP credential of that type.** Grid resolves every tied credential, binds that set into `payloadToSign`, and accepts the change pending the customer's signature. Build an API-key stamp over `payloadToSign` with the session API keypair of a verified credential on one of the customer's tied Embedded Wallets, then apply the change with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. - - **`APPLIED` — no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and returns `201` with it already done, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. + - **`201` + `APPLIED` — no tied OTP credential of that type.** There is nothing to re-key and no signature to collect, so Grid applies the change on create and it arrives already done, carrying no `payloadToSign` and no `expiresAt`. Nothing further to call. - - **`FAILED` — no tied OTP credential of that type, and applying it didn't work.** Still `201`: the attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` carries a stable code you can branch on, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. + - **`201` + `FAILED` — no tied OTP credential of that type, and applying it didn't work.** The attempt happened and is recorded, so it shows up in `GET /customers/{customerId}/contact-changes` alongside the successful ones. `failureReason` carries a stable code you can branch on, the customer contact field is unchanged, and the fix is a new contact change rather than anything on this one. - Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. + So the status code tells you whether you still owe Grid something, and `status` tells you what happened: + + - **`202`** — accepted, and your signature is required before it takes effect. The change is `AWAITING_SIGNATURE`. + - **`201`** — created and settled. Nothing further is required of you, though "settled" includes a recorded `FAILED` attempt, so a `201` means the change was recorded rather than that it worked. + - **`4xx`** — refused. No contact change exists, because nothing was attempted and so there is nothing to record. - A `201` therefore means "the change was recorded", not "the change worked". A `4xx` means the request itself was refused and no contact change exists — nothing was attempted, so there is nothing to record. + Branch on the returned `status` rather than on what you believe about the customer's credentials: a wallet that gained or lost an OTP credential since you last looked changes which arrival state you get. A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can read with `GET /customers/{customerId}/contact-changes/{changeId}` or abandon with `DELETE`. A change that arrives terminal — `APPLIED` or `FAILED` — never blocks the next one. - Unlike the `202` challenge on `PATCH /customers/{customerId}`, the challenge here survives the response: a client that lost the create response can re-read `payloadToSign` from the change resource instead of starting over. + The `202` follows the same convention as every other Embedded Wallet endpoint that needs a signature first, but unlike the `202` on `PATCH /customers/{customerId}` its body is the change resource rather than a bare challenge — so the challenge survives the response. A client that lost the create response can re-read `payloadToSign` from the resource instead of starting over. operationId: createContactChange tags: - Customers @@ -1232,7 +1236,35 @@ paths: value: '+14155559876' responses: '201': - description: 'Contact change recorded. `AWAITING_SIGNATURE` when a tied OTP credential of that type exists, carrying the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. When none exists Grid applies the change inline and it arrives terminal, carrying neither field: `APPLIED` on success, or `FAILED` with a `failureReason` if applying it didn''t work, which is still recorded so the attempt is visible in the customer''s contact-change history. Branch on `status`.' + description: Contact change created and settled — no signature was required because the customer has no tied OTP credential of that type, so Grid applied it inline. `APPLIED` on success, or `FAILED` with a `failureReason` when applying it didn't work, which is still recorded so the attempt stays visible in the customer's contact-change history. Either way the change is terminal on arrival and there is nothing further to call. Branch on `status`. + content: + application/json: + schema: + $ref: '#/components/schemas/ContactChange' + examples: + appliedOnCreate: + summary: Applied on create (no tied OTP credential of that type) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000022 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: APPLIED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:00Z' + failedOnCreate: + summary: Recorded but not applied (inline apply failed) + value: + id: ContactChange:019542f5-b3e7-1d02-0000-000000000023 + customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 + type: PHONE + value: '+14155559876' + status: FAILED + failureReason: CONTACT_UPDATE_REJECTED + createdAt: '2026-04-08T15:30:00Z' + updatedAt: '2026-04-08T15:30:02Z' + '202': + description: Contact change accepted, and the customer's signature is required before it takes effect. The customer has a tied OTP credential of that type, so the change is `AWAITING_SIGNATURE` and carries the `payloadToSign` to stamp and the `expiresAt` deadline to submit by. Apply it with `POST /customers/{customerId}/contact-changes/{changeId}/submit`. content: application/json: schema: @@ -1262,27 +1294,6 @@ paths: expiresAt: '2026-04-08T15:35:00Z' createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:00Z' - appliedOnCreate: - summary: Applied on create (no tied OTP credential of that type) - value: - id: ContactChange:019542f5-b3e7-1d02-0000-000000000022 - customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 - type: PHONE - value: '+14155559876' - status: APPLIED - createdAt: '2026-04-08T15:30:00Z' - updatedAt: '2026-04-08T15:30:00Z' - failedOnCreate: - summary: Recorded but not applied (inline apply failed) - value: - id: ContactChange:019542f5-b3e7-1d02-0000-000000000023 - customerId: Customer:019542f5-b3e7-1d02-0000-000000000001 - type: PHONE - value: '+14155559876' - status: FAILED - failureReason: CONTACT_UPDATE_REJECTED - createdAt: '2026-04-08T15:30:00Z' - updatedAt: '2026-04-08T15:30:02Z' '400': description: 'Bad request. Returned with `INVALID_INPUT` when `value` is not a valid email address for `type: EMAIL` or not a valid E.164 phone number for `type: PHONE`.' content: diff --git a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml index 77bc4dd2c..64d4bccce 100644 --- a/openapi/paths/customers/customers_{customerId}_contact-changes.yaml +++ b/openapi/paths/customers/customers_{customerId}_contact-changes.yaml @@ -20,27 +20,42 @@ post: with. So a change arrives in one of three states: - - **`AWAITING_SIGNATURE` — the customer has a tied Embedded Wallet OTP - credential of that type.** Grid resolves every tied credential, binds - that set into `payloadToSign`, and returns `201` with the change awaiting - its signature. Build an API-key stamp over `payloadToSign` with the - session API keypair of a verified credential on one of the customer's - tied Embedded Wallets, then apply the change with `POST - /customers/{customerId}/contact-changes/{changeId}/submit`. + - **`202` + `AWAITING_SIGNATURE` — the customer has a tied Embedded + Wallet OTP credential of that type.** Grid resolves every tied + credential, binds that set into `payloadToSign`, and accepts the change + pending the customer's signature. Build an API-key stamp over + `payloadToSign` with the session API keypair of a verified credential on + one of the customer's tied Embedded Wallets, then apply the change with + `POST /customers/{customerId}/contact-changes/{changeId}/submit`. - - **`APPLIED` — no tied OTP credential of that type.** There is nothing - to re-key and no signature to collect, so Grid applies the change on - create and returns `201` with it already done, carrying no - `payloadToSign` and no `expiresAt`. Nothing further to call. + - **`201` + `APPLIED` — no tied OTP credential of that type.** There is + nothing to re-key and no signature to collect, so Grid applies the change + on create and it arrives already done, carrying no `payloadToSign` and no + `expiresAt`. Nothing further to call. - - **`FAILED` — no tied OTP credential of that type, and applying it - didn't work.** Still `201`: the attempt happened and is recorded, so it - shows up in `GET /customers/{customerId}/contact-changes` alongside the - successful ones. `failureReason` carries a stable code you can branch on, - the customer contact field is unchanged, and the fix is a new contact - change rather than anything on this one. + - **`201` + `FAILED` — no tied OTP credential of that type, and applying + it didn't work.** The attempt happened and is recorded, so it shows up in + `GET /customers/{customerId}/contact-changes` alongside the successful + ones. `failureReason` carries a stable code you can branch on, the + customer contact field is unchanged, and the fix is a new contact change + rather than anything on this one. + + + So the status code tells you whether you still owe Grid something, and + `status` tells you what happened: + + + - **`202`** — accepted, and your signature is required before it takes + effect. The change is `AWAITING_SIGNATURE`. + + - **`201`** — created and settled. Nothing further is required of you, + though "settled" includes a recorded `FAILED` attempt, so a `201` means + the change was recorded rather than that it worked. + + - **`4xx`** — refused. No contact change exists, because nothing was + attempted and so there is nothing to record. Branch on the returned `status` rather than on what you believe about the @@ -48,11 +63,6 @@ post: since you last looked changes which arrival state you get. - A `201` therefore means "the change was recorded", not "the change - worked". A `4xx` means the request itself was refused and no contact - change exists — nothing was attempted, so there is nothing to record. - - A customer has at most one active (`AWAITING_SIGNATURE` or `PROCESSING`) contact change per contact type. Creating a second one while the first is still active returns `409` naming the active change, which the caller can @@ -61,10 +71,12 @@ post: `FAILED` — never blocks the next one. - Unlike the `202` challenge on `PATCH /customers/{customerId}`, the - challenge here survives the response: a client that lost the create - response can re-read `payloadToSign` from the change resource instead of - starting over. + The `202` follows the same convention as every other Embedded Wallet + endpoint that needs a signature first, but unlike the `202` on `PATCH + /customers/{customerId}` its body is the change resource rather than a + bare challenge — so the challenge survives the response. A client that + lost the create response can re-read `payloadToSign` from the resource + instead of starting over. operationId: createContactChange tags: - Customers @@ -88,15 +100,14 @@ post: type: PHONE value: '+14155559876' responses: - '201': + '202': description: >- - Contact change recorded. `AWAITING_SIGNATURE` when a tied OTP - credential of that type exists, carrying the `payloadToSign` to stamp - and the `expiresAt` deadline to submit by. When none exists Grid - applies the change inline and it arrives terminal, carrying neither - field: `APPLIED` on success, or `FAILED` with a `failureReason` if - applying it didn't work, which is still recorded so the attempt is - visible in the customer's contact-change history. Branch on `status`. + Contact change accepted, and the customer's signature is required + before it takes effect. The customer has a tied OTP credential of + that type, so the change is `AWAITING_SIGNATURE` and carries the + `payloadToSign` to stamp and the `expiresAt` deadline to submit by. + Apply it with `POST + /customers/{customerId}/contact-changes/{changeId}/submit`. content: application/json: schema: @@ -126,6 +137,20 @@ post: expiresAt: '2026-04-08T15:35:00Z' createdAt: '2026-04-08T15:30:00Z' updatedAt: '2026-04-08T15:30:00Z' + '201': + description: >- + Contact change created and settled — no signature was required + because the customer has no tied OTP credential of that type, so Grid + applied it inline. `APPLIED` on success, or `FAILED` with a + `failureReason` when applying it didn't work, which is still recorded + so the attempt stays visible in the customer's contact-change + history. Either way the change is terminal on arrival and there is + nothing further to call. Branch on `status`. + content: + application/json: + schema: + $ref: ../../components/schemas/customers/ContactChange.yaml + examples: appliedOnCreate: summary: Applied on create (no tied OTP credential of that type) value: