Skip to main content
Download Markdown

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).

  • capture receives the transactionReference you returned from authorize. amount is optional — omit to capture the full amount, or pass a smaller value for a partial capture.
  • reverse voids an authorization that was never captured.
  • refund returns 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 CAPTURE or SALE (you may declare both). LoyaltyPlant chooses authorize for two-phase and sale for 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.

CapabilityUnlocksNotes
CAPTUREPOST /v1/authorize + POST /v1/captureTwo-phase
SALEPOST /v1/saleSingle-phase
DIRECT_REFUNDPOST /v1/refundRefund by transactionReference
REDIRECT_FLOWPENDING + redirectUrl from authorize/saleEnables 3DS/redirect (see §4)
DIRECT_WEBHOOKThe result callbackYour PSP webhooks you; you call LP back
STATUS_POLLINGPOST /v1/inquire-status + POST /v1/inquire-enrollment-statusReconciliation (see §3 and §5.5)
TOKENIZATIONPOST /v1/enroll + the enrollment callback + paymentInstrument.CARD_TOKENCard-on-file / one-click (see §5)
APPLE_PAY / GOOGLE_PAYpaymentInstrument.walletPayload in authorize/saleWallet payments (see §5.4) — declare independently of TOKENIZATION

Wallets don't need enrollment. APPLE_PAY and GOOGLE_PAY add no endpoint: the wallet payload arrives inside authorize/sale. TOKENIZATION is 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

  1. LoyaltyPlant calls POST /v1/authorize (or /v1/sale).

  2. Your PSP says "authentication required" and gives you a redirect URL.

  3. You return 200 PENDING with that redirectUrl and an optional expectedTtlSeconds:

    {
    "status": "PENDING",
    "transactionReference": "psp_txn_7HagaIn2",
    "redirectUrl": "https://acs.issuer-bank.example/3ds/challenge?token=…",
    "expectedTtlSeconds": 300
    }
    • transactionReference is mandatory here — LoyaltyPlant stores it and later matches your callback to this suspended payment by this value.
    • expectedTtlSeconds tells LoyaltyPlant how long to wait before treating the payment as timed out (default 15 minutes if omitted).
  4. LoyaltyPlant redirects the customer to redirectUrl. They authenticate.

  5. Your PSP notifies your service (its own webhook to an endpoint you own — LoyaltyPlant is not involved here).

  6. You verify that notification and call LoyaltyPlant back to report the final result (§4.2).

  7. 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" }
ItemValue
URLPOST {payments-service-host}/gate/integration/{integrationId}/result — the host and your integrationId come from onboarding
AuthAuthorization: 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-KeyA UUID. Retries are safe (see below).
BodyAn IntegrationOperationResponse — usually SUCCESS or DECLINED.
transactionReferenceMust match the value you returned in the PENDING response — that's how LoyaltyPlant finds the suspended payment.

Responses you may get back:

CodeMeaning
200 OKAccepted; the suspended payment was resumed (or had already resumed — a safe replay).
400Invalid payload (e.g. bad JSON, missing transactionReference).
401Invalid inbound token.
404Unknown 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 PENDING without a transactionReference → 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 transactionReference in the callback that differs from the one in the PENDING response → 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 OK from the callback as "charge the customer again" on retry — it's idempotent; just stop retrying.
  • ❌ Declaring REDIRECT_FLOW but 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

  1. 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" }
    }
  2. You answer 200 PENDING with your hosted card-entry page:

    {
    "status": "PENDING",
    "transactionReference": "psp_enr_9KdlaQ7",
    "redirectUrl": "https://checkout.your-psp.example/cards/add?session=9KdlaQ7",
    "expectedTtlSeconds": 900
    }
  3. The customer enters the card on your page and your PSP tells you it is tokenized.

  4. You deliver the token with the enrollment callback (§5.2).

  5. LoyaltyPlant saves the card; it appears in the customer's card list.

enrollmentId is the key that always works. Echo it in the callback and accept it in /v1/inquire-enrollment-status. transactionReference is yours and equally required in the callback, but if your PENDING response is ever lost in transit, enrollmentId is 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
}
}
ItemValue
AuthThe same inbound token as the payment result callback — never the outbound one.
statusTerminal only: SUCCESS (with card) or DECLINED. PENDING/RETRIABLE/UNKNOWN are rejected with 400.
card.tokenWhat LoyaltyPlant will send back as paymentInstrument.token on later payments.
card.maskedPanDisplay mask only. A full card number is rejected with 400.
CodeMeaning
200 OKCard bound — or already bound, a safe replay.
400Malformed body, non-terminal status, SUCCESS without card, or a full PAN.
401Invalid inbound token.
404No 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" }
}
typeField to readRequires
CARD_TOKENtoken — the token you issued at enrollmentTOKENIZATION
APPLE_PAYwalletPayload — opaque blob, forward verbatimAPPLE_PAY
GOOGLE_PAYwalletPayload — opaque blob, forward verbatimGOOGLE_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 enrollmentIdtransactionReference 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 successUrl instead of sending the callback → the token is discarded and the card is never bound.
  • ❌ Returning PENDING without transactionReference or without redirectUrl → rejected.
  • ❌ Sending SUCCESS in the callback without a card object → 400.
  • ❌ Sending the real card number as maskedPan400, and it would have been a PCI incident on our side.
  • ❌ Following a DECLINED callback with a later SUCCESSDECLINED closes the enrollment for good; start a new one instead.
  • ❌ Declaring TOKENIZATION without implementing /v1/enroll → every "add card" fails.

Next: choose your path — SDK Integration Guide → (Java) or API Integration Guide → (any language).