# Stripe Shared Payment Tokens: five wire shapes I only found by shipping

> Source: <https://dev.to/hkarekar403/stripe-shared-payment-tokens-five-wire-shapes-i-only-found-by-shipping-3ch3>
> Published: 2026-08-20 14:24:18+00:00

Five response shapes from Stripe's Shared Payment Tokens preview API that I didn't find documented, captured from real test-mode responses.

Stripe's Shared Payment Tokens are the payment primitive for agentic commerce: a token that carries its own spend limits — max_amount, currency, expires_at — so an agent can pay without ever holding a card credential. The concept docs are good, and there are a few decent explainers around now.

What I couldn't find anywhere was the failure surface. Every write-up I read, including mine at first, covers the happy path: mint a token, charge it, done. Then you ship it, and the responses stop looking like the examples.

Below are five shapes captured from real test-mode responses while building a system that mints an SPT scoped to an exact approved amount and charges it. Each one cost me time. All are pinned in tests now, because prose is not evidence about a wire format.

This is against the 2026-04-22.preview version. Preview APIs move — treat these as observations with a date on them, not as a contract.

It doesn't. Stripe answers HTTP 402, which means your HTTP client throws, and the PaymentIntent you need is nested inside the error body:

HTTP 402

{

"error": {

"code": "card_declined",

"decline_code": "generic_decline",

"network_decline_code": "01",

"advice_code": "try_again_later",

"message": "Your card was declined.",

"charge": "ch_...",

"payment_intent": {

"id": "pi_...",

"object": "payment_intent",

"status": "requires_payment_method"

}

}

}

If your client treats a non-2xx as a transport failure, the exception escapes and you lose the decline entirely — no outcome, no record, just a stack trace. In my case that meant a run which had already held a seat and granted a spend authority ended with nothing written down.

The fix is narrow on purpose: convert an error envelope that carries an intent into a result, and rethrow everything else. A 401 or a missing preview header says nothing about whether money moved, so those must not be flattened into "declined".

And use decline_code, not code. code is the category (card_declined) and tells a reader nothing. The actual reason is one field lower, in decline_code (generic_decline). My own test asserted the wrong one for over a week.

Reach this branch with pm_card_chargeDeclined.

Charge a granted token whose underlying card is pm_card_authenticationRequired and you get:

HTTP 200

{

"status": "requires_action",

"next_action": {

"shared_payment_token_action": {},

"type": "shared_payment_token_action"

},

"amount_received": 0,

"last_payment_error": null

}

That's 3D Secure, seen from the seller's chair. Two things about it:

The action payload is empty. shared_payment_token_action: {} is what comes back — not trimmed for the docs, actually empty. You're told an action is required and handed nothing to perform it with. There is no redirect, no client secret to hand to a browser, nowhere to send the buyer. Which makes sense once you think about who the buyer is in an agentic flow: not present.

amount_received: 0. No money moved.

The practical rule: treat only status === "succeeded" as success. Not !== "failed", not "2xx means paid". A status you have never seen before must never read as success, because the alternative is telling someone their order is confirmed and then settling with your own money for funds that never arrived.

// at grant time

"payment_method_details": { "card": { "brand": "visa", "last4": "4242", ... }, "type": "card" }

// after a successful charge against the same token

"payment_method_details": null

I assumed this was a preview gap and asked. It's intended behaviour, confirmed. A partial capture consumes the token too, so there's no used-but-still-readable state to fall back on.

Related, and it cost me an hour on its own: the field is payment_method_details, not payment_method_preview. Guess wrong and optional chaining swallows it — no error, the brand just silently goes missing from your UI.

"usage_limits": { "currency": "aud", "expires_at": 1786185855, "max_amount": 120275 },

"usage_details": { "amount_captured": { "currency": "aud", "value": 0 } }

max_amount is a bare integer in minor units. amount_captured, three lines away, is { currency, value }.

Compare them directly and you get 120275 > [object Object], which is false, silently. Nothing throws. You just get a number on screen that is quietly wrong — and for a system whose entire claim is about spend limits, quietly wrong is the worst available failure.

The issued token (the agent's side) has a status field — active, requires_action, deactivated.

The granted token (the seller's side, spt_…) has no status field and no next_action. Those fields don't exist on it.

So when a charge comes back requires_action, the state blocking it is invisible on the only SPT object a seller can read. Neither party sees both halves. Don't write code that checks a granted token's status; there isn't one.

Two smaller things while we're here:

There is no list endpoint for granted tokens. Retrieve by id only. Store the id when you mint.

Exactly one webhook event exists: shared_payment.granted_token.deactivated, with the cause in deactivated_reason.

One trap that isn't a wire shape

If you get 403 "does not have access to this endpoint" from a rkcs_test_ sandbox key, that is very probably a key problem, not a regional-eligibility one. They are indistinguishable from the response.

I nearly concluded SPT was unavailable on an Australian account because of this. It wasn't — the sandbox key hadn't been claimed. Don't declare a feature unavailable in your region without minting against a properly claimed key first.

Where this came from

I built a policy-gated travel booking agent on top of this: it evaluates a request against a corporate travel policy and only then mints a token scoped to the exact approved fare. Rules that are a financial limit become usage_limits enforced by the network; rules that aren't — cabin class, carrier — are enforced by never minting a token at all.

The code is public, and every shape above is pinned in tests/stripe-token.test.ts against the real captured responses:

[https://github.com/hkarekar403/TG24-Stripe-Shared-payment-tokens](https://github.com/hkarekar403/TG24-Stripe-Shared-payment-tokens)

It runs offline with no keys and no network if you just want to see the flow:

npm install

INVENTORY_MODE=replay PAYMENTS_MODE=stub JOURNAL_PATH=:memory: npm start

If you've hit a shape I haven't, I'd genuinely like to know — the preview surface is bigger than what any one integration touches.
