# 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](01-overview-and-concepts.md)

> 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](/payments/api/)** and the
> **[SDK guide](04-sdk-integration-guide.md)** (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).

```mermaid
sequenceDiagram
    participant LP as LoyaltyPlant
    participant You as Your service
    participant PSP
    LP->>You: POST /v1/authorize
    You->>PSP: authorize (hold funds)
    You-->>LP: 200 SUCCESS
    Note over LP,PSP: later…
    LP->>You: POST /v1/capture
    You->>PSP: capture (settle funds)
    You-->>LP: 200 SUCCESS
    Note over LP,PSP: …or, instead of capture
    LP->>You: POST /v1/reverse
    You->>PSP: void (release hold)
    You-->>LP: 200 SUCCESS
```

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

```mermaid
sequenceDiagram
    participant LP as LoyaltyPlant
    participant You as Your service
    participant PSP
    LP->>You: POST /v1/sale
    You->>PSP: charge
    PSP-->>You: captured
    You-->>LP: 200 SUCCESS
```

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

| 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_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:

```json
{ "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`:

   ```json
   {
     "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.

```mermaid
sequenceDiagram
    participant LP as LoyaltyPlant
    participant You as Your service
    participant PSP as Your PSP
    participant Cust as Customer
    LP->>You: POST /v1/authorize
    You->>PSP: create payment
    PSP-->>You: needs 3DS
    You-->>LP: 200 PENDING (redirectUrl, transactionReference)
    LP->>Cust: redirect to redirectUrl
    Cust->>PSP: authenticate (3DS)
    PSP-->>You: webhook (payment resolved)
    Note over You: verify signature
    You->>LP: POST /gate/integration/{id}/result
    Note over LP: resume suspended payment
```

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

```http
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](02-general-requirements.md#4-token-directionality-read-this-twice). |
| **`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 `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

```mermaid
sequenceDiagram
    participant LP as LoyaltyPlant
    participant You as Your service
    participant PSP as Your PSP
    participant Cust as Customer
    LP->>You: POST /v1/enroll (enrollmentId, returnUrls)
    You->>PSP: create card-entry session
    You-->>LP: 200 PENDING (redirectUrl, transactionReference)
    LP->>Cust: open redirectUrl
    Cust->>PSP: enters card details
    PSP-->>You: webhook (card tokenized)
    You->>LP: POST /gate/integration/{id}/enrollment-result
    Note over LP: card saved, visible to the customer
    LP->>Cust: navigate to returnUrls.successUrl
```

1. The customer taps "add card". LoyaltyPlant calls `POST /v1/enroll`:

   ```json
   {
     "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:

   ```json
   {
     "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

```http
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:

```json
{
  "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`:

```json
{
  "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:

```http
POST /v1/inquire-enrollment-status
{ "enrollmentId": "3f6c1b90-…", "transactionReference": "psp_enr_9KdlaQ7" }
```

```json
{ "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 `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 `maskedPan` → `400`, and it would have been a PCI
  incident on our side.
- ❌ Following a `DECLINED` callback with a later `SUCCESS` → `DECLINED` 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 →](04-sdk-integration-guide.md) (Java)
or [API Integration Guide →](05-api-integration-guide.md) (any language).
