Skip to main content

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-sdk is 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-web is a provided dependency 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 in com.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).

MethodEndpointRequired when you declareDefault if not overridden
IntegrationCapabilitiesResponse capabilities()GET /v1/capabilitiesalways (abstract)— must implement
IntegrationHealthResponse health()GET /v1/healthoptionalreturns UP
IntegrationOperationResponse authorize(IntegrationAuthorizeRequest)POST /v1/authorizeCAPTUREthrows 501
IntegrationOperationResponse capture(IntegrationCaptureRequest)POST /v1/captureCAPTUREthrows 501
IntegrationOperationResponse sale(IntegrationSaleRequest)POST /v1/saleSALEthrows 501
IntegrationOperationResponse refund(IntegrationRefundRequest)POST /v1/refundDIRECT_REFUNDthrows 501
IntegrationOperationResponse reverse(IntegrationReverseRequest)POST /v1/reverse(no gating capability)throws 501
IntegrationStatusInquiryResponse inquireStatus(IntegrationStatusInquiryRequest)POST /v1/inquire-statusSTATUS_POLLINGthrows 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) and getSettings() — a Map<String,String> provisioned by LoyaltyPlant per client/outlet (currency is always present, merchantId is common; you read it, you never define it).
  • Initiation requests (authorize / sale) add getAmount() (major units, e.g. 49.99 — convert to minor units yourself if your PSP needs it) and getCurrency() (ISO 4217).
  • Follow-up requests (capture / refund / reverse) carry getTransactionReference() — 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 IntegrationOperationResponse DTO and IntegrationResponseBuilder to build the body.

Pick the shape that matches how your PSP settles money, then jump to the matching example:

Two-phase — 2aSingle-phase — 2b
You declare.capture().sale()
When money movesheld by authorize, settled later by captureauthorized and captured in one call
Use whenyou capture on fulfilment, do partial captures, or void (reverse) before captureyou 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 implement authorize / capture / reverse. Undeclared operations fall through to the 501 default, which is correct.
  • request.getSettings() is a Map<String,String> provisioned by LoyaltyPlant. Read keys you agreed during onboarding (currency is always present; merchantId is 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_CHECK becomes the Java constant PaymentErrorCode.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):

MethodCapabilityMethodCapability
.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 on PaymentIntegration. You support them inside authorize/sale using the payment data and the provisioned settings. 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.

OutcomeDo this
Approvedreturn IntegrationResponseBuilder.success(ref);
Needs 3DS/redirectreturn IntegrationResponseBuilder.pending(ref, url, ttl);
Declinedthrow new IntegrationDeclinedException(code, reason);
Transient/retrythrow new IntegrationRetriableException(reason, cause);
Not implementedleave 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 IntegrationOperationResponse200 + that body (e.g. SUCCESS / PENDING)
throws IntegrationDeclinedException(errorCode, reason)200 + { "status": "DECLINED", "errorCode", "reason" }
throws IntegrationRetriableException(reason)200 + { "status": "RETRIABLE", "reason" }
throws IntegrationUnsupportedException501 (non-retriable)
throws any other exception200 + { "status": "UNKNOWN", "reason": <message> }

The two management/read endpoints differ slightly:

EndpointBehavior
inquireStatusreturns 200; IntegrationUnsupportedException501; any other exception → 500
getHealthreturns 200; any exception → 503
getCapabilitiesreturns 200 with your declared capabilities

To return PENDING, you must return IntegrationResponseBuilder.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-Key header is not passed to your PaymentIntegration method. Make authorize/sale/capture/refund/reverse idempotent so a retry never double-charges — either dedupe on paymentId (or your PSP's own idempotency), or read the Idempotency-Key header yourself in a Spring Filter/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.

FactoryProduces
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 a PaymentErrorCode (e.g. "NOT_ENOUGH_MONEY"). If the string isn't a known code it falls back to GATEWAY_ERROR, so pass a valid value — or use ErrorCodeMapper to 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 transactionReference in the callback must equal the one you returned in the PENDING response — 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_POLLING so LoyaltyPlant can reconcile via inquireStatus.

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.

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

MethodReturns
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 codePaymentErrorCode
"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):

FixtureReturns
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/HandlerInterceptor in 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

SymptomLikely cause
LoyaltyPlant gets 501 for an operation you implementedThe method isn't overriding the interface method (signature mismatch), or the capability isn't declared.
Every call returns 401Outbound-token validation rejecting valid tokens, or token not yet provisioned.
3DS payments never completePENDING returned without a transactionReference, or the result callback isn't being sent / uses the wrong token / wrong transactionReference.
Declines look like errors to LoyaltyPlantYou're throwing a generic exception (→ UNKNOWN) instead of IntegrationDeclinedException (→ DECLINED).
Endpoints 404PaymentIntegrationController isn't component-scanned (see Step 1).
Result callback returns 404Wrong integrationId, or integration not active yet.
Result callback returns 401Using the outbound token instead of the inbound token.

Next: Message & Error Codes →