Skip to content

feat: Introduce the split payments extension#409

Open
raginpirate wants to merge 3 commits intomainfrom
raginpirate/split-payments
Open

feat: Introduce the split payments extension#409
raginpirate wants to merge 3 commits intomainfrom
raginpirate/split-payments

Conversation

@raginpirate
Copy link
Copy Markdown
Contributor

@raginpirate raginpirate commented May 2, 2026

Description

Adds the dev.ucp.shopping.split_payments extension, enabling buyers to pay with multiple payment instruments in a single checkout. Businesses declare which instrument combinations they support via allowed_combinations (a constraint language built on instrument groups with type/min/max). Instruments are submitted in allocation priority order, with each contribution either platform-specified (amount set) or business-determined (amount omitted, e.g. gift card balance).

This unlocks the development of redeemable payment instruments, which are functionally incomplete without the support of split payments to cover their remaining balance.

Category (Required)

  • Core Protocol: Changes to the base communication layer, global context, or breaking refactors. (Requires Technical Council approval)
  • Governance/Contributing: Updates to GOVERNANCE.md, CONTRIBUTING.md, or CODEOWNERS. (Requires Governance Council approval)
  • Capability: New schemas (Discovery, Cart, etc.) or extensions. (Requires Maintainer approval)
  • Documentation: Updates to README, or documentations regarding schema or capabilities. (Requires Maintainer approval)
  • Infrastructure: CI/CD, Linters, or build scripts. (Requires DevOps Maintainer approval)
  • Maintenance: Version bumps, lockfile updates, or minor bug fixes. (Requires DevOps Maintainer approval)
  • SDK: Language-specific SDK updates and releases. (Requires DevOps Maintainer approval)
  • Samples / Conformance: Maintaining samples and the conformance suite. (Requires Maintainer approval)
  • UCP Schema: Changes to the ucp-schema tool (resolver, linter, validator). (Requires Maintainer approval)
  • Community Health (.github): Updates to templates, workflows, or org-level configs. (Requires DevOps Maintainer approval)

Checklist

  • I have followed the Contributing Guide.
  • I have updated the documentation (if applicable).
  • My changes pass all local linting and formatting checks.
  • (For Core/Capability) I have included/updated the relevant JSON schemas.
  • I have regenerated Python Pydantic models by running generate_models.sh under python_sdk.
    ^ WIP for the python_sdk commit.

@raginpirate raginpirate self-assigned this May 2, 2026
@raginpirate raginpirate added the TC review Ready for TC review label May 2, 2026
@raginpirate raginpirate requested a review from a team as a code owner May 2, 2026 00:47
@raginpirate raginpirate requested review from a team as code owners May 2, 2026 00:47
Comment thread docs/specification/split-payments.md
Copy link
Copy Markdown
Collaborator

@drewolson-google drewolson-google left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A very straight-forward proposal, thanks @raginpirate. I left a few comments below, but the area I'm most unsure of is pre-defining of instrument groups.

Comment thread docs/specification/split-payments.md

A set of instruments is valid if it matches **any** combination in the array.

#### Instrument Group
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm struggling a bit to understand the need to be comprehensive with group definitions up-front. I worry you'll end up in one of two worlds:

  1. You attempt to be very specific with your configuration, which runs into an N x M problem with many combinations.
  2. You just have a single group with a min of 0 and a max of "some sufficiently large N" for each instrument type, effectively a free-for-all.

Might be worth talking through this tension live at TC.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the quick review Drew! Here's my POV:

  1. Up-front declaration is a requirement of the extension.

The dominant real-world pattern is narrow: "I support splitting a credit card with redeemables, and nothing else." That's a specific, declarative capability, and the platform needs it explicitly so it can render the right experiences. If a business doesn't declare combinations, the platform has to guess. The question isn't whether to declare, but how expressively.

  1. We shouldn't be dealing with an NxM problem for standard integrations, because of how the groups compose.

A group's types field is OR'd: one group can accept any of N instrument types. That collapses most of what would be N×M into a single combination. For example, "one primary instrument + up to 2 redeemables" with 4 primaries and 3 redeemable types is a single combination, not 12:

