From Authorization to Settlement: A Payment Is a Lifecycle
A payment isn’t a thing you fetch. It’s a thing that happens.
A card gets authorized in one request. The money gets captured in another, maybe the next morning when the warehouse confirms stock. A refund shows up a week later. A 3DS challenge parks the whole thing in a “waiting on the customer” limbo that resolves in a browser tab your server never sees. Confirmations trickle in by webhook, out of order, sometimes twice.
Most SDKs flatten all of that into a Payment object with a single status field. That shape is convenient, and it's exactly where things start to go wrong:
const payment = await sdk.payments.get(id)if (payment.status === "succeeded") {
await fulfillOrder(payment.order_id) // looks fine, ships a bug
}Here’s the catch: "succeeded" isn't one thing. A succeeded authorization and a succeeded capture are different events, and a success you polled for is different again from one that turned up in a settlement webhook.
Authorizing reserves the money.
Capturing actually moves it.
So if you fulfill on the authorization, you’ve shipped goods against a hold that can still be declined later, or just quietly expire.
And the code passes review.
The test card authorizes and captures in one go, so the demo is green and everyone moves on.
Then a real customer pays with a 3DS card, the else branch fires on a payment that actually went through, and you're debugging it in production.
Good SDKs hand you the whole lifecycle. The ones that don’t give you a noun with a status field, and let you write code that reads correctly and behaves wrong.
How Hyperswitch Prism Models It
Prism doesn’t hand you one mutable Payment to poke at. A payment has a position in a state machine, with named flows instead of a single status. Its PaymentStatus enum carries thirty-odd states, and they're grouped on purpose:
STARTED → AUTHENTICATION_PENDING → AUTHORIZED → CHARGED → (refund sub-process)
│ │ │
(3DS / SCA) (money reserved) (money moved)The API mirrors that machine.
There’s no status setter sitting on an object.
The lifecycle shows up as verbs spread across a few clients, and every call tells you where the payment ended up:
import {
PaymentClient, // get / capture / refund / void
MerchantAuthenticationClient, // open the session, mint client token
EventClient, // parse + verify asynchronous webhooks
types,
} from "hyperswitch-prism"await paymentClient.capture({ connectorTransactionId, /* ... */ })Notice capture is its own method, not update({ status: "captured" }).
You can’t fat-finger a capture onto a payment that was never authorized and have it pass as a harmless-looking status string.
The methods you can call are the transitions you’re actually allowed to make.
One Word, Three Enums
A flat status field is a trap because "succeeded" turns up in three different places that mean three different things. Prism gives each one its own type:
PaymentStatus.AUTHORIZEDvsPaymentStatus.CHARGED— reserved vs moved.- Webhook events are a separate enum:
PAYMENT_INTENT_AUTHORIZATION_SUCCESSvsPAYMENT_INTENT_CAPTURE_SUCCESS. - Both say “success.” Only one means you got paid.
- Refunds get their own enum again:
RefundStatus.REFUND_SUCCESS, with its own pending and failure states, because a refund is its own sub-process.
Cram all three into one status field and you're overloading it, and the overloading is where the bug lives.
Keep the flows apart and you read the one you actually meant.
Two Non-PCI Flows, Two Shapes
“Non-PCI” means the sensitive part (the card number, the PayPal login) gets entered in the connector’s own client-side SDK and never reaches your server.
Your backend only ever holds tokens and ids.
Prism supports this, but the lifecycle looks different from one connector to the next, which is another reason a flat status falls short.
Get Explorejeeva’s stories in your inbox
Join Medium for free to get updates from this writer.
Take Adyen and PayPal:
NON-PCI: the card / login is entered in the connector's client SDK.
Your server only holds tokens and ids — never raw card data.With Adyen, the card is authorized inside the Drop-in, and the result only reaches your server by webhook.
So authorizePayment can't just read a status.
It waits for the verified event, then picks up the pspReference from that webhook as the transaction id, which it needs for the capture you run separately:
// initiate: hand the browser a client token; the card stays client-side
const session = await authClient.createClientAuthenticationToken({ /* amount */ })
// → { clientToken, publishableKey } — server never sees the card// authorize: the outcome only exists once the verified webhook lands
const outcome = getWebhookOutcome(sessionId) // recorded by EventClient
// → AUTHORIZED + pspReference (capture is a later, separate call)PayPal works the other way around.
The order is created up front, the buyer approves it in the popup, and your server captures the approved order in one shot with AUTOMATIC capture:
// initiate: create the order server-side, hand the browser its id
const orderId = await createPaypalOrder({ /* amount, currency */ })
// → buyer approves orderId in the PayPal popup; no card on your server// authorize: capture the approved order using the JS-SDK token
const res = await paymentClient.authorize({
paymentMethod: { paypalSdk: { token: { value: orderId } } },
captureMethod: types.CaptureMethod.AUTOMATIC,
})
// → CHARGED (already captured; only refunds remain)Same vocabulary, two real paths.
Adyen ends on AUTHORIZED with a capture still owed.
PayPal ends on CHARGED right away.
A plain status field would call both of those "succeeded" and hide the fact that one of them still needs a capture.
The named states won’t let that slip.
Truth That Isn’t Synchronous
Sometimes the answer isn’t there yet when you ask for it.
In client-side flows the authorization decision lands later, over a webhook, so a synchronous read is really just a guess.
Prism treats it as a guess rather than pretending otherwise:
- A status read with nothing behind it yet comes back
PENDING, notERROR. - Unknown means “check again in a bit,” not “decline someone who already paid.”
- An async event has to prove itself before it can move state.
EventClientparses the webhook, checks the source, and only then maps it.- A webhook it can’t verify is just a forged transition, so it gets rejected, and there’s no dev-mode shortcut around that.
- Even the amount isn’t a plain number.
- It’s a currency-scaled minor unit, so
1000is ¥1000 in one currency and $10.00 in another.
The Takeaway
Treat a payment as a lifecycle and a few habits follow:
- Don’t branch on one
status. Ask the specific question: authorized, captured, or refunded? They're different flows. - Treat a sync read as provisional. Default unknowns to pending and let the verified event settle it.
- Never fulfill on authorization. Only a capture means you’ve been paid.
- Money is a currency-scaled integer, not a number.
A payment runs from authorization to settlement, and treating it as a lifecycle rather than a single status is the gap between code that works and code that only looks like it does.
Prism puts the lifecycle in front of you, with separate verbs, separate flows, and events it actually verifies, so the obvious thing to write tends to be the right thing too.
Reference
- Hyperswitch Prism on GitHub
https://github.com/juspay/hyperswitch-prism






