API Integration Guide (any language)
Implement the Payments API specification 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 is faster.
Throughout, the PSP behind your service is the fictional ExamplePay — substitute your own.
Read first: Overview & Concepts · Payment Flows
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.
You can build and test everything before onboarding; you only need LoyaltyPlant-provisioned
values (tokens, integrationId, callback host) to go live — see
General Requirements.
Step 1 — Scaffold from the OpenAPI spec
The spec is the single source of truth — generate models/server stubs instead of hand-typing them:
# 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 — 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:
{ "capabilities": ["CAPTURE", "DIRECT_REFUND", "REDIRECT_FLOW", "DIRECT_WEBHOOK", "STATUS_POLLING"] }
TOKENIZATION,APPLE_PAY, andGOOGLE_PAYhave no dedicated endpoint — support them insideauthorize/saleusing the payment data and the provisionedsettings. Declaring them just tells LoyaltyPlant you accept those payment types.
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:
{ "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):
{ "status": "SUCCESS", "transactionReference": "psp_txn_7HagaIn2" }
{
"status": "PENDING",
"transactionReference": "psp_txn_7HagaIn2",
"redirectUrl": "https://acs.issuer-bank.example/3ds/challenge?token=…",
"expectedTtlSeconds": 300
}
{ "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(declareDIRECT_REFUND) — return funds after capture, bytransactionReference+amount(supports partial refunds).POST /v1/inquire-status(declareSTATUS_POLLING) — return the PSP's current status, for reconciliation when an outcome is ambiguous or a callback was lost:
{ "status": "CAPTURED", "transactionReference": "psp_txn_7HagaIn2", "rawStatus": "captured" }
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.
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.
# 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.
Step 5 — Asynchronous flow and the result callback
For 3-D Secure / redirect (declare REDIRECT_FLOW, and DIRECT_WEBHOOK if your PSP webhooks
you):
-
authorize/salereturnsPENDING+redirectUrl+ atransactionReference(mandatory). -
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 -
reportResultToLoyaltyPlantPOSTs the final result to LoyaltyPlant:
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
transactionReferencemust match the one from yourPENDINGresponse. - Expect
200 OK(resumed, or already resumed — a safe replay). Retry on5xx/timeout with the sameIdempotency-Key.400= bad payload,401= bad token,404= unknown/inactive integration.
Full flow, diagram, and pitfalls: Payment Flows §4.
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.
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):
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: 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 — then runs the certification test cases you
must pass before go-live.
Step 10 — Pre-production checklist
Walk the Integration Checklist 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. |
| 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 →