[
   { "types": ["card", "paypal", "apple_pay", "google_pay"], "min": 1, "max": 1 },
   { "types": ["gift_card", "store_credit", "loyalty"], "max": 2 }
 ]

Did you have a perspective of standard use cases this fails to keep concise?

Let me know if this was helpful! I'd be more than happy to chat live during a TC; sadly I will have to miss the one this Friday I think but we can always review during the next if this is not resolved async!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a specific example other than worrying this will mostly devolve into people having a single "everything" entry in the most common cases.

One thought -- would it be easier to model this as a deny-list rather than an allow-list?

Totally open to discussion here, async is fine for sure.

Copy link
Copy Markdown
Contributor Author

@raginpirate raginpirate May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Greatly appreciate the push here Drew!

I think the deny-list pattern could be worse ergonomics for the ecosystem as businesses easily add new method types and fail to introduce a denied_combinations that should have existed, instead of properly exposing their method for different kind of applicable payment splits. This leads to poor UX as splits repeatedly get rejected only at complete time. I'll also note that we have no example capability leaning on a deny vs. allow list pattern, and that the combinatorial complexity of the solution does not change regardless of if we pick deny vs. allow.

At one point while writing this I did allow businesses to omit the allowed_combinations, which defaults to "everything works" if merchants wanted it; it was removed because it seems like a dangerous tool (like the pushback above), and somewhat impractical for a first deliverable (who in commerce supports splitting everything by anything?).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the reply. I think we can take a "wait and see" approach on omitting allowed_combinations -- if a lot of folks are effectively doing it then we can make it easier. If not, no need.

Copy link
Copy Markdown
Collaborator

@drewolson-google drewolson-google left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A very straight-forward proposal, thanks @raginpirate. I left a few comments below, but the area I'm most unsure of is pre-defining of instrument groups.

@igrigorik igrigorik added this to the Working Draft milestone May 4, 2026
@kmcduffie kmcduffie self-assigned this May 4, 2026
@prasad-stripe
Copy link
Copy Markdown

