# API Integration Guide (any language)

> Implement the [Payments API specification](/payments/api/)
> as a REST service in any language/framework. This is the path for non-JVM stacks (Node, Go,
> Python, .NET, Ruby, PHP, …) or anyone who wants full control of the HTTP layer.
>
> On the JVM? The [SDK Integration Guide](04-sdk-integration-guide.md) is faster.
>
> Throughout, the PSP behind your service is the fictional **ExamplePay** — substitute your own.

Read first: [Overview & Concepts](01-overview-and-concepts.md) · [Payment Flows](03-payment-flows.md)

---

## Prerequisites

- A web framework in your language of choice.
- A publicly reachable HTTPS host (LoyaltyPlant must reach you; valid TLS certificate).
- A PSP account + the ability to call it and receive its webhooks.
- The OpenAPI spec: [`v1-integration-spec.yaml`](/payments/api/).

You can build and test everything before onboarding; you only need LoyaltyPlant-provisioned
values (tokens, `integrationId`, callback host) to go live — see
[General Requirements](02-general-requirements.md).

---

## Step 1 — Scaffold from the OpenAPI spec

The spec is the single source of truth — generate models/server stubs instead of hand-typing
them:

```bash
# Example: generate a server stub + models for your language with openapi-generator
openapi-generator-cli generate \
  -i payments-integration-api/protocol/v1-integration-spec.yaml \
  -g <your-generator>            # e.g. nodejs-express-server, go-server, python-fastapi, aspnetcore \
  -o ./generated
```

Render the spec in Swagger UI / Redoc for an interactive reference while you work. The spec
includes request/response examples for every operation and the async callback.

---

## Step 2 — Implement the endpoints (flow-first)

Ship incrementally: start with the two **management** endpoints (LoyaltyPlant probes them
first), then build your **primary payment flow** end to end, then the remaining operations. This
guide shows the wire shapes and the handler logic; the full request/response **schema and
examples for every operation live in the
[Payments API specification](/payments/api/)** — render
it in Swagger UI / Redoc and keep it open.

### Management endpoints

`GET /v1/capabilities` — return only the features you actually support; LoyaltyPlant calls only
those operations:

```json
{ "capabilities": ["CAPTURE", "DIRECT_REFUND", "REDIRECT_FLOW", "DIRECT_WEBHOOK", "STATUS_POLLING"] }
```

