Card contract: Idempotency-Key + 409s, DECLINED status, CARD_TRANSACTION.DECLINED, threeDSecurePassword#686
Card contract: Idempotency-Key + 409s, DECLINED status, CARD_TRANSACTION.DECLINED, threeDSecurePassword#686kphurley7 wants to merge 7 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
✱ 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. |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
|
4e82188 to
2edd585
Compare
|
@greptileai review this |
|
@faraday review this |
|
📌 Bolt Status 2026-07-17 14:58:46 UTC — ⚡ Agent |
Greptile SummaryThis PR extends the card contract with five additions for Striga-under-Grid (EU card program) support: an
Confidence Score: 5/5Safe to merge — all schema additions are additive, lint suppressions are correctly mirrored in both linters, and the bundled openapi.yaml matches the source changes. Every new schema element (DECLINED status, CARD_TRANSACTION.DECLINED webhook type, threeDSecurePassword, Idempotency-Key header, 409 responses) is correctly wired: enum values appear in all the right places (CardTransactionStatus, WebhookType, CardTransactionWebhook), the sibling description on refundedAmount is valid in the spec's OpenAPI 3.1 dialect, and the Error409 enum already contains CONFLICT which covers the new provider-conflict case. Two cosmetic observations from prior reviews (enum ordering, ASCII diagram) remain open but do not affect correctness. mintlify/snippets/cards/reconciliation.mdx — the ASCII state-machine diagram still lacks the AUTHORIZED → DECLINED branch (carry-over from a prior review comment).
|
| Filename | Overview |
|---|---|
| openapi/paths/cards/cards.yaml | Adds Idempotency-Key header (required: false, consistent with other endpoints) and 409 response for idempotency conflicts; aligns with existing spec conventions and PR description. |
| openapi/paths/cards/cards_{id}.yaml | Adds CONFLICT error code to PATCH 409 description for provider-reported state conflicts; CONFLICT is already in Error409.yaml enum. |
| openapi/components/schemas/cards/CardTransactionStatus.yaml | Adds DECLINED to enum and description table; enum position (between REFUNDED and EXCEPTION) doesn't match description-table position (second, after AUTHORIZED) — noted in a prior review. |
| openapi/components/schemas/cards/CardCreateRequest.yaml | Adds threeDSecurePassword (writeOnly, optional, 8-36 chars); lint suppressions for the non-standard casing are correctly mirrored in both .redocly.lint-ignore.yaml and .spectral.yaml. |
| openapi/webhooks/card-transaction.yaml | Adds DECLINED to the webhook description and a complete declined example payload; all required CardTransaction fields are present and zeros on all summaries are correct for a terminal-no-funds event. |
| openapi/components/schemas/cards/CardTransaction.yaml | Adds a sibling description to refundedAmount alongside its $ref; valid in OpenAPI 3.1 (spec is openapi: 3.1.0) and correctly documents the EU/US refund-linkage difference. |
| mintlify/snippets/cards/reconciliation.mdx | Updates status table to include DECLINED and rewrites webhook section, but the ASCII state-machine diagram still lacks the AUTHORIZED → DECLINED terminal branch (raised in a prior review). |
| mintlify/snippets/cards/webhooks.mdx | Fully rewrites the card-transaction lifecycle section to document all CARD_TRANSACTION.* webhook types including DECLINED; adds a complete settled payload example. |
| .spectral.yaml | Adds an overrides section to suppress field-names-camelCase for threeDSecurePassword, correctly mirroring the .redocly.lint-ignore.yaml exception. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Authorization Received] --> B{Issuer Decision}
B -- Approved --> C[AUTHORIZED]
B -- Rejected --> D[DECLINED - terminal, no funds moved]
C --> E[PARTIALLY_SETTLED - one or more clearings landed]
E --> F[SETTLED - all clearings posted]
F --> G[REFUNDED - RETURN received]
C --> H[EXCEPTION - pull failed after settlement]
E --> H
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[Authorization Received] --> B{Issuer Decision}
B -- Approved --> C[AUTHORIZED]
B -- Rejected --> D[DECLINED - terminal, no funds moved]
C --> E[PARTIALLY_SETTLED - one or more clearings landed]
E --> F[SETTLED - all clearings posted]
F --> G[REFUNDED - RETURN received]
C --> H[EXCEPTION - pull failed after settlement]
E --> H
Reviews (4): Last reviewed commit: "LINT: spectral override for threeDSecure..." | Re-trigger Greptile
…webhook
- POST /cards declares the Idempotency-Key header (same shape as the
money-movement operations); required for EU card programs.
- POST /cards and PATCH /cards/{id} declare 409 conflict responses via
the existing Error409 component (idempotency replay conflicts;
provider state conflicts).
- New CARD.TRANSACTION_UPDATE webhook type + CardTransactionUpdateWebhook
payload (mirrors CardStateChangeWebhook; data = CardTransaction),
registered in the webhook operations and card docs snippets.
Bundles regenerated with make build; lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kzvhCS9mZrkHaH7E5EQ94
Upstream #690 merged a canonical per-status card-transaction webhook contract (CARD_TRANSACTION.AUTHORIZED/PARTIALLY_SETTLED/SETTLED/ REFUNDED/EXCEPTION) that supersedes this branch's single CARD.TRANSACTION_UPDATE type. Drop CardTransactionUpdateWebhook and its operation/registration/enum value, rewrite the card doc snippets to the family semantics, and extend the family with CARD_TRANSACTION.DECLINED (webhook enum, operation description, and a declined example) to match the DECLINED terminal status this branch adds to CardTransactionStatus. Bundles regenerated with make build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kzvhCS9mZrkHaH7E5EQ94
The custom camelCase checker rejects the consecutive-capital hump in threeDSecurePassword, but threeDSecure is the industry-standard casing for 3DS and the field matches the EU provider's wire name. Recorded as a deliberate exception in the lint-ignore file (same mechanism as the existing no-ambiguous-paths entry) rather than renaming across the SDK and server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kzvhCS9mZrkHaH7E5EQ94
| description: >- | ||
| Optional static 3-D Secure password to set on the card, for card | ||
| programs whose issuer challenges online purchases with a static | ||
| password factor (EU/Striga cards). 8-36 characters; Latin letters, | ||
| digits, and the issuer's allowed special characters. Grid forwards it | ||
| to the provider and never stores or returns it. When omitted, Grid | ||
| generates a strong password; the cardholder-facing view/change flow | ||
| is delivered through the secure card UI. Rejected for card programs | ||
| without a static-password challenge factor (US/Lithic cards). |
There was a problem hiding this comment.
The description mentions
EU/Striga cards and US/Lithic cards by provider name. The API contract is provider-agnostic in the rest of the spec (card programs are described by geography or feature set, not by the underlying vendor name), and leaking Striga / Lithic into a public description couples the documented interface to implementation details that may change. Describing the distinction by platform configuration keeps the contract stable.
| description: >- | |
| Optional static 3-D Secure password to set on the card, for card | |
| programs whose issuer challenges online purchases with a static | |
| password factor (EU/Striga cards). 8-36 characters; Latin letters, | |
| digits, and the issuer's allowed special characters. Grid forwards it | |
| to the provider and never stores or returns it. When omitted, Grid | |
| generates a strong password; the cardholder-facing view/change flow | |
| is delivered through the secure card UI. Rejected for card programs | |
| without a static-password challenge factor (US/Lithic cards). | |
| description: >- | |
| Optional static 3-D Secure password to set on the card, for card | |
| programs whose issuer challenges online purchases with a static | |
| password factor (EUR-funded EU card programs). 8-36 characters; Latin | |
| letters, digits, and the issuer's allowed special characters. Grid | |
| forwards it to the provider and never stores or returns it. When | |
| omitted, Grid generates a strong password; the cardholder-facing | |
| view/change flow is delivered through the secure card UI. Rejected for | |
| card programs without a static-password challenge factor (USD-funded | |
| card programs). |
Prompt To Fix With AI
This is a comment left during a code review.
Path: openapi/components/schemas/cards/CardCreateRequest.yaml
Line: 27-35
Comment:
The description mentions `EU/Striga cards` and `US/Lithic cards` by provider name. The API contract is provider-agnostic in the rest of the spec (card programs are described by geography or feature set, not by the underlying vendor name), and leaking `Striga` / `Lithic` into a public description couples the documented interface to implementation details that may change. Describing the distinction by platform configuration keeps the contract stable.
```suggestion
description: >-
Optional static 3-D Secure password to set on the card, for card
programs whose issuer challenges online purchases with a static
password factor (EUR-funded EU card programs). 8-36 characters; Latin
letters, digits, and the issuer's allowed special characters. Grid
forwards it to the provider and never stores or returns it. When
omitted, Grid generates a strong password; the cardholder-facing
view/change flow is delivered through the secure card UI. Rejected for
card programs without a static-password challenge factor (USD-funded
card programs).
```
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
79ce279 to
795f169
Compare
| when a pull or confirmation fails (`CARD_TRANSACTION.EXCEPTION`). | ||
| The payload carries the full `CardTransaction` resource. | ||
| (`CARD_TRANSACTION.AUTHORIZED`), when an authorization is declined by | ||
| the issuer or program (`CARD_TRANSACTION.DECLINED`), as clearings |
There was a problem hiding this comment.
This introduces the DECLINED terminal status and CARD_TRANSACTION.DECLINED webhook, but sandbox-testing.mdx's decline-outcome table (descriptor suffixes 002/003) wasn't updated beyond the family rename — it never says these now resolve to status DECLINED / fire CARD_TRANSACTION.DECLINED, unlike EXCEPTION's explicit suffix-006 mapping. Without that, there's no documented way for integrators to trigger or verify the new status in sandbox.
| - PARTIALLY_SETTLED | ||
| - SETTLED | ||
| - REFUNDED | ||
| - DECLINED |
There was a problem hiding this comment.
Nit (optional): DECLINED is enum position 5 — I'd move it to position 2 to match the description table order and reflect that it's an immediate alternative to AUTHORIZED rather than a state preceding EXCEPTION.
| ## Card-transaction lifecycle | ||
| ## The CARD_TRANSACTION.* family | ||
|
|
||
| Fires on every state transition of a `CardTransaction` — the webhook |
There was a problem hiding this comment.
Now that this event only fires on state transitions and the type itself names the new status, how do we deliver reconciliation updates that don't change CardTransaction.status — e.g. a partial refund that stays SETTLED, or another partial clearing? Is there still a channel for those, or is that intentionally out of scope here?
|
@greptileai review this |
|
@faraday review this |
| settlementSummary: | ||
| count: 0 | ||
| totalAmount: 0 | ||
| authorizedAt: '2026-05-09T11:20:00Z' |
There was a problem hiding this comment.
In the declined example, authorizedAt is set to a real timestamp even though this authorization was never approved. The field description says "When the auth was approved," which is confusing for anyone generating fixtures off this example. Could we broaden the description (e.g. "when the authorization was processed") or note what it means on DECLINED rows specifically?
| minLength: 8 | ||
| maxLength: 36 | ||
| description: >- | ||
| Optional static 3-D Secure password to set on the card, for card |
There was a problem hiding this comment.
Nit (optional): the threeDSecurePassword description calls out providers by brand name (EU/Striga vs US/Lithic) in public contract docs. Let's describe this by platform funding config (EUR-funded vs USD-funded) instead, matching how the Idempotency-Key description phrases the same distinction — keeps the public docs from coupling to vendor names.
| ## Card-transaction lifecycle | ||
| ## The CARD_TRANSACTION.* family | ||
|
|
||
| Fires on every state transition of a `CardTransaction` — the webhook |
There was a problem hiding this comment.
Question: since this family fires only on state transitions, does a same-status mutation get its own event — e.g. another partial clearing while still PARTIALLY_SETTLED, or a second partial return while REFUNDED? If not, could pullSummary/refundSummary/settlementSummary go stale between transitions for consumers relying on these webhooks for reconciliation?
| | Status | Description | | ||
| |--------|-------------| | ||
| | `AUTHORIZED` | The auth has been approved and a hold placed on the funding source; no clearing has arrived yet. | | ||
| | `DECLINED` | The authorization was declined by the issuer or program; no funds moved and the transaction is terminal. | |
There was a problem hiding this comment.
Question: should CardTransaction expose a decline code/reason alongside DECLINED? The existing card flow already distinguishes cases like INSUFFICIENT_FUNDS vs CARD_PAUSED vs issuer/program declines and says that code is visible on the resulting transaction — is leaving it out here intentional, or should it be surfaced too?
US programs link merchant refunds to the original purchase (REFUNDED status, refundedAmount/refundSummary update). EU programs receive refunds from the network without a purchase reference, so they surface as standalone CREDIT-direction card transactions and never update the purchase's refund fields. Stated on the CardTransaction schema and the reconciliation guide so platforms reconcile EU refunds by merchant/amount/time instead of parent linkage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kzvhCS9mZrkHaH7E5EQ94
|
@greptileai review this |
|
@faraday review this |
The repo runs the same conventions through BOTH redocly and spectral; the redocly lint-ignore entry silenced one linter while spectral (which has no ignore-file mechanism) kept erroring - the source of the pass/fail flapping on this check. Adds the mirrored per-pointer override with the same rationale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kzvhCS9mZrkHaH7E5EQ94
| the card program's provider links refunds to the original purchase | ||
| (US programs). EU card programs receive merchant refunds WITHOUT a | ||
| purchase reference, so those refunds surface as standalone | ||
| CREDIT-direction card transactions and never update the purchase's |
There was a problem hiding this comment.
This description says EU merchant refunds surface as standalone CREDIT-direction card transactions, but the direction field elsewhere in this schema documents that card transactions debit the customer's account (and only gives a DEBIT example). That's a direct contradiction for a valid EU refund response — generated clients (and anyone reading this spec) get two mutually exclusive answers for what direction should be. Can we reconcile these, e.g. by calling out CREDIT as a valid direction value in its own description?
| settlementSummary: | ||
| count: 0 | ||
| totalAmount: 0 | ||
| authorizedAt: '2026-05-09T11:20:00Z' |
There was a problem hiding this comment.
This declined example populates authorizedAt, but CardTransaction.yaml documents authorizedAt as "when the auth was approved" and marks it required — a DECLINED auth was never approved, so this is our only DECLINED fixture and it's encoding the wrong invariant. Since authorizedAt is required we can't just drop it from the example; this needs a schema-side fix, either loosen the requirement for DECLINED or adjust the description so it doesn't imply approval.
|
@greptileai review this |
|
@faraday review this |
| the card program's provider links refunds to the original purchase | ||
| (US programs). EU card programs receive merchant refunds WITHOUT a | ||
| purchase reference, so those refunds surface as standalone | ||
| CREDIT-direction card transactions and never update the purchase's |
There was a problem hiding this comment.
refundedAmount's description says EU merchant refunds surface as standalone CREDIT-direction transactions, but that contradicts direction's description just above it ("Card transactions debit the customer's account", example DEBIT) in this same schema. Generated clients end up with mutually exclusive semantics for a valid EU refund response — let's reconcile these two descriptions.
| settlementSummary: | ||
| count: 0 | ||
| totalAmount: 0 | ||
| authorizedAt: '2026-05-09T11:20:00Z' |
There was a problem hiding this comment.
This declined example sets authorizedAt ('2026-05-09T11:20:00Z'), but CardTransaction docs authorizedAt as "when the auth was approved" and marks it required — a declined auth was never approved, so this fixture encodes a contradiction. This needs a schema-side fix (redescribe authorizedAt or relax required for declines), not just a tweak to the example.
Contract prerequisite (C1) for the Striga-under-Grid card work (design: lightsparkdev/webdev#30261; server-side stack lightsparkdev/webdev#30314 → #30403).
Changes
POST /cards: declares theIdempotency-Keyheader, mirroring the money-movement operations' shape. The server now requires it for card programs on EUR-funded (EU) platforms, where card creation is a non-idempotent fee-bearing provider call.POST /cards+PATCH /cards/{id}: declare the 409 responses the server actually emits (key replayed with a different body / same key still processing; provider-reported state conflicts), reusing the existingError409component. No new error codes invented.DECLINEDterminal status onCardTransactionStatus: declined authorizations are first-class transactions (no funds moved, terminal), needed for read parity with the EU provider's decline stream.CARD_TRANSACTION.DECLINEDwebhook type: extends theCARD_TRANSACTION.*family merged in feat(webhooks): add CARD_TRANSACTION webhook contract #690 (this branch originally proposed a singleCARD.TRANSACTION_UPDATEtype; rebased onto the merged family and dropped the competing type). Includes a declined delivery example and doc-snippet rework to the per-status family semantics.threeDSecurePassword(write-only, optional, 8-36 chars) on card create: static 3-D Secure challenge password pass-through for EU card programs; Grid generates when omitted; rejected on programs without a static-password factor.Bundles regenerated with
make build(Redocly).Known conformance note (not changed here)
The existing PATCH 409 prose says
state: CLOSEDon an already-CLOSED card returnsCARD_ALREADY_CLOSED— the server implementation (both providers) actually treats CLOSED→CLOSED as an idempotent 200. Reconciling contract-vs-implementation is part of the card contract-conformance workstream (design §10 Phase 0a).