Add contact-changes endpoints for customer email/phone updates - #853
Add contact-changes endpoints for customer email/phone updates#853carsonp6 wants to merge 8 commits into
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
✱ Stainless preview builds for gridThis PR will update the cli go kotlin openapi php python ruby typescript Edit this comment to update them. They will appear in their respective SDK's changelogs. ✅ grid-typescript studio · code · diff
✅ grid-openapi studio · code · diff
✅ grid-ruby studio · code · diff
✅ grid-go studio · code · diff
✅ grid-kotlin studio · code · diff
✅ grid-python studio · code · diff
✅ grid-php studio · code · diff
✅ grid-cli studio · code · diff
This comment is auto-generated by GitHub Actions and is automatically kept up to date as you push. |
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>
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>
Adds dedicated endpoints for changing a customer's email or phone number, replacing the contact-update half of
PATCH /customersthat was excised from #850. Contact changes are now the single door for contact updates:emailandphoneNumberonPATCH /customersare marked deprecated here (still functional during migration; nothing removed).Why
The email behind
EMAIL_OTPand the phone number behindSMS_OTPare 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 /customerscarried that as a202challenge inside a single request/response pair, which has three problems:202body — page reload, dropped connection, a backend that didn't persist it — and there is no way to getpayloadToSignback. The customer starts over.emailorphoneNumber.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
statusinstead of tracking which of their customers have which credentials.The lifecycle
In prose: a create arrives in one of three states. With a tied OTP credential of that type it arrives
202 AWAITING_SIGNATUREholdingpayloadToSignandexpiresAt; 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 toAPPLIEDor comes backPROCESSING, 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 arrives201and terminal with neither field —APPLIEDon success, orFAILEDwith afailureReasonif the inline apply didn't work. Either way it's recorded, and either way there's nothing further to call.Grid reconciles a
PROCESSINGchange on its own regardless, so a client that stops retrying can read the outcome fromGET. An unsubmitted change can be cancelled deliberately, and lapses toEXPIREDon its own otherwise. Once submitted there is nothing to cancel: we don't pretend to un-sign a signature.APPLIEDmeans 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 endsFAILED— nothing lands half-applied.Endpoints
POST /customers/{customerId}/contact-changes202 AWAITING_SIGNATURE+payloadToSignwhen a tied OTP credential exists;201 APPLIEDor201 FAILEDwhen none does, since Grid applies it inline. One active change per contact type — a second create while one is active returns409 CONTACT_CHANGE_PENDINGwithdetails.contactChangeIdnaming the live one, and no resource.POST /customers/{customerId}/contact-changes/{changeId}/submitGrid-Wallet-Signature.200with theAPPLIEDchange, or200 WalletOperationProcessingwhile in flight. Idempotent.GET /customers/{customerId}/contact-changesstatus.GET /customers/{customerId}/contact-changes/{changeId}payloadToSignafter a lost create response.DELETE /customers/{customerId}/contact-changes/{changeId}AWAITING_SIGNATURE;409otherwise.Status enum:
AWAITING_SIGNATURE,PROCESSING,APPLIED,FAILED,EXPIRED,CANCELLED.Based on #850
This branch is stacked on
sp3579-wallet-operation-processing, becausesubmitreturns #850'sWalletOperationProcessingon 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 lintpasses: 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-204on the cancel endpoint — deliberate, see open question 9.DELETE /customers/{customerId}already returns200+ the resource.pagination-envelope-has-dataonContactChange— the rule fires on anyGET200schema;ContactChangeis the read-one response, not a list. Same false positive already exists forStablecoinOperationandStablecoinProviderAccount.make buildregeneratedopenapi.yamlandmintlify/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 ispreview(the Stainless SDK build offopenapi.yaml+.stainless/stainless.yml), which passes. The other three fire for the first time when this retargets main after #850 merges.Decided
Status codes:
202when a signature is required,201when it isn't. ✅ Ruled: the house convention holds —202whenever Grid needs the client's signature before proceeding. So anAWAITING_SIGNATUREarrival is202with theContactChangeas the body (identical fields, only the code changed), and terminal arrivals stay201. The resource-body part of the design is unchanged.The two codes answer different questions, which the endpoint now states outright:
202means accepted and waiting on the customer's signature;201means created and settled with nothing further required of the caller — and "settled" includes a recordedFAILEDattempt, so a201still means the change was recorded rather than that it worked;4xxstays 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
202onPATCH /customers" framing, which no longer distinguished anything once both returned202. The difference that survives is the one that always mattered: the body is a change resource whosepayloadToSigncan 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 returns201alreadyAPPLIED. ✅ 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 nopayloadToSignand noexpiresAt. Every arrival state is documented on the create endpoint, and callers are told to branch onstatusrather than on what they believe about the customer's credentials. The dead400clause is gone; every other error code is unchanged.PATCH /customerskeepsemailandphoneNumberworking, now marked deprecated in the endpoint description, theCustomerUpdateRequestschema description, and both field descriptions.One contract hole this opened, closed in a follow-up commit: a change can now reach
APPLIEDhaving never had apayloadToSign, so submitting it was ambiguous — the200clause said anAPPLIEDchange returns its own body, while the401clause said a stamp not matchingpayloadToSignis 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 it409'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
201with the change persisted asFAILEDplus afailureReason, so every attempt is visible in the list whether or not it worked. Create therefore has three arrival states —AWAITING_SIGNATUREwith a tied OTP credential,APPLIEDorFAILEDwithout one — documented on the endpoint description, both success-response descriptions,ContactChange,ContactChangeStatus, and the auth guide, with afailedOnCreateexample.The status code follows repo precedent rather than instinct alone, since you asked me to look:
POST /agents/{agentId}/actions/{actionId}/approveandPOST /agents/me/quotes/{quoteId}/executeboth return2xxcarrying anAgentActionthat may beFAILED, and reserve4xxfor a request that can't be accepted.AgentActionStatusdraws the same line in its own vocabulary —REJECTEDfor a request turned down,FAILEDfor one that ran and didn't work. So the repo already says "2xx + resource for a failed attempt,4xxfor a refused request", which is the boundary you described.201rather than their200because this call creates the resource.That boundary is now stated at the
409itself: 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 plain409with no record. The guide puts it as — a201means the change was recorded, not that it worked.Cross-flow race, fixed here with the mirror agreed elsewhere. Adding an
EMAIL_OTP/SMS_OTPcredential and changing the contact behind one both rewrite the same underlying user attribute — visible directly in this spec, wherePOST /auth/credentialsand a contact change publish byte-identicalACTIVITY_TYPE_UPDATE_USER_EMAILpayloads. Two submitted at once means whichever settles last silently wins. Create and submit now both409with a newAUTH_CREDENTIAL_OPERATION_IN_FLIGHTcode; on submit the change staysAWAITING_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'401description 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
deprecated: trueflag, not just prose? You said note, so I wrote notes. Settingdeprecated: trueon 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:
Customers(SDK namespacecustomers.contactChanges), notEmbedded Wallet Auth. The single-door decision strengthens this:PATCH /customersnow 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.payloadToSign, notpayload_to_sign. Your sketch used snake_case; spectral enforces camelCase, and this matches the existingSignedRequestChallenge.payloadToSign. Same forexpiresAt/createdAt. Flagging only in case you meant a different field.CANCELLED, notCANCELED. Your sketch used one L. The repo's only precedent (UmaInvitationstatus,INVITATION_CANCELLED) uses two, so I matched the repo.{type, value}vs{email}/{phoneNumber}. Kept your{type: EMAIL|PHONE, value}— uniform across list/read, explicit enum, one code path. The alternative mirrors theCustomerresource's own field names, which integrators may expect. Related:PHONEas the enum value, when the customer field isphoneNumberand the credential type isSMS_OTP.Request-Id— justGrid-Wallet-Signatureover the change'spayloadToSign. The auth endpoints needRequest-Idbecause their challenge exists only in a prior response; herechangeIdin 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 droppingRequest-Idis right.424(mirroring PATCH's existing424) and moves the change toFAILED. A failure discovered after aPROCESSINGresponse has no status code to land on, so it only surfaces asFAILED+failureReasonon the resource. The alternative — submit always returns200with the change — gives one place to look but makes200mean "your change failed", which fights every SDK's error handling. See also question 16 for the third case, the inline apply on create.✅ Resolved — it's a code, and my prose version was the anomaly. Surfaced by the pending-credentials cross-check, then confirmed against the repo:failureReasonis free text.StablecoinOperation.failureReasonis "Stable internal failure code" withPROVIDER_TRANSFER_FAILED,OutgoingTransaction.failureReason$refs a dedicated failure-reason enum, andmintlify/snippets/error-handling.mdxdocumentsfailureReasonas the machine token integrators branch on, with lists ofUPPER_SNAKEvalues. 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 proposedfailureCode+failureMessagesplit — 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'sOperationErroris{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 isDeleteApiKeysFailed, 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.200+ theCANCELLEDchange, not204. Saves a follow-upGETto render terminal state; costs a spectral warning.DELETE /customers/{customerId}sets the200precedent.limit,cursor,hasMore,nextCursor,totalCount) plus astatusfilter, unlikeGET /auth/credentialswhich is deliberately unpaginated. Contact changes accumulate over a customer's lifetime, so pagination. Notypefilter — say the word if you want one.{type, value}while one is active still409s. But a change that arrivesAPPLIEDis 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 ownAPPLIEDrecord. Options: leave it (every call is a real request, honestly recorded), dedupe whenvaluealready equals the current contact, or return the existing change instead of409on the signed path.customerIdandupdatedAt(matchingAuthMethod).payloadToSignandexpiresAtappear only whileAWAITING_SIGNATURE— a consumed payload isn't echoed back, and a change that arrivesAPPLIEDnever had one.CONTACT_CHANGE_PENDINGandCONTACT_CHANGE_NOT_AWAITING_SIGNATURE(one code for both "can't sign this" and "can't cancel this", naming the required state likeTRANSACTION_NOT_PENDING_PLATFORM_APPROVALdoes), plus theSMS_OTPcounterparts of codes that only existed forEMAIL_OTP:SMS_OTP_PHONE_ALREADY_EXISTS,SMS_OTP_CREDENTIAL_SET_CHANGED,SMS_OTP_CREDENTIAL_SYNC_FAILED.PATCH /customersalready described those phone cases in prose without ever defining codes for them..stainless/stainless.ymlis not updated, so these endpoints get no SDK methods yet.auth/credentialsis 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-editopenapi/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_changeswithcreate/list/retrieve/cancel/submit.PROCESSINGresponse — is discoverable only by pollingGET. Should there be acontact_change.applied/contact_change.failedevent?🤖 Generated with Claude Code