SDK Integration Guide (Java)
Build your integration on the JVM with the
payments-integration-sdk. You implement one interface; a ready-made controller exposes the endpoints. End-to-end, with a worked example against a fictional PSP called ExamplePay. This guide is self-contained — it includes the full SDK reference (interface, controller, builders, exceptions, error mapping, fixtures) inline.Not on the JVM? Use the API Integration Guide instead.
Read first: Overview & Concepts · Payment Flows
Prerequisites
- JDK 25 and a Spring Boot 4 application (the SDK builds on Spring Web).
- Maven or Gradle.
- A PSP account and a client library/HTTP wrapper for it (here:
ExamplePayClient). - A publicly reachable HTTPS host (LoyaltyPlant must be able to call you).
- The SDK release version from LoyaltyPlant.
You can build most of this before onboarding; you only need LoyaltyPlant-provisioned values
(tokens, integrationId, the callback host) to go live — see
General Requirements.
Note: The
payments-integration-sdkis not publicly available. LoyaltyPlant provides access after the project kickoff (see Overview → Integration project stages).
Step 1 — Add the dependency and create the app
<!-- The SDK: PaymentIntegration, the controller, builders, exceptions, ErrorCodeMapper.
Transitively pulls in payments-integration-api (the generated request/response DTOs). -->
<dependency>
<groupId>com.loyaltyplant.server.services</groupId>
<artifactId>payments-integration-sdk</artifactId>
<version>${payments-integration-sdk.version}</version>
</dependency>
<!-- Optional: ready-made test fixtures (IntegrationTestFixtures) -->
<dependency>
<groupId>com.loyaltyplant.server.services</groupId>
<artifactId>payments-integration-sdk</artifactId>
<version>${payments-integration-sdk.version}</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
- Use the release version LoyaltyPlant gives you (the in-repo version is a
-SNAPSHOT). spring-boot-starter-webis aprovideddependency of the SDK — your application supplies it (you are running a Spring Boot web app).- Generated DTOs live in
com.loyaltyplant.payments.integration.model.generated.v1; the generated API interfaces (which the controller implements) live incom.loyaltyplant.payments.integration.api.generated.v1. Both are generated from the OpenAPI spec — never edit them by hand (see Generated DTOs).
@SpringBootApplication
public class ExamplePayIntegrationApp {
public static void main(String[] args) {
SpringApplication.run(ExamplePayIntegrationApp.class, args);
}
}
Ensure PaymentIntegrationController is component-scanned. It lives in
com.loyaltyplant.payments.integration.sdk, so either add that to your @ComponentScan base
packages or register it explicitly:
@Configuration
@Import(com.loyaltyplant.payments.integration.sdk.PaymentIntegrationController.class)
class SdkConfig {}
That's all the wiring. PaymentIntegrationController is a @RestController that implements the
generated API interfaces and delegates to your PaymentIntegration bean — once that bean exists
(Step 2), all 8 endpoints go live automatically: /v1/authorize, /v1/sale, /v1/capture,
/v1/refund, /v1/reverse, /v1/inquire-status, /v1/capabilities, and /v1/health. The
controller binds the Authorization and Idempotency-Key headers as parameters, but it does
not validate the token or deduplicate requests — that's your responsibility (see Steps 4 and 8).
Step 2 — Implement PaymentIntegration
The general shape
PaymentIntegration (package com.loyaltyplant.payments.integration.sdk) is the one interface
you implement. Every payment operation has a default that throws IntegrationUnsupportedException
(→ HTTP 501), so you override only the methods whose capability you declare in
capabilities() (Step 3) — LoyaltyPlant never calls an operation you didn't declare. The two
management methods are separate: capabilities() is required, and health() is optional (the
SDK's default reports UP; override it to reflect real PSP connectivity).
| Method | Endpoint | Required when you declare | Default if not overridden |
|---|---|---|---|
IntegrationCapabilitiesResponse capabilities() | GET /v1/capabilities | always (abstract) | — must implement |
IntegrationHealthResponse health() | GET /v1/health | optional | returns UP |
IntegrationOperationResponse authorize(IntegrationAuthorizeRequest) | POST /v1/authorize | CAPTURE | throws 501 |
IntegrationOperationResponse capture(IntegrationCaptureRequest) | POST /v1/capture | CAPTURE | throws 501 |
IntegrationOperationResponse sale(IntegrationSaleRequest) | POST /v1/sale | SALE | throws 501 |
IntegrationOperationResponse refund(IntegrationRefundRequest) | POST /v1/refund | DIRECT_REFUND | throws 501 |
IntegrationOperationResponse reverse(IntegrationReverseRequest) | POST /v1/reverse | (no gating capability) | throws 501 |
IntegrationStatusInquiryResponse inquireStatus(IntegrationStatusInquiryRequest) | POST /v1/inquire-status | STATUS_POLLING | throws 501 |
The request objects share a small, predictable set of accessors:
- Every request carries
getPaymentId()(LoyaltyPlant's payment id — use it as your idempotency seed at the PSP) andgetSettings()— aMap<String,String>provisioned by LoyaltyPlant per client/outlet (currencyis always present,merchantIdis common; you read it, you never define it). - Initiation requests (
authorize/sale) addgetAmount()(major units, e.g.49.99— convert to minor units yourself if your PSP needs it) andgetCurrency()(ISO 4217). - Follow-up requests (
capture/refund/reverse) carrygetTransactionReference()— the reference you returned from the initiating operation.
The asynchronous result callback is not a method on this interface — your PSP webhook receiver and the call back to LoyaltyPlant are code you write (Step 5); the SDK just gives you the
IntegrationOperationResponseDTO andIntegrationResponseBuilderto build the body.
Pick the shape that matches how your PSP settles money, then jump to the matching example:
| Two-phase — 2a | Single-phase — 2b | |
|---|---|---|
| You declare | .capture() | .sale() |
| When money moves | held by authorize, settled later by capture | authorized and captured in one call |
| Use when | you capture on fulfilment, do partial captures, or void (reverse) before capture | you don't hold funds separately |
Implement one shape per integration. refund is independent of the choice — declare
DIRECT_REFUND and implement it in either.
2a. Two-phase (authorize + capture)
The primary flow: hold funds with authorize, settle later with capture. This richer example
adds 3-D Secure (redirectFlow), refunds, voids (reverse), and status polling for
reconciliation.
@Component
public class ExamplePayIntegration implements PaymentIntegration {
private final ExamplePayClient psp;
public ExamplePayIntegration(ExamplePayClient psp) {
this.psp = psp;
}
@Override
public IntegrationCapabilitiesResponse capabilities() {
return IntegrationCapabilitiesBuilder.builder()
.capture() // two-phase
.directRefund()
.redirectFlow() // 3DS
.directWebhook() // ExamplePay webhooks us; we call LP back
.statusPolling() // reconciliation
.build();
}
@Override
public IntegrationOperationResponse authorize(IntegrationAuthorizeRequest request) {
// `settings` is provisioned by LoyaltyPlant per partner/outlet (currency, your merchantId, …).
String merchantId = request.getSettings().get("merchantId");
ExamplePayResult result = psp.authorize(
request.getPaymentId(), // use as your idempotency seed at the PSP
request.getAmount(), // major units; convert to minor units if your PSP needs it
request.getCurrency(),
merchantId);
if (result.requires3ds()) {
// Suspend: hand LoyaltyPlant a redirect URL and your transaction reference.
return IntegrationResponseBuilder.pending(
result.transactionId(),
result.redirectUrl(),
300); // expected TTL in seconds
}
return IntegrationResponseBuilder.success(result.transactionId());
}
@Override
public IntegrationOperationResponse capture(IntegrationCaptureRequest request) {
// amount is optional — null means "capture the full authorized amount".
psp.capture(request.getTransactionReference(), request.getAmount());
return IntegrationResponseBuilder.success(request.getTransactionReference());
}
@Override
public IntegrationOperationResponse refund(IntegrationRefundRequest request) {
psp.refund(request.getTransactionReference(), request.getAmount());
return IntegrationResponseBuilder.success(request.getTransactionReference());
}
@Override
public IntegrationOperationResponse reverse(IntegrationReverseRequest request) {
psp.voidAuthorization(request.getTransactionReference());
return IntegrationResponseBuilder.success(request.getTransactionReference());
}
@Override
public IntegrationStatusInquiryResponse inquireStatus(IntegrationStatusInquiryRequest request) {
ExamplePayStatus s = psp.lookup(request.getTransactionReference());
var response = new IntegrationStatusInquiryResponse(mapStatus(s)); // AUTHORIZED/CAPTURED/…
response.setTransactionReference(request.getTransactionReference());
response.setRawStatus(s.raw());
return response;
}
}
2b. Single-phase (sale)
The simpler variant: a synchronous charge plus refunds. These capabilities mirror the
saleOnlyCapabilities() test fixture (Step 7).
@Component
public class ExamplePayIntegration implements PaymentIntegration {
private final ExamplePayClient psp;
public ExamplePayIntegration(ExamplePayClient psp) {
this.psp = psp;
}
@Override
public IntegrationCapabilitiesResponse capabilities() {
return IntegrationCapabilitiesBuilder.builder()
.sale() // single-phase: authorize + capture in one call
.directRefund()
.build();
}
@Override
public IntegrationOperationResponse sale(IntegrationSaleRequest request) {
String merchantId = request.getSettings().get("merchantId");
ExamplePayResult result = psp.charge(
request.getPaymentId(), // idempotency seed at the PSP
request.getAmount(), // major units; convert to minor units if your PSP needs it
request.getCurrency(),
merchantId);
return IntegrationResponseBuilder.success(result.transactionId());
}
@Override
public IntegrationOperationResponse refund(IntegrationRefundRequest request) {
psp.refund(request.getTransactionReference(), request.getAmount());
return IntegrationResponseBuilder.success(request.getTransactionReference());
}
}
That's a complete single-phase integration. You don't implement authorize / capture /
reverse — you didn't declare CAPTURE, so their defaults return 501, which is correct. Map
PSP failures (declines, timeouts) as shown in Step 6. If your single-phase flow can also
require 3-D Secure, return pending(...) exactly as the two-phase authorize() does above.
Notes:
- Implement one shape: in two-phase you don't implement
sale(); in single-phase you don't implementauthorize/capture/reverse. Undeclared operations fall through to the501default, which is correct. request.getSettings()is aMap<String,String>provisioned by LoyaltyPlant. Read keys you agreed during onboarding (currencyis always present;merchantIdis common).
Generated DTOs
The request/response models you pass and return are generated from the OpenAPI spec into
com.loyaltyplant.payments.integration.model.generated.v1:
IntegrationAuthorizeRequest, IntegrationSaleRequest, IntegrationCaptureRequest,
IntegrationRefundRequest, IntegrationReverseRequest, IntegrationOperationResponse,
IntegrationStatus, IntegrationCapability, IntegrationCapabilitiesResponse,
IntegrationHealthResponse, IntegrationStatusInquiryRequest,
IntegrationStatusInquiryResponse, PaymentErrorCode.
- Field-by-field documentation: the spec is the single source of truth (it generates these classes and their Javadoc).
- Do not edit generated sources. To change a model, change the spec and regenerate.
- Naming note: the wire value
FAILURE_3DS_CHECKbecomes the Java constantPaymentErrorCode.FAILURE_3_DS_CHECK(its.getValue()is still"FAILURE_3DS_CHECK").
Step 3 — Declare capabilities honestly
capabilities() (Step 2) is the contract LoyaltyPlant relies on — it only calls operations
whose capability you declared. Declare only what you've implemented and tested. Two-phase
integrations declare .capture(); single-phase integrations declare .sale() — see examples
2a and 2b above.
Build the response with the fluent IntegrationCapabilitiesBuilder
(package com.loyaltyplant.payments.integration.sdk):
| Method | Capability | Method | Capability |
|---|---|---|---|
.capture() | CAPTURE | .directWebhook() | DIRECT_WEBHOOK |
.sale() | SALE | .tokenization() | TOKENIZATION |
.directRefund() | DIRECT_REFUND | .statusPolling() | STATUS_POLLING |
.redirectFlow() | REDIRECT_FLOW | .applePay() | APPLE_PAY |
.googlePay() | GOOGLE_PAY | .supports(IntegrationCapability) | any (generic) |
Declaring the same capability twice is a no-op (de-duplicated).
tokenization(),applePay(),googlePay()have no dedicated method onPaymentIntegration. You support them insideauthorize/saleusing the payment data and the provisionedsettings. Declaring them tells LoyaltyPlant you accept those payment types.
Step 4 — Build responses (the response contract)
Use IntegrationResponseBuilder and let the controller turn your return values / exceptions into
the right HTTP response. Never return an HTTP error for a business decline — throw
IntegrationDeclinedException (→ 200 DECLINED) instead.
| Outcome | Do this |
|---|---|
| Approved | return IntegrationResponseBuilder.success(ref); |
| Needs 3DS/redirect | return IntegrationResponseBuilder.pending(ref, url, ttl); |
| Declined | throw new IntegrationDeclinedException(code, reason); |
| Transient/retry | throw new IntegrationRetriableException(reason, cause); |
| Not implemented | leave the method un-overridden (default → 501) |
Return value / exception → HTTP mapping
This is what makes the SDK enforce the response contract. For the payment operations
(authorize/sale/capture/refund/reverse):
| Your method… | Controller emits |
|---|---|
returns an IntegrationOperationResponse | 200 + that body (e.g. SUCCESS / PENDING) |
throws IntegrationDeclinedException(errorCode, reason) | 200 + { "status": "DECLINED", "errorCode", "reason" } |
throws IntegrationRetriableException(reason) | 200 + { "status": "RETRIABLE", "reason" } |
throws IntegrationUnsupportedException | 501 (non-retriable) |
| throws any other exception | 200 + { "status": "UNKNOWN", "reason": <message> } |
The two management/read endpoints differ slightly:
| Endpoint | Behavior |
|---|---|
inquireStatus | returns 200; IntegrationUnsupportedException → 501; any other exception → 500 |
getHealth | returns 200; any exception → 503 |
getCapabilities | returns 200 with your declared capabilities |
To return
PENDING, you must returnIntegrationResponseBuilder.pending(...)— there is no "pending exception". Exceptions only express declines, retriable, unsupported, or unknown.
Idempotency is yours to implement. The SDK only shapes responses — it does not deduplicate requests, and the
Idempotency-Keyheader is not passed to yourPaymentIntegrationmethod. Makeauthorize/sale/capture/refund/reverseidempotent so a retry never double-charges — either dedupe onpaymentId(or your PSP's own idempotency), or read theIdempotency-Keyheader yourself in a SpringFilter/HandlerInterceptor(the same place you validate the outbound token) and store(key → response). See the spec for the contract.
IntegrationResponseBuilder
Package: com.loyaltyplant.payments.integration.sdk. Static factories for the operation response.
Prefer these over constructing DTOs by hand.
| Factory | Produces |
|---|---|
success(String transactionReference) | { status: SUCCESS, transactionReference } |
pending(String transactionReference) | { status: PENDING, transactionReference } |
pending(String transactionReference, String redirectUrl, int expectedTtlSeconds) | { status: PENDING, transactionReference, redirectUrl, expectedTtlSeconds } |
declined(String errorCode, String reason) | { status: DECLINED, errorCode, reason } |
retriable(String reason) | { status: RETRIABLE, reason } |
unknown(String reason) | { status: UNKNOWN, reason } |
declined(errorCode, …)accepts the wire string of aPaymentErrorCode(e.g."NOT_ENOUGH_MONEY"). If the string isn't a known code it falls back toGATEWAY_ERROR, so pass a valid value — or useErrorCodeMapperto map a PSP code.
Step 5 — Asynchronous flow (3-D Secure) and the result callback
When authorize() returns PENDING, the payment is suspended at LoyaltyPlant until you
report the outcome. Two pieces of code you write yourself (the SDK doesn't generate them):
5a. Receive your PSP's webhook
ExamplePay calls a webhook on your service when the customer finishes 3-D Secure. This is your own endpoint — define it however you like.
@RestController
public class ExamplePayWebhookController {
private final ExamplePayClient psp;
private final LoyaltyPlantResultClient lpClient; // Step 5b
@PostMapping("/psp/examplepay/webhook")
public ResponseEntity<Void> onWebhook(@RequestBody String rawBody,
@RequestHeader("ExamplePay-Signature") String signature) {
// 1. ALWAYS verify the signature before trusting the payload.
if (!psp.verifyWebhookSignature(rawBody, signature)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
ExamplePayWebhook event = psp.parseWebhook(rawBody);
// 2. Report the final result to LoyaltyPlant.
IntegrationOperationResponse result = event.approved()
? IntegrationResponseBuilder.success(event.transactionId())
: IntegrationResponseBuilder.declined(
ErrorCodeMapper.mapToString(event.declineCode()), event.message());
lpClient.reportResult(event.transactionId(), result);
return ResponseEntity.ok().build(); // acknowledge to ExamplePay
}
}
5b. Call LoyaltyPlant back
POST the result to LoyaltyPlant's result endpoint. Authenticate with your inbound token
(not the outbound token you validate on incoming calls) and send an Idempotency-Key.
@Component
public class LoyaltyPlantResultClient {
private final HttpClient http = HttpClient.newHttpClient();
private final ObjectMapper mapper; // your JSON mapper
// All three values are provisioned by LoyaltyPlant during onboarding (store them securely).
@Value("${lp.base-url}") String lpBaseUrl; // e.g. https://payments.loyaltyplant.example
@Value("${lp.integration-id}") String integrationId;
@Value("${lp.inbound-token}") String inboundToken; // secret
public void reportResult(String transactionReference, IntegrationOperationResponse result) {
try {
String body = mapper.writeValueAsString(result); // transactionReference MUST be set & match the PENDING ref
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(lpBaseUrl + "/gate/integration/" + integrationId + "/result"))
.header("Authorization", "Bearer " + inboundToken)
.header("Idempotency-Key", UUID.randomUUID().toString())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
// Retry with the SAME Idempotency-Key on 5xx/timeout. 200 means resumed (or already resumed).
throw new IllegalStateException("LP result callback failed: HTTP " + resp.statusCode());
}
} catch (Exception e) {
// schedule a retry; replays are safe (idempotent by transactionReference)
throw new RuntimeException(e);
}
}
}
Critical details (see Payment Flows §4):
- The
transactionReferencein the callback must equal the one you returned in thePENDINGresponse — that's how LoyaltyPlant finds the suspended payment. - Use the inbound token. Using the outbound token →
401. - Retry until
200; replays are no-ops. - If you can't webhook, declare
STATUS_POLLINGso LoyaltyPlant can reconcile viainquireStatus.
Step 6 — Error handling
Map PSP failures to the contract. Use the SDK's ErrorCodeMapper to translate
PSP decline codes to a standard PaymentErrorCode.
@Override
public IntegrationOperationResponse authorize(IntegrationAuthorizeRequest request) {
try {
ExamplePayResult r = psp.authorize(/* … */);
return r.requires3ds()
? IntegrationResponseBuilder.pending(r.transactionId(), r.redirectUrl(), 300)
: IntegrationResponseBuilder.success(r.transactionId());
} catch (ExamplePayDeclined e) {
// business decline → 200 DECLINED
throw new IntegrationDeclinedException(ErrorCodeMapper.mapToString(e.code()), e.getMessage());
} catch (ExamplePayTimeout | IOException e) {
// transient → 200 RETRIABLE (LP may retry; keep it idempotent)
throw new IntegrationRetriableException("ExamplePay timeout", e);
}
// any other exception → the controller emits 200 UNKNOWN
}
Exception hierarchy
Package: com.loyaltyplant.payments.integration.sdk.exception. All extend IntegrationException
(a RuntimeException). Throw these from your PaymentIntegration methods; the controller maps
them per Step 4.
| Exception | Constructors | Controller result |
|---|---|---|
IntegrationException | (String), (String, Throwable) | base type (don't throw directly) |
IntegrationDeclinedException | (String errorCode, String reason) — exposes getErrorCode() | 200 DECLINED |
IntegrationRetriableException | (String), (String, Throwable) | 200 RETRIABLE |
IntegrationUnsupportedException | (String operation) → message "Operation not supported: <op>" | 501 |
IntegrationConfigurationException | (String), (String, Throwable) | (startup/config use; surfaces as UNKNOWN if thrown during an operation) |
ErrorCodeMapper
Package: com.loyaltyplant.payments.integration.sdk. Maps common ISO 8583 numeric codes and
widespread PSP string codes to a standard PaymentErrorCode. Falls back to GATEWAY_ERROR for
anything unrecognized.
| Method | Returns |
|---|---|
PaymentErrorCode fromPspCode(@Nullable String pspCode) | the mapped enum (or GATEWAY_ERROR) |
String mapToString(@Nullable String pspCode) | the mapped enum's wire value (for IntegrationResponseBuilder.declined) |
A few of the built-in mappings (see the source for the full table):
| PSP code | → PaymentErrorCode |
|---|---|
"51", "insufficient_funds" | NOT_ENOUGH_MONEY |
"54", "expired_card", "card_expired" | CARD_EXPIRED |
"82", "3ds_failed", "authentication_required" | FAILURE_3DS_CHECK |
"incorrect_cvc", "invalid_cvc" | FAILURE_SECRET_CODE_CHECK |
"fraudulent", "suspected_fraud" | ANTIFRAUD |
| (unrecognized) | GATEWAY_ERROR |
Step 7 — Test with the fixtures
The test-jar ships ready-made requests/responses (IntegrationTestFixtures, package
com.loyaltyplant.payments.integration.sdk):
| Fixture | Returns |
|---|---|
authorizeRequest() | an IntegrationAuthorizeRequest (99.99 USD) |
authorizeRequest(BigDecimal amount, String currency) | a customised authorize request |
saleRequest() | an IntegrationSaleRequest (49.99 USD) |
captureRequest(String transactionReference) | a capture request (99.99) |
refundRequest(String transactionReference) | a refund request (99.99) |
reverseRequest(String transactionReference) | a reverse request |
successResponse() | IntegrationResponseBuilder.success(...) |
pendingResponse() | a PENDING response with redirectUrl + 300s TTL |
declinedResponse() | a DECLINED response (NOT_ENOUGH_MONEY) |
twoPhaseCapabilities() | CAPTURE, DIRECT_REFUND, REDIRECT_FLOW, DIRECT_WEBHOOK |
saleOnlyCapabilities() | SALE, DIRECT_REFUND |
healthUp() / healthDown() | UP / DOWN health responses |
import static com.loyaltyplant.payments.integration.sdk.IntegrationTestFixtures.*;
import static org.junit.jupiter.api.Assertions.*;
class ExamplePayIntegrationTest {
ExamplePayClient psp = mock(ExamplePayClient.class);
ExamplePayIntegration integration = new ExamplePayIntegration(psp);
@Test
void declares_capabilities() {
var caps = integration.capabilities().getCapabilities();
assertTrue(caps.contains(IntegrationCapability.CAPTURE));
}
@Test
void authorize_success() {
when(psp.authorize(any(), any(), any(), any()))
.thenReturn(ExamplePayResult.approved("txn_123"));
var r = integration.authorize(authorizeRequest());
assertEquals(IntegrationStatus.SUCCESS, r.getStatus());
assertEquals("txn_123", r.getTransactionReference());
}
@Test
void authorize_requires_3ds_returns_pending() {
when(psp.authorize(any(), any(), any(), any()))
.thenReturn(ExamplePayResult.threeDs("txn_123", "https://acs.bank/3ds"));
var r = integration.authorize(authorizeRequest());
assertEquals(IntegrationStatus.PENDING, r.getStatus());
assertNotNull(r.getRedirectUrl());
}
}
Also test against the response contract (Step 4): assert declines surface as DECLINED (not
exceptions leaking as 500), and that an idempotent replay returns the same result.
Step 8 — Get provisioned by LoyaltyPlant
Your service is built; now LoyaltyPlant provisions you and runs certification. What you exchange
(tokens, integrationId, settings, limits, callback host), how the two tokens flow,
environments, activation states, and security are all in
General Requirements.
SDK note: enforce outbound-token validation (see General Requirements §4) in a Spring
Filter/HandlerInterceptorin front of/v1/**, or via Spring Security.
Step 9 — Pre-production checklist
Before go-live, walk the Integration Checklist. The big ones:
capabilities declared honestly, idempotency verified, the async result callback tested
against a real PENDING transaction, outbound-token validation enforced, and health()
reflecting your real PSP connectivity.
Versioning
The version segment in the endpoint paths (/v1/...) corresponds to the OpenAPI spec version
and the package suffix (...generated.v1). LoyaltyPlant records the apiVersion it should call
for your integration during onboarding; keep your deployed SDK
version and that apiVersion in step.
Troubleshooting
| Symptom | Likely cause |
|---|---|
LoyaltyPlant gets 501 for an operation you implemented | The method isn't overriding the interface method (signature mismatch), or the capability isn't declared. |
Every call returns 401 | Outbound-token validation rejecting valid tokens, or token not yet provisioned. |
| 3DS payments never complete | PENDING returned without a transactionReference, or the result callback isn't being sent / uses the wrong token / wrong transactionReference. |
| Declines look like errors to LoyaltyPlant | You're throwing a generic exception (→ UNKNOWN) instead of IntegrationDeclinedException (→ DECLINED). |
| Endpoints 404 | PaymentIntegrationController isn't component-scanned (see Step 1). |
Result callback returns 404 | Wrong integrationId, or integration not active yet. |
Result callback returns 401 | Using the outbound token instead of the inbound token. |
Next: Message & Error Codes →