Great work on this, @raginpirate. The capability-gated pattern is clean, allowed_combinations is a solid primitive, and the open-amount model maps naturally to how redeemables work in practice (as @ihoosain noted, Etsy's gift card flow fits this well). A few thoughts on scope and failure handling that I think would make this more robust:

Scoping to redeemables + fallback
The stated motivation is unlocking redeemable instruments, and every example in the spec follows the same shape: one or more redeemables (gift card, loyalty, store credit) with an open-loop fallback (card). In my experience, this is also the only multi-instrument pattern that sees real production traffic in online commerce. Arbitrary combinations (two credit cards, card + ACH) introduce a different class of problems: independent dispute timelines, partial authorization handling, cross-network retry semantics, etc.

Would it make sense to explicitly scope this version to redeemable + open-loop fallback? In my experience, the natural processing order is open-loop first: you authorize the card (the uncertain, externally-controlled leg), and only then debit the redeemable (which the business platform owns and can guarantee). If the card fails, nothing has been touched. This avoids the reversal problem entirely for the common case, keeps the complexity bounded, and gives us room to tackle the harder multi-network scenarios in a follow-up once we have answers for partial auth and dispute routing.

Relaxing the atomicity requirement
The current MUST ("void or reverse all previously successful authorizations and return payment_failed") concerns me. A few reasons:

  1. It conflicts with UCP's own principle that the business is MoR and authoritative over payment processing. The protocol is prescribing an operational policy where it should be defining a communication contract.
  2. In practice, when a second instrument soft-declines, the business may want to retry (with backoff) or escalate it to the buyer for an alternative. Mandating immediate void turns recoverable situations into hard failures. Worse, the void itself can fail or hit acquirer rate limits, creating a cascading problem.
  3. For the redeemable case specifically, if a gift card has been debited and the card auth fails, the merchant might prefer to hold the transaction open and let the buyer provide a different card, rather than refund the gift card and lose the sale.

IMO the protocol should require the business to report per-instrument status (succeeded, failed, pending) so the platform has visibility, but leave the failure policy to the business. A SHOULD default of void-all is reasonable, but a MUST removes agency from the party best positioned to make that call. Something like a failure_policy field in the business config ("void_all", "partial_allowed", "escalate") would let businesses declare their behavior without the protocol dictating it.

Processing order as a spec concern
The current spec says instruments are submitted in "allocation priority order" and the business MAY process in any order. I'd suggest the business platform should make the decision about the ordering, they are best positioned to make the call and remove the complexity of the "allocation priority order". This could be addressed in a fast follow-up by encoding in a split-strategy as suggested by @drewolson-google.

Excited to see this move forward. Happy to discuss further

@raginpirate
Copy link
Copy Markdown
Contributor Author

Thanks as well for your quick feedback @prasad-stripe! Great thoughts, let me clarify and I also just pushed up a fix based on your feedback ❤️

Scoping to redeemables + fallback

Arbitrary combinations (two credit cards, card + ACH) introduce a different class of problems: independent dispute timelines, partial authorization handling, cross-network retry semantics, etc.

I think the advertised capability enables a business who cannot abstract these concerns from the platform to not support anything but redeemables. I also am of the opinion that all of these concerns can be abstracted, and is only a concern to empower businesses who wants to do some potentially smarter things, some of which you touch on in your next paragraph...

The current MUST ("void or reverse all previously successful authorizations and return payment_failed") concerns me.

Lets dig into this!

It conflicts with UCP's own principle that the business is MoR and authoritative over payment processing. The protocol is prescribing an operational policy where it should be defining a communication contract.
In practice, when a second instrument soft-declines, the business may want to retry (with backoff) or escalate it to the buyer for an alternative. Mandating immediate void turns recoverable situations into hard failures. Worse, the void itself can fail or hit acquirer rate limits, creating a cascading problem.
For the redeemable case specifically, if a gift card has been debited and the card auth fails, the merchant might prefer to hold the transaction open and let the buyer provide a different card, rather than refund the gift card and lose the sale.

I agree theres tension here... Thank you for the pushback!

I think this is a disconnect between my intention vs. how I wrote this. Although I believe we should dictate this as a MUST, it is not a MUST IN REALTIME; the effect after a batch of UCP checkout submissions needs to behave this way, but it does not have to happen immediately. Let me take a stab at improving that phrasing!

With that, I think the above perspective might change? Hopefully we can see we're not mandating the payment processing layer, but mandating the side-effect of abandoned charges is NOT ok. And that businesses SHOULD have the flexibility to do any smart caching they need to 😄

IMO the protocol should require the business to report per-instrument status (succeeded, failed, pending) so the platform has visibility, but leave the failure policy to the business.

Great thinking, I definitely pondered this as well!
Right now, the spec mentions pushing error codes which help convey which instrument was problematic, and a successful instrument; is it actually important to use anything other than messages to convey this to the platform as well? It will be surfaced back with a fixed amount ready for the next submission, and if the buyer were to now remove it and pick a different instrument to cover the checkout that seems like acceptable commerce practice.
With that said, I'm not positive this makes sense to add to UCP, but if it does it might make sense as an improvement beyond the scope of this PR; what do you think?

remove the complexity of the "allocation priority order"

This feature is pretty key to the spec though; when you submit N instruments without an amount as a platform, the order you submit them in determines how much they would be charged. We'd go from allowing a customer at our register to say "charge gift card A, then B, then my card for the rest", to only say "charge these (gift card A, B, and card), I don't care how".

Greatly appreciate your input here and PTAL at my change to the MUST; does this move the needle for you or lmk some examples this is still problematic for!

@prasad-stripe
Copy link
Copy Markdown

Thanks @raginpirate, really appreciate the thoughtful response and the quick update. The eventual-consistency framing lands well for me. "Buyer MUST NOT remain charged for an incomplete split after checkout" is the right invariant, and allowing async reversal with retry/rate-limiter flexibility is exactly what I was hoping for.

I'm aligned on allocation priority as buyer intent (charge A first, then B, then card for the rest) being distinct from processing order. That separation makes sense.

One thing I'd like to push on: the re-submit flow. Your updated language mentions "wait and see if the buyer re-submits partially captured instruments," which is great, but from the platform's perspective there's no structured signal to act on. Today the only response is payment_failed in messages, which doesn't distinguish between:

  • "Failed permanently, I'm cleaning everything up"
  • "One leg failed, I'm holding the successful auth, send me a replacement instrument"

For the second case, the platform (or agent) needs to know: which instruments succeeded, which failed, and what amount is still outstanding. Without that, the agent can't prompt the buyer with "your card was declined, you still owe $45, want to try another?"

My suggestion: on a partial failure where the business is holding the checkout open, return per-instrument status (succeeded/failed/pending) and an amount_pending field indicating what's left to cover. This gives the platform everything it needs to drive the recovery flow without the protocol dictating how the business handles it internally. And importantly, the consumer should be able to cancel the open checkout at any point, which would trigger reversal of any instruments that already succeeded in earlier stages.

I think there's real value in addressing this alongside this PR since split payments without a structured recovery path will push every implementer to invent their own. But if you'd rather keep this PR focused and track it as an immediate follow-up, I'm OK with that too as long as it's sequenced before this extension moves beyond working draft.

Thanks again for the collaboration here, this is shaping up nicely.

@jamesandersen
Copy link
Copy Markdown
Contributor

Great discussion on the recovery flow, @prasad-stripe and @raginpirate. I think there's a low-complexity path to structured per-instrument status that doesn't require new schema fields.

+1 on the importance of platforms being able to convey per-instrument status to buyers — without it, the platform can't guide the user on what specifically needs to change to complete the transaction.

The existing message schemas already have a path field (RFC 9535 JSONPath). If the spec required the business to return one message per instrument on partial failure — payment_failed errors for failed instruments and info messages for successfully processed ones — the platform gets everything it needs to drive recovery:

{
  "status": "incomplete",
  "payment": {
    "instruments": [
      { "id": "pi_gc_1", "type": "gift_card", "amount": 1000, "..." : "..." },
      { "id": "pi_card_1", "type": "card", "..." : "..." }
    ]
  },
  "messages": [
    {
      "type": "info",
      "path": "$.payment.instruments[0]",
      "content": "Gift card authorized for $10.00"
    },
    {
      "type": "error",
      "code": "payment_failed",
      "path": "$.payment.instruments[1]",
      "content": "Card declined — insufficient funds",
      "severity": "recoverable"
    }
  ]
}

The platform can reconstruct the full picture from what's already in the response:

  • Instruments with an info message → succeeded (amount reflects actual charge)
  • Instruments with a payment_failed error → failed, with severity signaling whether recovery is possible
  • Outstanding amount = total - sum(succeeded amounts)

This is purely a spec-language clarification — no schema changes needed. It also pairs well with the updated eventual-consistency language: the business holds the checkout open, returns severity: recoverable on the failed leg, and the platform knows to prompt the buyer for a replacement rather than showing a terminal error.

@raginpirate
Copy link
Copy Markdown
Contributor Author

That’s exactly how I viewed it as well @jamesandersen! I think messages and paths work perfectly and I should have had more intentional examples of how to form those messages in the proposal.

Let me see what I can push to the PR later today and I’ll tag folks again when I do! 😄

@raginpirate raginpirate force-pushed the raginpirate/split-payments branch from 3b04598 to 77c26d4 Compare May 6, 2026 22:53
@raginpirate
Copy link
Copy Markdown
Contributor Author

Just pushed an update covering the open feedback from this round:

For @jamesandersen & @prasad-stripe — implemented the per-instrument status approach clarified above, pulling inspiration from the discounts capability! The Error Handling section now requires a payment_failed error per failed instrument with path pointing at the instrument, and
documents info messages as a recommended pattern for succeeded instruments to convey positive context. Added a "Partial Failure with Recovery" example end-to-end so implementers can see the full shape. @prasad-stripe lmk do you still have open concerns here about how we convey this context to platforms!
For @drewolson-google — hoping the deny-list discussion above and the strategies thread both feel resolved. Let me know if anything is still open on your end!

Thanks all for the thoughtful review — this is much sharper than what I originally opened!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

payments TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants