Payment Flows
How payments move through your integration: the lifecycle, capabilities, reconciliation, and — most importantly — asynchronous (3-D Secure / redirect) flows and the result callback.
Read first: Overview & Concepts
The cross-cutting rules every integration follows — the five outcomes (
SUCCESS/PENDING/DECLINED/RETRIABLE/UNKNOWN), the response contract (HTTP 200 +status; how LoyaltyPlant interprets your HTTP codes), idempotency, and the error-code catalogue (PaymentErrorCode) — live in the Payments API specification and the SDK guide (which includes the full SDK reference inline), not here. This page covers the flows only.
1. The payment lifecycle
Your integration supports one of two shapes, declared via capabilities.
1.1 Two-phase (CAPTURE)
Authorize first (hold), capture later (settle), or reverse (release).
capturereceives thetransactionReferenceyou returned fromauthorize.amountis optional — omit to capture the full amount, or pass a smaller value for a partial capture.reversevoids an authorization that was never captured.refundreturns funds after capture (supports partial amounts).
1.2 Single-phase (SALE)
One call authorizes and captures.
Refund later with POST /v1/refund (if you declare DIRECT_REFUND).
Declare
CAPTUREorSALE(you may declare both). LoyaltyPlant choosesauthorizefor two-phase andsalefor single-phase based on what you declare.
2. Capabilities & dispatch
GET /v1/capabilities returns the features you support. LoyaltyPlant caches the list and
only calls operations whose capability you declared.
| Capability | Unlocks | Notes |
|---|---|---|
CAPTURE | POST /v1/authorize + POST /v1/capture | Two-phase |
SALE | POST /v1/sale | Single-phase |
DIRECT_REFUND | POST /v1/refund | Refund by transactionReference |
REDIRECT_FLOW | PENDING + redirectUrl from authorize/sale | Enables 3DS/redirect (see §4) |
DIRECT_WEBHOOK | The result callback | Your PSP webhooks you; you call LP back |
STATUS_POLLING | POST /v1/inquire-status + POST /v1/inquire-enrollment-status | Reconciliation (see §3 and §5.5) |
TOKENIZATION | POST /v1/enroll + the enrollment callback + paymentInstrument.CARD_TOKEN | Card-on-file / one-click (see §5) |
APPLE_PAY / GOOGLE_PAY | paymentInstrument.walletPayload in authorize/sale | Wallet payments (see §5.4) — declare independently of TOKENIZATION |
Wallets don't need enrollment.
APPLE_PAYandGOOGLE_PAYadd no endpoint: the wallet payload arrives insideauthorize/sale.TOKENIZATIONis different — it has its own operation and its own callback, because a card has to be bound before it can be charged. Declare only what you have implemented: LoyaltyPlant never sends an instrument type whose capability you did not declare.
reverse has no gating capability — implement it whenever you support CAPTURE.
3. Reconciliation: status inquiry
When an outcome is ambiguous — a timeout, an UNKNOWN, or a PENDING whose callback never
arrived — LoyaltyPlant calls POST /v1/inquire-status (if you declare STATUS_POLLING) to
ask your PSP for the truth.
Return the PSP's current status:
{ "status": "CAPTURED", "transactionReference": "psp_txn_7HagaIn2", "rawStatus": "captured" }
status is one of AUTHORIZED, CAPTURED, VOIDED, REFUNDED, DECLINED, EXPIRED,
PENDING, NOT_FOUND. This is read-only and carries no Idempotency-Key. Implementing
status polling makes your integration far more robust against lost callbacks and network
blips — it is strongly recommended for any integration that uses async flows.
4. Asynchronous flows (3-D Secure / redirect) — the keystone
Some payments can't finish in the single HTTP call from LoyaltyPlant: the customer must complete 3-D Secure or be redirected to a bank/hosted page. This is the part integrations most often get wrong, so read it carefully.
4.1 The shape of an async payment
-
LoyaltyPlant calls
POST /v1/authorize(or/v1/sale). -
Your PSP says "authentication required" and gives you a redirect URL.
-
You return
200 PENDINGwith thatredirectUrland an optionalexpectedTtlSeconds:{
"status": "PENDING",
"transactionReference": "psp_txn_7HagaIn2",
"redirectUrl": "https://acs.issuer-bank.example/3ds/challenge?token=…",
"expectedTtlSeconds": 300
}transactionReferenceis mandatory here — LoyaltyPlant stores it and later matches your callback to this suspended payment by this value.expectedTtlSecondstells LoyaltyPlant how long to wait before treating the payment as timed out (default 15 minutes if omitted).
-
LoyaltyPlant redirects the customer to
redirectUrl. They authenticate. -
Your PSP notifies your service (its own webhook to an endpoint you own — LoyaltyPlant is not involved here).
-
You verify that notification and call LoyaltyPlant back to report the final result (§4.2).
-
LoyaltyPlant resumes the suspended payment and finishes the flow.
To use this flow, declare REDIRECT_FLOW (and DIRECT_WEBHOOK if your PSP webhooks you).
4.2 The result callback
After the payment resolves at your PSP, you report it to LoyaltyPlant with a single call. This callback is not in the set of endpoints you implement — it's an endpoint LoyaltyPlant exposes that you call.
POST https://<payments-service-host>/gate/integration/{integrationId}/result
Authorization: Bearer <inboundToken>
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/json
{ "status": "SUCCESS", "transactionReference": "psp_txn_7HagaIn2" }
| Item | Value |
|---|---|
| URL | POST {payments-service-host}/gate/integration/{integrationId}/result — the host and your integrationId come from onboarding |
| Auth | Authorization: Bearer <inboundToken> — your inbound token (the one LP gave you for calling back). This is not the outbound token you validate on incoming requests. See General Requirements §4. |
Idempotency-Key | A UUID. Retries are safe (see below). |
| Body | An IntegrationOperationResponse — usually SUCCESS or DECLINED. |
transactionReference | Must match the value you returned in the PENDING response — that's how LoyaltyPlant finds the suspended payment. |
Responses you may get back:
| Code | Meaning |
|---|---|
200 OK | Accepted; the suspended payment was resumed (or had already resumed — a safe replay). |
400 | Invalid payload (e.g. bad JSON, missing transactionReference). |
401 | Invalid inbound token. |
404 | Unknown or inactive integrationId. |
Retries & idempotency. Send an Idempotency-Key and retry the callback until you get a
200. Replays are safe: LoyaltyPlant matches the payment by transactionReference, so a
callback that arrives after the payment already resumed is a no-op. If you never send the
callback, the payment eventually times out (per expectedTtlSeconds) and, if you support
STATUS_POLLING, LoyaltyPlant may reconcile by asking your PSP directly.
4.3 Hosted payment pages
Because your service is publicly reachable, the redirectUrl you return can point at a page
you host — your own hosted payment page, a PSP drop-in/redirect, etc.
LoyaltyPlant simply sends the customer there; you own the page and the PSP acknowledgement.
4.4 Common async mistakes
- ❌ Returning
PENDINGwithout atransactionReference→ LoyaltyPlant can't match your later callback, and the payment hangs until it times out. - ❌ Sending the callback with the outbound token instead of the inbound token →
401. - ❌ A
transactionReferencein the callback that differs from the one in thePENDINGresponse → LoyaltyPlant can't find the payment (logged, no resume). - ❌ Not verifying the PSP webhook signature before trusting it → you may report a forged result. Always verify on your side.
- ❌ Treating a
200 OKfrom the callback as "charge the customer again" on retry — it's idempotent; just stop retrying. - ❌ Declaring
REDIRECT_FLOWbut never implementing the callback → every 3DS payment stalls.
5. Card enrollment and tokenization
Enrollment is how a customer's card gets bound so it can be charged later without
re-entering the card details. If you declare TOKENIZATION, this section is your contract.
The shape deliberately mirrors §4: PENDING + redirectUrl, then a server-to-server callback.
Same auth, same idempotency, same retry semantics — there is nothing new to learn beyond one
extra endpoint and one extra callback.
5.1 The enrollment flow
-
The customer taps "add card". LoyaltyPlant calls
POST /v1/enroll:{
"enrollmentId": "3f6c1b90-2d4e-4a11-9f77-0b2c5d8e4a10",
"customer": { "email": "customer@example.com", "name": "Jane Doe", "language": "en" },
"returnUrls": {
"successUrl": "https://…/payment-result/success",
"errorUrl": "https://…/payment-result/error",
"cancelUrl": "https://…/payment-result/cancelled"
},
"settings": { "currency": "USD", "merchantId": "acct_3092f1" }
} -
You answer
200 PENDINGwith your hosted card-entry page:{
"status": "PENDING",
"transactionReference": "psp_enr_9KdlaQ7",
"redirectUrl": "https://checkout.your-psp.example/cards/add?session=9KdlaQ7",
"expectedTtlSeconds": 900
} -
The customer enters the card on your page and your PSP tells you it is tokenized.
-
You deliver the token with the enrollment callback (§5.2).
-
LoyaltyPlant saves the card; it appears in the customer's card list.
enrollmentIdis the key that always works. Echo it in the callback and accept it in/v1/inquire-enrollment-status.transactionReferenceis yours and equally required in the callback, but if yourPENDINGresponse is ever lost in transit,enrollmentIdis the only identifier both sides still share.
5.2 The enrollment callback — the only way a token reaches us
POST https://<payments-service-host>/gate/integration/{integrationId}/enrollment-result
Authorization: Bearer <inboundToken>
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/json
{
"enrollmentId": "3f6c1b90-2d4e-4a11-9f77-0b2c5d8e4a10",
"transactionReference": "psp_enr_9KdlaQ7",
"status": "SUCCESS",
"card": {
"token": "tok_4gld6myqrxbu5g3gcxlqcwbxfa",
"maskedPan": "**** 4242",
"brand": "VISA",
"expiryMonth": 12,
"expiryYear": 2029
}
}
| Item | Value |
|---|---|
| Auth | The same inbound token as the payment result callback — never the outbound one. |
status | Terminal only: SUCCESS (with card) or DECLINED. PENDING/RETRIABLE/UNKNOWN are rejected with 400. |
card.token | What LoyaltyPlant will send back as paymentInstrument.token on later payments. |
card.maskedPan | Display mask only. A full card number is rejected with 400. |
| Code | Meaning |
|---|---|
200 OK | Card bound — or already bound, a safe replay. |
400 | Malformed body, non-terminal status, SUCCESS without card, or a full PAN. |
401 | Invalid inbound token. |
404 | No enrollment matches, it belongs to another integration, or it already expired. Nothing was stored — do not assume the card is bound. |
Never put the token on the browser redirect. returnUrls are navigation only. A token on
successUrl ends up in browser history and referrer logs, the redirect cannot be
authenticated, and it never fires at all if the customer closes the tab after your PSP already
bound the card. The callback is the source of truth; the redirect is cosmetics.
5.3 Tokenizing without a redirect
If your PSP tokenizes without sending the customer anywhere — client-side SDK tokenization,
for instance — answer /v1/enroll with SUCCESS and the card inline. No callback follows:
{
"status": "SUCCESS",
"transactionReference": "psp_enr_9KdlaQ7",
"card": { "token": "tok_…", "maskedPan": "**** 4242", "brand": "VISA",
"expiryMonth": 12, "expiryYear": 2029 }
}
5.4 Paying with a bound card or a wallet
Once a card is bound, authorize/sale carry a paymentInstrument:
{
"paymentId": "8e2b7d6a-1f3c-4e9a-9c2b-77a0e3d4f111",
"amount": 49.99,
"currency": "USD",
"settings": { "currency": "USD", "merchantId": "acct_3092f1" },
"paymentInstrument": { "type": "CARD_TOKEN", "token": "tok_4gld6myqrxbu5g3gcxlqcwbxfa" }
}
type | Field to read | Requires |
|---|---|---|
CARD_TOKEN | token — the token you issued at enrollment | TOKENIZATION |
APPLE_PAY | walletPayload — opaque blob, forward verbatim | APPLE_PAY |
GOOGLE_PAY | walletPayload — opaque blob, forward verbatim | GOOGLE_PAY |
The field is optional and absent for integrations that declared none of these
capabilities — nothing changes for you if you don't opt in. capture, refund and reverse
never carry it: they work off transactionReference, exactly as before.
Both token and walletPayload are secrets. Do not log them, do not echo them in reason,
do not store them outside your PSP integration.
5.5 Reconciling a lost enrollment callback
A lost enrollment callback is worse than a lost payment callback: the customer's card is live
at your PSP and invisible in the app, with nothing to detect it. If you declare
STATUS_POLLING, LoyaltyPlant asks you once before writing the enrollment off:
POST /v1/inquire-enrollment-status
{ "enrollmentId": "3f6c1b90-…", "transactionReference": "psp_enr_9KdlaQ7" }
{ "status": "COMPLETED", "transactionReference": "psp_enr_9KdlaQ7",
"card": { "token": "tok_…", "maskedPan": "**** 4242", "brand": "VISA" } }
status is COMPLETED (return the card), PENDING, DECLINED or NOT_FOUND. Resolve by
enrollmentId — transactionReference may be missing. Read-only, no Idempotency-Key.
Without this endpoint the enrollment simply expires (default 15 minutes, capped at 30) and the card is never saved.
5.6 Common enrollment mistakes
- ❌ Putting the card token on
successUrlinstead of sending the callback → the token is discarded and the card is never bound. - ❌ Returning
PENDINGwithouttransactionReferenceor withoutredirectUrl→ rejected. - ❌ Sending
SUCCESSin the callback without acardobject →400. - ❌ Sending the real card number as
maskedPan→400, and it would have been a PCI incident on our side. - ❌ Following a
DECLINEDcallback with a laterSUCCESS→DECLINEDcloses the enrollment for good; start a new one instead. - ❌ Declaring
TOKENIZATIONwithout implementing/v1/enroll→ every "add card" fails.
Next: choose your path — SDK Integration Guide → (Java) or API Integration Guide → (any language).