Skip to content

Java SDK — Direct typed client

dev.paylera:paylera-sdk is the typed HTTP client at the bottom of the Java SDK stack. It depends on nothing Spring-specific — use it from Quarkus, Micronaut, Vert.x, a plain main(), or an AWS Lambda handler.

The Spring Boot starter sits on top of this client; if you’re on Spring Boot 3 the starter is the ergonomic path. Reach for the typed client directly when:

  • You’re not on Spring Boot.
  • You want full control over the OkHttpClient (connection pool, interceptors, proxy config).
  • You’re embedding the SDK in a CLI tool, scheduled job, or other short-lived process.
  • You only need server-to-server calls, not the merchant-backend relay.

Install

<dependency>
<groupId>dev.paylera</groupId>
<artifactId>paylera-sdk</artifactId>
<version>0.0.0-SNAPSHOT</version>
</dependency>

The SDK depends on okhttp, gson, commons-lang3, and (optionally) opentelemetry-api. No reflection-heavy frameworks pulled in transitively.

Build a client

import dev.paylera.sdk.PayleraClient;
var client = PayleraClient.builder()
.apiToken(System.getenv("PAYLERA_API_TOKEN"))
.build();

PayleraClient is immutable and thread-safe. Build it once, reuse it across the lifetime of the JVM. Multiple instances pointing at different tokens / base URLs run side-by-side without conflict — no global state.

Builder options

var client = PayleraClient.builder()
.apiToken(token)
.baseUrl("https://api.test.paylera.dev") // override token-prefix routing
.apiVersion("2026-05-01") // override DEFAULT_API_VERSION
.tracer(GlobalOpenTelemetry.getTracer("Paylera.Sdk"))
.build();
MethodDefault
apiToken(String)(required)
baseUrl(String)Routed from token prefix — pl_live_* → api.paylera.dev, pl_test_* → api.test.paylera.dev
apiVersion(String)"2026-05-01" (PayleraClient.DEFAULT_API_VERSION)
tracer(io.opentelemetry.api.trace.Tracer)no-op tracer

Make a call

import dev.paylera.sdk.model.CheckRequest;
import dev.paylera.sdk.model.CheckResponse;
CheckResponse resp = client.check(new CheckRequest()
.customerId(customerId)
.featureCode("api_calls")
.requiredUsage("1"));
if (Boolean.TRUE.equals(resp.getAllowed())) {
// gate passed
}

Every operation has a hand-written wrapper on PayleraClient. The wrappers handle base-URL routing, Paylera-Api-Version stamping, Idempotency-Key auto-generation, retries, exception translation, and tracing.

The full surface:

client.createCustomer(...)
client.listCustomers(...)
client.getCustomer(UUID)
client.getPlan(UUID)
client.listPlans(...)
client.getSubscription(UUID)
client.listSubscriptions(...)
client.cancelSubscription(UUID, body)
client.cancelSubscriptionImmediate(UUID, version, body)
client.pauseSubscription(UUID)
client.resumeSubscription(UUID)
client.changeSubscriptionPlan(UUID, body)
client.resolveSubscriptionEntitlements(UUID, asOf)
client.attach(body)
client.check(body)
client.checkByFeatureCode(featureCode, customerId, subscriptionId)
client.track(body)
client.createBillingPortalSession(UUID, [body])

Need an operation the wrapper doesn’t expose ergonomically? Drop down to client.rawApi() — that returns the generated dev.paylera.sdk.api.DefaultApi with every operation in the spec.

Idempotency

Operations that require an Idempotency-Key on the wire get a fresh UUID v7 (time-ordered, RFC 9562) stamped automatically. Pass an explicit key to share it across your own retry budget:

client.track(
new TrackRequest().customerId(id).featureCode("api_calls").value("1"),
"my-stable-dedup-key",
/* xRequestId */ null);

UUID v7 keeps the server-side idempotency store sortable by mint time — eviction is O(log n) rather than O(n).

Typed exceptions

Every 4xx/5xx is translated into a typed subclass of PayleraException based on the HTTP status + the code field in the RFC 9457 problem-details body:

SubclassWhen
PayleraAuthenticationExceptionHTTP 401
PayleraAuthorizationExceptionHTTP 403
PayleraNotFoundExceptionHTTP 404
PayleraIdempotencyConflictException409 + code = paylera.idempotency_conflict
PayleraIdempotencyInProgressException409 + code = paylera.idempotency_in_progress
PayleraPreconditionFailedExceptionHTTP 412 (optimistic-concurrency version mismatch)
PayleraRateLimitExceptionHTTP 429 — carries Duration retryAfter
PayleraServerExceptionHTTP 5xx + transport-level failures
PayleraException (base)any other 4xx

Every exception exposes:

e.getCode() // e.g. "paylera.invoice_not_found"
e.getDetail() // human-readable detail string
e.getRequestId() // Paylera-Request-Id for support tickets
e.isRetryable()
e.getErrors() // field-scoped list parsed from the problem-details body

Typical handling:

try {
client.check(req);
} catch (PayleraRateLimitException e) {
sleep(e.retryAfter());
return retry();
} catch (PayleraAuthenticationException e) {
// token revoked / rotated — pull a fresh secret
throw e;
} catch (PayleraException e) {
logger.warn("paylera error code={} req={} detail={}",
e.getCode(), e.getRequestId(), e.getDetail());
throw e;
}

Retries

Built-in retry on 5xx + 429:

  • 5 total attempts (1 initial + 4 retries)
  • base 200 ms, doubled per attempt, capped at 8 s
  • full-jitter scale on each delay
  • Retry-After header honoured on 429 when longer than the computed backoff
  • 4xx responses never retry — replaying a malformed request wastes quota and risks duplicate writes against non-idempotent endpoints

Retries are stamped with the same Idempotency-Key as the original request, so duplicate-write protection holds end-to-end.

Pagination

list* responses carry a cursor field; loop until null:

String cursor = null;
do {
var page = client.listSubscriptions(customerId, null, null, 100, cursor);
page.getData().forEach(sub -> {
// ...
});
cursor = page.getNextCursor();
} while (cursor != null);

A page-iterator helper is on the roadmap; for now the cursor loop is the canonical pattern.

Closing the client

PayleraClient doesn’t expose close() — the underlying OkHttpClient connection pool is cleaned up by the JVM at exit. In a short-lived process (CLI, Lambda) you don’t need to do anything; in a long-lived process you only need to close the client if you’ve constructed a custom OkHttpClient with a custom dispatcher that you’ve registered for shutdown elsewhere.

Quarkus / Micronaut / etc.

Wire PayleraClient as a singleton bean in your framework’s DI container:

// Quarkus
@ApplicationScoped
class PayleraClientProducer {
@Produces
@Singleton
PayleraClient payleraClient(@ConfigProperty(name = "paylera.api-token") String token) {
return PayleraClient.builder().apiToken(token).build();
}
}
// Micronaut
@Factory
class PayleraClientFactory {
@Bean
@Singleton
PayleraClient payleraClient(@Value("${paylera.api-token}") String token) {
return PayleraClient.builder().apiToken(token).build();
}
}

The webhook receiver and relay controller from the Spring starter are Spring-specific. For non-Spring frameworks you implement the receiver in your framework’s idiom and use dev.paylera.starter.PayleraWebhookVerifier (no Spring deps) to verify signatures, or write your own HMAC check against the protocol contract.

See also