> `APPLE_PAY` and `GOOGLE_PAY` have **no dedicated endpoint** — the wallet payload arrives as
> `paymentInstrument.walletPayload` inside `authorize`/`sale`. `TOKENIZATION` does have its own
> operation (`POST /v1/enroll`) and its own callback — see
> [Card enrollment](03-payment-flows.md#5-card-enrollment-and-tokenization).

`GET /v1/health` — return `UP` when ready; `DOWN` (or HTTP `503`) when your PSP connection is
unhealthy so LoyaltyPlant stops routing live payments to you:

```json
{ "status": "UP" }
```

### 2a. Primary flow — two-phase (`authorize` + `capture`)

Declare `CAPTURE`. `authorize` holds funds, `capture` settles them later, `reverse` voids an
uncaptured hold. Every operation follows the same handler shape — validate the token (Step 3),
honour idempotency (Step 4), call your PSP, then map the outcome to a `200` response:

```
# POST /v1/authorize
function authorize(req):
    assertOutboundToken(req)                              # Step 3 — else 401
    if seen(req.idempotencyKey): return stored(req.idempotencyKey)   # Step 4

    psp = pspClient.authorize(req.paymentId, req.amount, req.currency, req.settings)

    if psp.requires3ds:                                   # finish later via the callback (Step 5)
        resp = { status: "PENDING", transactionReference: psp.txnId,
                 redirectUrl: psp.redirectUrl, expectedTtlSeconds: 300 }
    else if psp.approved:
        resp = { status: "SUCCESS", transactionReference: psp.txnId }
    else:
        resp = { status: "DECLINED", errorCode: mapToPaymentErrorCode(psp.code),   # Step 6
                 reason: psp.message }

    store(req.idempotencyKey, resp)
    return 200, resp
```

The three response shapes (all HTTP `200`):

```json
{ "status": "SUCCESS", "transactionReference": "psp_txn_7HagaIn2" }
```

```json
{
  "status": "PENDING",
  "transactionReference": "psp_txn_7HagaIn2",
  "redirectUrl": "https://acs.issuer-bank.example/3ds/challenge?token=…",
  "expectedTtlSeconds": 300
}
```

```json
{ "status": "DECLINED", "errorCode": "NOT_ENOUGH_MONEY", "reason": "Insufficient funds" }
```

`POST /v1/capture` and `POST /v1/reverse` take the `transactionReference` you returned from
`authorize` (capture also takes an optional `amount` — omit it to capture the full amount). Each
returns `200 SUCCESS` or `200 DECLINED`, same handler shape.

### 2b. Single-phase variant (`sale`)

If your PSP authorizes **and** captures in one step, declare `SALE` instead of `CAPTURE` and
implement `POST /v1/sale` — an identical handler to `authorize`, returning `SUCCESS` (or
`PENDING` if it can still trigger 3-D Secure). You then don't implement `authorize` / `capture` /
`reverse`. Implement **one** shape per integration; `refund` works with either.

### Follow-up operations

- `POST /v1/refund` (declare `DIRECT_REFUND`) — return funds after capture, by
  `transactionReference` + `amount` (supports partial refunds).
- `POST /v1/inquire-status` (declare `STATUS_POLLING`) — return the PSP's current status, for
  reconciliation when an outcome is ambiguous or a callback was lost:

```json
{ "status": "CAPTURED", "transactionReference": "psp_txn_7HagaIn2", "rawStatus": "captured" }
```

### Card enrollment (declare `TOKENIZATION`)

- `POST /v1/enroll` — start binding a card. Answer `PENDING` + `redirectUrl` +
  `transactionReference` with your hosted card-entry page, or `SUCCESS` + `card` if your PSP
  tokenizes without a redirect.
- `POST /gate/integration/{integrationId}/enrollment-result` — **you call LoyaltyPlant** with the
  token once the card is bound. This is the only channel that delivers a token; never put it on
  the browser redirect.
- `POST /v1/inquire-enrollment-status` (also gated by `STATUS_POLLING`) — reconciliation by
  `enrollmentId` before an enrollment expires.

Later payments then carry the bound card as `paymentInstrument`:

```json
{ "paymentId": "…", "amount": 49.99, "currency": "USD",
  "paymentInstrument": { "type": "CARD_TOKEN", "token": "tok_…" } }
```

Wallets use the same field with `type` `APPLE_PAY`/`GOOGLE_PAY` and `walletPayload` instead of
`token`. The field is absent unless you declared the matching capability. Full walkthrough:
[Card enrollment](03-payment-flows.md#5-card-enrollment-and-tokenization).

### The response contract

Business outcomes are **always** HTTP `200` with a `status` field; reserve HTTP `4xx`/`5xx` for
infrastructure errors; return `501` for an operation you don't implement. This is exactly how
LoyaltyPlant maps your HTTP status to an outcome — design your error handling around it:

| Your HTTP status | LoyaltyPlant treats it as | Retried? |
| --- | --- | --- |
| `200` + `status` body | the business outcome you sent | per `status` |
| `400`, `401`, `403`, `404` | `UNKNOWN` (non-retriable) | no |
| `408`, `429`, `500`, `502`, `503`, `504` | `RETRIABLE` | yes |
| `501` | `UNKNOWN` (operation not implemented) | no |

So for a **transient** failure use a `5xx` or `200 RETRIABLE` — **never** a `4xx`. A business
decline (insufficient funds, fraud, …) is **not** an HTTP error: return `200` + `DECLINED`. The
full contract and per-operation examples are in
[the spec](/payments/api/).

---

## Step 3 — Authenticate inbound requests (outbound token)

Validate the **outbound** token on **every** `/v1/*` request (constant-time compare) and reject
mismatches with `401`. The token model (outbound vs inbound) and the full security requirements
are in [General Requirements §4](02-general-requirements.md#4-token-directionality-read-this-twice).

```
# pseudocode middleware for /v1/*
expected = config.outboundToken
provided = request.header("Authorization").removePrefix("Bearer ")
if not constantTimeEquals(provided, expected):
    return 401
```

---

## Step 4 — Honour idempotency

Mutating endpoints (`authorize`, `sale`, `capture`, `refund`, `reverse`) carry an
`Idempotency-Key` (UUID). LoyaltyPlant **reuses the same key when it retries**, so:

- First request with a key → do the work, store `(integrationId, key) → response`.
- Repeat with the same key → return the stored response; do **not** charge again.

Persist the mapping (a table or your PSP's own idempotency support). Key it per integration.
See [the spec](/payments/api/).

---

## Step 5 — Asynchronous flow and the result callback

For 3-D Secure / redirect (declare `REDIRECT_FLOW`, and `DIRECT_WEBHOOK` if your PSP webhooks
you):

1. `authorize`/`sale` returns `PENDING` + `redirectUrl` + a `transactionReference` (mandatory).
2. Your PSP notifies **your** webhook endpoint when the customer finishes. Verify its signature,
   then report the result to LoyaltyPlant:

   ```
   # POST /psp/webhook — your own endpoint (the URL you registered with your PSP)
   function onPspWebhook(req):
       if not verifyPspSignature(req.body, req.headers):   # ALWAYS verify before trusting
           return 401
       event = parse(req.body)
       result = event.approved
           ? { status: "SUCCESS", transactionReference: event.txnId }
           : { status: "DECLINED", errorCode: mapToPaymentErrorCode(event.declineCode),
               reason: event.message }
       reportResultToLoyaltyPlant(result)   # the call below — retry until 200
       return 200                            # acknowledge to your PSP
   ```
3. `reportResultToLoyaltyPlant` POSTs the final result to LoyaltyPlant:

```bash
curl -X POST "https://<payments-service-host>/gate/integration/<integrationId>/result" \
  -H "Authorization: Bearer <INBOUND_TOKEN>" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "status": "SUCCESS", "transactionReference": "psp_txn_7HagaIn2" }'
```

- Use the **inbound** token (the one for calling LoyaltyPlant), **not** the outbound token you
  validate on incoming requests. Wrong token → `401`.
- The `transactionReference` **must match** the one from your `PENDING` response.
- Expect `200 OK` (resumed, or already resumed — a safe replay). Retry on `5xx`/timeout with the
  same `Idempotency-Key`. `400` = bad payload, `401` = bad token, `404` = unknown/inactive
  integration.

Full flow, diagram, and pitfalls:
[Payment Flows §4](03-payment-flows.md#4-asynchronous-flows-3-d-secure--redirect--the-keystone).

---

## Step 6 — Map errors to `PaymentErrorCode`

For `DECLINED`/`RETRIABLE`/`UNKNOWN`, set `errorCode` to a standard `PaymentErrorCode` when you
can. The full catalogue (with the responsible party per code) is the `PaymentErrorCode` schema
in [the spec](/payments/api/).
Map your PSP's decline codes to these (e.g. `insufficient_funds → NOT_ENOUGH_MONEY`,
`expired_card → CARD_EXPIRED`, `3ds_failed → FAILURE_3DS_CHECK`). Unmappable PSP code → pass it
through; LoyaltyPlant falls back to `GATEWAY_ERROR`.

> Send the **wire value** (e.g. `FAILURE_3DS_CHECK`), exactly as listed in the spec's enum.

---

## Step 7 — Test with curl

A few flows to exercise locally (set `BASE`, `TOK` first):

```bash
BASE=https://localhost:8443
TOK="Bearer test_outbound_token"

# capabilities
curl -s "$BASE/v1/capabilities" -H "Authorization: $TOK"

# health
curl -s "$BASE/v1/health" -H "Authorization: $TOK"

# sale (single-phase) — happy path
curl -s -X POST "$BASE/v1/sale" -H "Authorization: $TOK" \
  -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
  -d '{"paymentId":"p-1","amount":49.99,"currency":"USD","settings":{"currency":"USD","merchantId":"m-1"}}'

# authorize → capture (two-phase)
REF=$(curl -s -X POST "$BASE/v1/authorize" -H "Authorization: $TOK" \
  -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
  -d '{"paymentId":"p-2","amount":49.99,"currency":"USD","settings":{"currency":"USD"}}' \
  | jq -r .transactionReference)
curl -s -X POST "$BASE/v1/capture" -H "Authorization: $TOK" \
  -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
  -d "{\"paymentId\":\"p-2\",\"transactionReference\":\"$REF\"}"

# idempotent replay — same key must return the same result, no second charge
KEY=$(uuidgen)
for i in 1 2; do
  curl -s -X POST "$BASE/v1/sale" -H "Authorization: $TOK" \
    -H "Idempotency-Key: $KEY" -H "Content-Type: application/json" \
    -d '{"paymentId":"p-3","amount":49.99,"currency":"USD","settings":{"currency":"USD"}}'
done

# decline (business outcome → still HTTP 200)
curl -i -X POST "$BASE/v1/sale" -H "Authorization: $TOK" \
  -H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
  -d '{"paymentId":"p-4","amount":1.00,"currency":"USD","settings":{"currency":"USD"}}'
# expect: HTTP/1.1 200 OK  +  {"status":"DECLINED","errorCode":"...","reason":"..."}
```

Verify: business declines come back as `200 DECLINED` (not an HTTP error); the same
`Idempotency-Key` yields the same response; unimplemented operations return `501`.

---

## Step 8 — Security hardening

Apply the security requirements in
[General Requirements §5](02-general-requirements.md#5-security): TLS, outbound-token validation,
inbound-token protection, PSP webhook signature verification, a durable per-integration
idempotency store, optional IP allow-listing, and keeping secrets/PANs out of logs.

---

## Step 9 — Get provisioned by LoyaltyPlant

LoyaltyPlant provisions you (tokens, `integrationId`, `settings`, limits, callback host) — see
[General Requirements](02-general-requirements.md) — then runs the certification test cases you
must pass before go-live.

---

## Step 10 — Pre-production checklist

Walk the [Integration Checklist](07-integration-checklist.md) before go-live — especially the
async **result callback tested against a real PENDING transaction**, idempotency, and
outbound-token validation.

---

## Versioning

The version segment in the endpoint paths (`/v1/...`) is the contract version. LoyaltyPlant
records the `apiVersion` it should call for your integration during onboarding; keep your
deployed service and that `apiVersion` in
step, and regenerate your stubs from the spec whenever the version changes.

---

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| LoyaltyPlant treats transient failures as terminal | You returned `4xx` (mapped to non-retriable `UNKNOWN`). Use `5xx` or `200 RETRIABLE`. |
| Declines look like errors | You returned an HTTP error instead of `200` + `status: DECLINED`. |
| 3DS payments never complete | `PENDING` without `transactionReference`, or the result callback not sent / wrong token / mismatched `transactionReference`. |
| Result callback `401` | Used the outbound token instead of the inbound token. |
| Result callback `404` | Wrong `integrationId` or the integration isn't active yet. |
| Cards never appear after "add card" | Enrollment callback not sent (a token on `successUrl` is discarded), or sent without `card`, or the enrollment already expired (`404`). |
| Enrollment callback `400` | Non-terminal `status` (`PENDING`/`RETRIABLE`/`UNKNOWN`), `SUCCESS` without `card`, or a full PAN in `maskedPan`. |
| Double charges on retries | Not honouring `Idempotency-Key`. |
| LoyaltyPlant calls an operation you don't support | You declared its capability — remove it from `/v1/capabilities`. |

---

**Next:** [Message & Error Codes →](06-message-and-error-codes.md)
