Skip to content

Add contact-changes endpoints for customer email/phone updates - #853

Draft
carsonp6 wants to merge 8 commits into
sp3579-wallet-operation-processingfrom
contact-changes-endpoints
Draft

Add contact-changes endpoints for customer email/phone updates#853
carsonp6 wants to merge 8 commits into
sp3579-wallet-operation-processingfrom
contact-changes-endpoints

Conversation

@carsonp6

@carsonp6 carsonp6 commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Adds dedicated endpoints for changing a customer's email or phone number, replacing the contact-update half of PATCH /customers that was excised from #850. Contact changes are now the single door for contact updates: email and phoneNumber on PATCH /customers are marked deprecated here (still functional during migration; nothing removed).

Why

The email behind EMAIL_OTP and the phone number behind SMS_OTP are what the customer logs in with. Changing either one is a login-security operation, not a profile edit: it needs the customer's own signature, and it has to re-key every tied OTP credential across every tied wallet as one operation.

PATCH /customers carried that as a 202 challenge inside a single request/response pair, which has three problems:

  • The challenge was unrecoverable. Lose the 202 body — page reload, dropped connection, a backend that didn't persist it — and there is no way to get payloadToSign back. The customer starts over.
  • A pending change was invisible. Nothing to read, nothing to cancel, no way to answer "is a contact change in flight for this customer?" or to show the customer what they already requested.
  • It was welded to a profile edit. One endpoint that usually completes synchronously and occasionally turns into a two-step signed ceremony, discriminated by whether the body happened to contain email or phoneNumber.

Modelling the change as a resource fixes all three: the challenge lives on something the platform can GET, cancel, and list.

Making it the only door fixes a fourth: whether a signature is required is Grid's business, not the integrator's. A contact backing an OTP credential needs one; a contact that isn't backing a credential is just a field. Callers hit one endpoint and branch on the returned status instead of tracking which of their customers have which credentials.

The lifecycle

stateDiagram-v2
  [*] --> AWAITING_SIGNATURE: POST /contact-changes → 202 (tied OTP credential exists)
  [*] --> APPLIED: POST /contact-changes → 201 (no tied credential — applied on create)
  [*] --> FAILED: POST /contact-changes → 201 (no tied credential — inline apply failed)
  AWAITING_SIGNATURE --> PROCESSING: POST .../submit (stamp accepted, provider in flight)
  AWAITING_SIGNATURE --> APPLIED: POST .../submit (settles inline)
  AWAITING_SIGNATURE --> CANCELLED: DELETE
  AWAITING_SIGNATURE --> EXPIRED: expiresAt passes
  PROCESSING --> APPLIED: provider settles
  PROCESSING --> FAILED: provider fails / tied credential sync fails
  APPLIED --> [*]
  FAILED --> [*]
  EXPIRED --> [*]
  CANCELLED --> [*]
Loading

In prose: a create arrives in one of three states. With a tied OTP credential of that type it arrives 202 AWAITING_SIGNATURE holding payloadToSign and expiresAt; the client stamps that payload with the session key of any verified credential on one of the customer's tied wallets and submits it, and the submit either settles inline to APPLIED or comes back PROCESSING, where re-sending the identical stamp converges on the terminal answer. With no tied 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 201 and terminal with neither field — APPLIED on success, or FAILED with a failureReason if the inline apply didn't work. Either way it's recorded, and either way there's nothing further to call.

Grid reconciles a PROCESSING change on its own regardless, so a client that stops retrying can read the outcome from GET. An unsubmitted change can be cancelled deliberately, and lapses to EXPIRED on its own otherwise. Once submitted there is nothing to cancel: we don't pretend to un-sign a signature.

APPLIED means the customer contact field and every tied matching OTP credential were updated. If any tied credential can't be updated, the contact field is left alone and the change ends FAILED — nothing lands half-applied.

Endpoints

Endpoint Purpose
POST /customers/{customerId}/contact-changes Create a change. 202 AWAITING_SIGNATURE + payloadToSign when a tied OTP credential exists; 201 APPLIED or 201 FAILED when none does, since Grid applies it inline. One active change per contact type — a second create while one is active returns 409 CONTACT_CHANGE_PENDING with details.contactChangeId naming the live one, and no resource.
POST /customers/{customerId}/contact-changes/{changeId}/submit Carry the stamp in Grid-Wallet-Signature. 200 with the APPLIED change, or 200 WalletOperationProcessing while in flight. Idempotent.
GET /customers/{customerId}/contact-changes List, newest first, paginated, filterable by status.
GET /customers/{customerId}/contact-changes/{changeId} Read one — including re-reading payloadToSign after a lost create response.
DELETE /customers/{customerId}/contact-changes/{changeId} Cancel while AWAITING_SIGNATURE; 409 otherwise.

Status enum: AWAITING_SIGNATURE, PROCESSING, APPLIED, FAILED, EXPIRED, CANCELLED.

Based on #850

This branch is stacked on sp3579-wallet-operation-processing, because submit returns #850's WalletOperationProcessing on the in-flight path. Review #850 first; this PR's diff against it is only the contact-change surface plus the PATCH deprecation notes.

Lint / build

make lint passes: 0 errors, 150 warnings (baseline on the parent branch was 148). The two added warnings are both known rule quirks, neither a real finding:

  • delete-returns-204 on the cancel endpoint — deliberate, see open question 9. DELETE /customers/{customerId} already returns 200 + the resource.
  • pagination-envelope-has-data on ContactChange — the rule fires on any GET 200 schema; ContactChange is the read-one response, not a list. Same false positive already exists for StablecoinOperation and StablecoinProviderAccount.

make build regenerated openapi.yaml and mintlify/openapi.yaml; both are committed in sync.

Note that Lint, OpenAPI Build, and breaking-changes do not run on this PR — those workflows are scoped to pull_request: branches: [main], so a stacked PR skips them. The gate that does run is preview (the Stainless SDK build off openapi.yaml + .stainless/stainless.yml), which passes. The other three fire for the first time when this retargets main after #850 merges.

Decided

Status codes: 202 when a signature is required, 201 when it isn't. ✅ Ruled: the house convention holds — 202 whenever Grid needs the client's signature before proceeding. So an AWAITING_SIGNATURE arrival is 202 with the ContactChange as the body (identical fields, only the code changed), and terminal arrivals stay 201. The resource-body part of the design is unchanged.

The two codes answer different questions, which the endpoint now states outright: 202 means accepted and waiting on the customer's signature; 201 means created and settled with nothing further required of the caller — and "settled" includes a recorded FAILED attempt, so a 201 still means the change was recorded rather than that it worked; 4xx stays refused-and-unrecorded.

A side benefit: create now matches every other Embedded Wallet endpoint that needs a signature first, so there's no new convention for integrators to learn. It also retires the old "unlike the 202 on PATCH /customers" framing, which no longer distinguished anything once both returned 202. The difference that survives is the one that always mattered: the body is a change resource whose payloadToSign can be re-read after a lost response, not a bare challenge that exists only in that one response.

6. A create for a customer with no tied OTP credential no longer 400s — it returns 201 already APPLIED. ✅ Decided: contact-changes is the only door. Grid applies the profile change and downstream sync inline on create, with the same semantics as today's PATCH path, and the change arrives terminal with no payloadToSign and no expiresAt. Every arrival state is documented on the create endpoint, and callers are told to branch on status rather than on what they believe about the customer's credentials. The dead 400 clause is gone; every other error code is unchanged. PATCH /customers keeps email and phoneNumber working, now marked deprecated in the endpoint description, the CustomerUpdateRequest schema description, and both field descriptions.

One contract hole this opened, closed in a follow-up commit: a change can now reach APPLIED having never had a payloadToSign, so submitting it was ambiguous — the 200 clause said an APPLIED change returns its own body, while the 401 clause said a stamp not matching payloadToSign is unauthorized, and both read as applying. Resolved 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. Flagging in case you'd rather it 409'd to make "you never needed to sign this" explicit.

16. A failed instant-apply is recorded, not discarded. ✅ Decided: a create whose inline apply fails returns 201 with the change persisted as FAILED plus a failureReason, so every attempt is visible in the list whether or not it worked. Create therefore has three arrival states — AWAITING_SIGNATURE with a tied OTP credential, APPLIED or FAILED without one — documented on the endpoint description, both success-response descriptions, ContactChange, ContactChangeStatus, and the auth guide, with a failedOnCreate example.

The status code follows repo precedent rather than instinct alone, since you asked me to look: 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. So the repo already says "2xx + resource for a failed attempt, 4xx for a refused request", which is the boundary you described. 201 rather than their 200 because this call creates the resource.

That boundary is now stated at the 409 itself: a rejected request leaves no resource because nothing was attempted, so a duplicate value or an already-pending change of the same type stays a plain 409 with no record. The guide puts it as — a 201 means the change was recorded, not that it worked.

Cross-flow race, fixed here with the mirror agreed elsewhere. Adding an EMAIL_OTP / SMS_OTP credential and changing the contact behind one both rewrite the same underlying user attribute — visible directly in this spec, where POST /auth/credentials and a contact change publish byte-identical ACTIVITY_TYPE_UPDATE_USER_EMAIL payloads. Two submitted at once means whichever settles last silently wins. Create and submit now both 409 with a new AUTH_CREDENTIAL_OPERATION_IN_FLIGHT code; on submit the change stays AWAITING_SIGNATURE, so a transient overlap never costs the customer their signature.

Scoped deliberately to submitted operations: an issued-but-unsigned credential challenge hasn't forwarded anything to the provider, so there's nothing to race, and blocking on it would let two abandoned challenges — one per side — lock a customer out of both flows until they expired, with 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. The guide also documents the reverse order, where an applied contact change invalidates an outstanding credential-add challenge because the stamped payload names the old contact.

Related gap for whoever owns the auth endpoints, not fixed here: that staleness rejection is real shipped behavior, and POST /auth/credentials' 401 description doesn't mention it — an integrator hitting it has no way to know why. Worth folding into the credential-side change rather than expanding this PR.

Open questions for review

  1. Should the deprecation be the machine-readable deprecated: true flag, not just prose? You said note, so I wrote notes. Setting deprecated: true on the two fields would propagate into every generated SDK as a deprecation warning — correct eventually, but loud while those fields are still the functional path for live integrators. My read: prose now, flip the flag when the contact-changes path has real adoption. Confirm the timing.

Carried over:

  1. Tag / SDK grouping. All five operations are tagged Customers (SDK namespace customers.contactChanges), not Embedded Wallet Auth. The single-door decision strengthens this: PATCH /customers now carries a deprecation note pointing at a sibling in the same tag group, and the endpoint serves customers with no wallet credentials at all, so filing it under an Embedded Wallet tag would be actively wrong for that half of its traffic. Flagging only because the mechanism is still Embedded Wallet vocabulary.
  2. payloadToSign, not payload_to_sign. Your sketch used snake_case; spectral enforces camelCase, and this matches the existing SignedRequestChallenge.payloadToSign. Same for expiresAt / createdAt. Flagging only in case you meant a different field.
  3. CANCELLED, not CANCELED. Your sketch used one L. The repo's only precedent (UmaInvitation status, INVITATION_CANCELLED) uses two, so I matched the repo.
  4. {type, value} vs {email} / {phoneNumber}. Kept your {type: EMAIL|PHONE, value} — uniform across list/read, explicit enum, one code path. The alternative mirrors the Customer resource's own field names, which integrators may expect. Related: PHONE as the enum value, when the customer field is phoneNumber and the credential type is SMS_OTP.
  5. Submit takes no request body and no Request-Id — just Grid-Wallet-Signature over the change's payloadToSign. The auth endpoints need Request-Id because their challenge exists only in a prior response; here changeId in the path is the correlation and the change already holds the fields being changed. Confirm you want the header rather than a {signature} body field, and that dropping Request-Id is right.
  6. Failure is expressed two ways on the signed path. A failure Grid sees during submit is 424 (mirroring PATCH's existing 424) and moves the change to FAILED. A failure discovered after a PROCESSING response has no status code to land on, so it only surfaces as FAILED + failureReason on the resource. The alternative — submit always returns 200 with the change — gives one place to look but makes 200 mean "your change failed", which fights every SDK's error handling. See also question 16 for the third case, the inline apply on create.
  7. failureReason is free text.Resolved — it's a code, and my prose version was the anomaly. Surfaced by the pending-credentials cross-check, then confirmed against the repo: StablecoinOperation.failureReason is "Stable internal failure code" with PROVIDER_TRANSFER_FAILED, OutgoingTransaction.failureReason $refs a dedicated failure-reason enum, and mintlify/snippets/error-handling.mdx documents failureReason as the machine token integrators branch on, with lists of UPPER_SNAKE values. I had given the same field name a human sentence. Fixed by keeping the name and making it what the name already promises. I did not take the proposed failureCode + failureMessage split — that would be a third convention for one concept, and no existing schema in this repo pairs a code with a prose message (feat(webhooks): add wallet-operation partner webhook #802's OperationError is {code} alone). One thing to confirm: I specified the code as Grid's own vocabulary, not a pass-through of the provider's, partly because feat(webhooks): add wallet-operation partner webhook #802's example value is DeleteApiKeysFailed, which is PascalCase and reads like provider vocabulary — worth a look there given we don't put provider names or their terminology in the public surface.
  8. Cancel returns 200 + the CANCELLED change, not 204. Saves a follow-up GET to render terminal state; costs a spectral warning. DELETE /customers/{customerId} sets the 200 precedent.
  9. List is fully paginated (limit, cursor, hasMore, nextCursor, totalCount) plus a status filter, unlike GET /auth/credentials which is deliberately unpaginated. Contact changes accumulate over a customer's lifetime, so pagination. No type filter — say the word if you want one.
  10. Create is not idempotent, and the no-credential path makes that more visible. A second create with an identical {type, value} while one is active still 409s. But a change that arrives APPLIED is terminal on arrival and so never blocks the next one — meaning repeated identical creates on the no-credential path each succeed and each leave their own APPLIED record. Options: leave it (every call is a real request, honestly recorded), dedupe when value already equals the current contact, or return the existing change instead of 409 on the signed path.
  11. Fields added beyond your sketch: customerId and updatedAt (matching AuthMethod). payloadToSign and expiresAt appear only while AWAITING_SIGNATURE — a consumed payload isn't echoed back, and a change that arrives APPLIED never had one.
  12. Five new error codes. CONTACT_CHANGE_PENDING and CONTACT_CHANGE_NOT_AWAITING_SIGNATURE (one code for both "can't sign this" and "can't cancel this", naming the required state like TRANSACTION_NOT_PENDING_PLATFORM_APPROVAL does), plus the SMS_OTP counterparts of codes that only existed for EMAIL_OTP: SMS_OTP_PHONE_ALREADY_EXISTS, SMS_OTP_CREDENTIAL_SET_CHANGED, SMS_OTP_CREDENTIAL_SYNC_FAILED. PATCH /customers already described those phone cases in prose without ever defining codes for them.
  13. .stainless/stainless.yml is not updated, so these endpoints get no SDK methods yet. auth/credentials is registered there; verify-email, verify-phone, and the SCA trust endpoints are not — spec endpoints that never reached the SDKs. I left it out as outside "hand-edit openapi/ sources", but this is now the only door for contact updates, so it shouldn't repeat that. Proposed mapping, for this PR or a follow-up: customers.contact_changes with create / list / retrieve / cancel / submit.
  14. No webhook. A change that settles asynchronously — or fails after a PROCESSING response — is discoverable only by polling GET. Should there be a contact_change.applied / contact_change.failed event?

🤖 Generated with Claude Code

carsonp6 and others added 2 commits August 21, 2026 13:03
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
grid-flow-builder Ignored Ignored Preview Aug 21, 2026 11:57pm
grid-wallet-demo Ignored Ignored Preview Aug 21, 2026 11:57pm

Request Review

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

✱ Stainless preview builds for grid

This PR will update the grid SDKs with the following commit messages.

cli

chore(internal): regenerate SDK with no functional changes

go

chore(internal): regenerate SDK with no functional changes

kotlin

chore(internal): regenerate SDK with no functional changes

openapi

feat(api): add contact changes endpoints, types, and error codes to customers

php

chore(internal): regenerate SDK with no functional changes

python

chore(internal): regenerate SDK with no functional changes

ruby

chore(internal): regenerate SDK with no functional changes

typescript

chore(internal): regenerate SDK with no functional changes

Edit this comment to update them. They will appear in their respective SDK's changelogs.

grid-typescript studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ✅lint ❗test ✅

npm install https://pkg.stainless.com/s/grid-typescript/f9e0d90691d191091692717eb4182a0cf35110d1/dist.tar.gz
New diagnostics (5 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes/{changeId}/submit`
grid-openapi studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️

New diagnostics (5 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes/{changeId}/submit`
grid-ruby studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ✅lint ✅test ✅

New diagnostics (5 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes/{changeId}/submit`
grid-go studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ✅lint ❗test ❗

go get github.com/stainless-sdks/grid-go@8492d218d033dbe0f7a9596ccaea1d98fb4222de
New diagnostics (5 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes/{changeId}/submit`
grid-kotlin studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ✅lint ✅test ❗

New diagnostics (5 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes/{changeId}/submit`
grid-python studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ✅lint ❗test ❗

pip install https://pkg.stainless.com/s/grid-python/5b8df8e1e306ecf5a89ba214ff9c730514002205/grid-0.0.1-py3-none-any.whl
New diagnostics (5 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes/{changeId}/submit`
grid-php studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️lint ✅test ✅

New diagnostics (5 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes/{changeId}/submit`
grid-cli studio · code · diff

Your SDK build had at least one new note diagnostic, which is a regression from the base state.
generate ⚠️build ⏭️ (prev: build ❗) → lint ⏭️ (prev: lint ❗) → test ❗

New diagnostics (5 note)
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `get /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `delete /customers/{customerId}/contact-changes/{changeId}`
💡 Endpoint/NotConfigured: Skipped endpoint because it's not in your Stainless config: `post /customers/{customerId}/contact-changes/{changeId}/submit`

This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push.
If you push custom code to the preview branch, re-run this workflow to update the comment.
Last updated: 2026-08-22 00:01:50 UTC

carsonp6 and others added 2 commits August 21, 2026 13:25
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
carsonp6 and others added 4 commits August 21, 2026 14:29
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ions

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant