Skip to content

SDK relay protocol

This document is the source of truth for the wire format every Paylera backend SDK speaks. The React SDK (@paylera/react) calls into the merchant’s own backend at /api/paylera/*; the relay handler in that backend (Node, .NET, Python, Go, or Java) authenticates the inbound request, identifies the caller, and forwards to api.paylera.dev with the secret token + customer context injected.

Every backend SDK exposes the same routes with the same request and response shapes. Cross-language conformance tests in sdks/_conformance/ verify this against recorded fixtures.

Trust boundary

┌────────────────────┐ ┌────────────────────────┐ ┌────────────────────┐
│ Merchant frontend │ ──▶ │ Merchant backend │ ──▶ │ Paylera API │
│ (React, browser) │ │ (Node / .NET / Python │ │ api.paylera.dev │
│ │ │ / Go / Java relay) │ │ │
│ Paylera-CSRF-Token │ │ Holds pl_live_ token, │ │ Validates token, │
│ on every state- │ │ resolves customer_id, │ │ applies RLS to the │
│ changing call │ │ forwards to Paylera │ │ merchant's tenant │
└────────────────────┘ └────────────────────────┘ └────────────────────┘
talks to uses pl_live_* sees only this
/api/paylera/* never seen by browser tenant's data

The secret API token never leaves the merchant’s server. The relay is the trust boundary; the browser holds only an anti-CSRF token bound to the authenticated session.

Relay routes — the ten

The merchant’s backend mounts the relay at any path; the convention is /api/paylera. Every backend SDK exposes exactly these ten relay routes:

#Relay routeForwards toAuth on relayIdempotency?
1POST /attachPOST /v1/attachsession + CSRFRequired
2POST /checkPOST /v1/checksession + CSRFOptional
3POST /trackPOST /v1/tracksession + CSRFRequired (auto-stamped)
4GET /entitlementsGET /v1/subscriptions/{id}/entitlementssessionN/A
5GET /meGET /v1/customers/{id} (or auto-create)sessionN/A
6GET /plansGET /v1/planssession (optional)N/A
7GET /invoicesGET /v1/customers/{id}/invoicessessionN/A
8POST /billing-portalPOST /v1/customers/{id}/billing-portal-sessionssession + CSRFRequired
9POST /subscriptions/{id}/upgradePOST /v1/subscriptions/{id}/change-plansession + CSRFRequired
10POST /subscriptions/{id}/cancelPOST /v1/subscriptions/{id}/cancelsession + CSRFRequired

A relay-only route — not forwarded — completes the picture:

Auxiliary routePurpose
GET /csrf-tokenMints a fresh CSRF token + sets the double-submit cookie. Browser-only; not forwarded.

1. POST /attach

One-shot signup. The merchant’s React app calls this when a customer picks a plan; the relay resolves the customer identity, optionally auto-creates a Paylera customer, opens a subscription against the requested plan in pending_activation, and returns a hosted checkout URL.

Request body (frontend → relay):

{
"plan_id": "8e4f1c0a-...",
"payment_provider": "stripe", // optional; "stripe" | "toss"; tenant default if omitted
"product_id": "5c9b0a3d-...", // optional; pre-flight check that plan ∈ product
"success_url": "https://acme.com/success?session={CHECKOUT_SESSION_ID}",
"cancel_url": "https://acme.com/pricing",
"trial_period_days": 14, // optional
"add_ons": [ // optional
{ "id": "...", "quantity": 1 }
]
}

The relay injects customer_id (or customer_email + customer_name) from identify() before forwarding. The frontend never supplies a customer_id directly — that would let one user attach a subscription to another user.

Response (Paylera → relay → frontend):

{
"customer_id": "9b1f...",
"subscription_id": "a7e2...",
"checkout_session_id": "cs_test_...",
"checkout_url": "https://checkout.stripe.com/c/pay/cs_test_...",
"checkout_expires_at": "2026-05-13T10:30:00Z"
}

Errors — RFC 9457 problem-details from Paylera are forwarded verbatim with status code preserved. Notable types:

  • paylera.attach_return_url_required — neither success_url+cancel_url nor return_url supplied
  • paylera.redirect_domain_not_allowed — strict-mode tenant rejected the redirect URL (see redirect-policy docs)
  • paylera.idempotency_conflict — same Idempotency-Key but different body

2. POST /check

Autumn-style runtime gate: “is this customer allowed to do X right now, and what’s left in the quota?”

Request body:

{
"feature_code": "api_calls",
"required_usage": 1 // optional; defaults to 0 (pure entitlement read)
}

The relay injects either customer_id or subscription_id from identify().

Response:

{
"allowed": true,
"feature_id": "f0b1...",
"feature_code": "api_calls",
"feature_name": "API calls",
"value_type": "metered", // boolean | numeric | text | metered | unlimited
"unit": "call",
"balance": 9876.0, // remaining quota (metered + credit-granted)
"included_usage": 10000.0,
"usage": 124.0,
"unlimited": false,
"interval": "calendar_month",
"cycle_start_at": "2026-05-01T00:00:00Z",
"next_reset_at": "2026-06-01T00:00:00Z",
"source": "plan_grant", // plan_grant | override | credit_grant
"value": { "type": "metered", "n": 10000.0, "u": false }
}

A GET /check?feature_code=... variant exists for non-mutating UI gates that must avoid POST-body cache invalidation; it defaults required_usage to 0.

3. POST /track

Record a usage event keyed by feature code.

Request body:

{
"feature_code": "api_calls",
"value": 1,
"dedup_key": "request-abc123" // optional; becomes the request Idempotency-Key
}

If dedup_key is omitted, the relay auto-generates a UUID v7 and uses it as the Idempotency-Key header forwarded to Paylera. The SDK’s TanStack Query mutation cache de-dupes near-simultaneous identical dedup_key calls before they hit the network.

Response: 204 No Content on success. The frontend invalidates any cached useFeature(feature_code) query so the next render reflects the new balance.

4. GET /entitlements

Bulk read of every feature on the resolved subscription. The relay derives the subscription from identify() and calls GET /v1/subscriptions/{id}/entitlements.

Response:

{
"subscription_id": "a7e2...",
"features": [
{ "feature_code": "api_calls", "value_type": "metered", "balance": 9876.0, ... },
{ "feature_code": "seats", "value_type": "numeric", "value": 5, ... },
{ "feature_code": "premium", "value_type": "boolean", "value": true, ... }
],
"privileges": [
{ "code": "export_csv" }
]
}

5. GET /me

Returns the resolved Paylera customer. If identify() returned customer_id=null and the relay’s auto_create_customer flag is on, the relay calls POST /v1/customers with the identify metadata, caches the resulting id per request, and returns the new customer.

Response: the full Customer DTO from Paylera (id, email, name, currency, created_at, etc.). The auto-create flow is idempotent on (tenant_id, email).

6. GET /plans

Plan catalog for usePricingTable(). May be called without an authenticated session — the relay omits customer_id and serves a public list. Filter parameters (product_id, status, etc.) pass through.

7. GET /invoices

Invoice history for the resolved customer.

Query parameters:

  • status (optional) — filter by invoice status
  • limit, cursor — Paylera’s standard pagination

Response: paginated Invoice DTOs from Paylera, untouched.

8. POST /billing-portal

Mints a one-time URL to the Paylera-hosted self-service portal where the customer can manage their payment method, view invoices, or cancel.

Request body:

{ "return_url": "https://acme.com/account" }

Response:

{ "url": "https://billing.paylera.dev/p/sess_...", "expires_at": "..." }

9. POST /subscriptions/{id}/upgrade

Upgrade, downgrade, or change-plan — Paylera treats all three uniformly. The relay verifies the subscription belongs to the resolved customer before forwarding (server-side enforcement; the React app must not be trusted).

Request body:

{
"new_plan_id": "...",
"proration_behavior": "create_prorations", // optional
"billing_cycle_anchor": "now" | "unchanged" // optional
}

10. POST /subscriptions/{id}/cancel

Cancel at-period-end (default) or immediately.

Request body:

{
"at_period_end": true, // default; false = immediate cancellation
"reason": "...", // optional
"feedback": "..." // optional
}

CustomerIdentity envelope

Every backend SDK calls a merchant-supplied identify() function once per request. It returns a CustomerIdentity:

FieldTypeRequiredNotes
customer_idstring | nullone of customer_id or emailPaylera customer id; null triggers auto-create when auto_create_customer=true.
emailstring | nullone ofUsed for auto-create and idempotency keying.
namestring | nulloptionalUsed for auto-create only.
currencystring | nulloptionalISO-4217. Used only for auto-create; default "USD".
metadataobject | nulloptionalFree-form; merged into the auto-created customer’s metadata.

Per-language shapes (all wire-compatible):

// TS
type PayleraCustomerIdentity = {
customerId?: string | null;
email?: string | null;
name?: string | null;
currency?: string | null;
metadata?: Record<string, unknown> | null;
};
// C#
public sealed record PayleraCustomerIdentity {
public string? CustomerId { get; init; }
public string? Email { get; init; }
public string? Name { get; init; }
public string? Currency { get; init; }
public IReadOnlyDictionary<string, object>? Metadata { get; init; }
}
# Python (Pydantic)
class PayleraCustomerIdentity(BaseModel):
customer_id: str | None = None
email: str | None = None
name: str | None = None
currency: str | None = None
metadata: dict[str, object] | None = None
// Go
type CustomerIdentity struct {
CustomerID string `json:"customer_id,omitempty"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Currency string `json:"currency,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// Java
public record PayleraCustomerIdentity(
String customerId,
String email,
String name,
String currency,
Map<String, Object> metadata
) {}

Auto-create idempotency: when customer_id is null and auto_create_customer=true, the relay calls POST /v1/customers with an auto-generated Idempotency-Key. Two concurrent first-time useFeature() calls for the same user share the same key and resolve to the same customer.

Two wire shapes are valid:

  • 3-segmentpaylera-relay-autocreate:{tenant_id}:{email} — the canonical multi-tenant form. Emitted by every relay (Node, .NET, Python, Go, Java) when a tenant identifier is available (from identify() for Node/Python/Go, from PayleraRelayOptions.TenantId for .NET, from paylera.tenant-id for Spring).
  • 2-segmentpaylera-relay-autocreate:{email} — legacy form emitted by paylera-spring-boot-starter when paylera.tenant-id is unset or the literal "default". Preserves wire compatibility for single-tenant Spring deployments that have been producing this shape since Phase 2. The two-segment form has no multi-tenant disambiguation; merchants sharing email addresses across tenants must opt in to the 3-segment form by setting paylera.tenant-id.

Migration between shapes — switching a Spring deployment from paylera.tenant-id=default to a real value (or any change that flips the emitted shape) creates a one-time idempotency-cache miss: existing auto-created customers will dupe on first auto-create call per email under the new key. The Paylera idempotency cache TTL is 24 hours, so two acceptable migration approaches:

  1. Pre-warm: before flipping the property, walk the existing customer set and call POST /v1/customers with an explicit Idempotency-Key matching the new shape for each email. Subsequent auto-creates hit the cache and resolve to existing customers.
  2. Accept one-day duplicate window: flip the property during a low-traffic window; auto-create calls for never-before-seen emails are unaffected, and calls for already-known emails dupe once per email. Reconcile the duplicates via the dashboard merge UI after the 24-hour cache settles.

The 3-segment shape is the strongly-recommended default for all new deployments.

Idempotency-Key

Every state-changing relay call carries an Idempotency-Key. The relay forwards it verbatim to Paylera; Paylera’s IdempotencyMiddleware caches the response for 24 hours and replays on retry.

Auto-generation: the relay generates UUID v7 per call. Sortable timestamps make idempotency-store eviction efficient.

User override: every imperative hook accepts a dedup_key argument that becomes the Idempotency-Key. Same dedup_key within the TanStack-Query-mutation-cache window (default 30s) is de-duped before hitting the network:

const { track } = useTrack();
await track("api_calls", 1, { dedupKey: "req-abc123" });

Backend SDK behaviour: if the inbound relay request already carries an Idempotency-Key header, the relay uses it as-is. If not, the relay generates one and forwards. The frontend is never trusted to omit idempotency on a required-idempotency endpoint — the relay supplies it.

CSRF — Paylera-CSRF-Token

The merchant-backend relay is the trust boundary; cookies authenticate the session; CSRF protects against cross-site forgery.

Token format: 32 bytes random, base64url-encoded (no padding), 43 chars.

Header name: Paylera-CSRF-Token.

Cookie name: paylera_csrf. Attributes: Path=/api/paylera, SameSite=Lax, Secure in production, HttpOnly=false (JS reads it to attach the header — this is the double-submit-cookie pattern, not session-cookie theft protection).

Acquisition: the React <PayleraProvider> calls GET /api/paylera/csrf-token on mount. The relay mints a token, sets the cookie, returns:

{ "token": "8f3a...", "expires_at": "..." }

The provider caches it in memory and attaches it on every state-changing call.

Validation: the relay middleware compares the header to the cookie value with crypto.timingSafeEqual. Match → pass. Mismatch or missing → 403 with problem-details type paylera.csrf_mismatch.

Rotation triggers:

  • <PayleraProvider authVersion={...}> prop change — sign-out, account switch
  • Token age > 24 hours (server enforces)
  • Explicit refreshCsrf() call from app code

Exemptions: GET, HEAD, OPTIONS — these are safe per HTTP semantics and don’t need CSRF.

Opt-out: the merchant can chain their own CSRF middleware and disable the SDK’s via csrf: false (Node) / opts.Csrf.Enabled = false (.NET) / csrf=False (Python) / CSRF: relay.CSRFDisabled (Go) / paylera.csrf: false (Spring). Whatever protection the host framework offers must still be active — the relay does not allow naked POSTs.

Deployment requirements

PAYLERA_RELAY_SECRET (multi-instance)

The CSRF token is a self-contained <random>.<unix_expiry>.<hmac> triple — the relay does not persist tokens server-side. Validation is purely an HMAC-SHA-256 recomputation over <random>.<unix_expiry> keyed by a process secret. This is what makes the scheme horizontally scalable: any relay instance can validate a token minted by any other.

The HMAC key is read from PAYLERA_RELAY_SECRET. All instances behind the same load balancer MUST share the same value — a stale or mismatched value on any instance produces 403 paylera.csrf_mismatch errors that look exactly like a real CSRF attack from the browser’s perspective.

SettingRequirement
NamePAYLERA_RELAY_SECRET
RequiredYes, in production. Dev fall-back is a per-process random — fine for one instance, fatal in a fleet.
FormatAt least 32 bytes of entropy, base64url-encoded recommended (openssl rand -base64 32)
RotationHot — new value is accepted immediately, but tokens minted under the old value 403 until refresh. Rotate during a low-traffic window or stage a graceful rollover by accepting two keys (PAYLERA_RELAY_SECRET + PAYLERA_RELAY_SECRET_PREVIOUS) during the cut.
StorageA secrets manager (AWS SM / GCP Secret Manager / HashiCorp Vault), never committed to source.
ScopeOne value per environment. Sharing dev and prod is a leak risk; sharing across the prod fleet is mandatory.

Per-language env-var read points:

  • Node (@paylera/server): read at relay-handler-factory construction, e.g. payleraNextRouteHandler({ relaySecret: process.env.PAYLERA_RELAY_SECRET! }). If unset, a process-lifetime random is logged at WARN.
  • .NET (Paylera.AspNetCore): builder.Configuration["Paylera:RelaySecret"] or env PAYLERA__RELAYSECRET (standard .NET env-var rewriting). AddPaylera panics in production if absent.
  • Python (paylera_fastapi): PayleraSettings(relay_secret=os.environ["PAYLERA_RELAY_SECRET"]).
  • Go (paylera-go/relay): relay.NewHandler(relay.Options{RelaySecret: os.Getenv("PAYLERA_RELAY_SECRET")}).
  • Spring Boot: paylera.relay-secret property, sourced from env via the standard Spring Boot property resolver.

A misconfigured fleet (mismatched secret across instances) is the single most common production CSRF false-positive. If you see a sudden spike of paylera.csrf_mismatch after a deploy, suspect this first.

SSR for @paylera/react

The /server subpath export ships React Server Component helpers.

Functions:

// @paylera/react/server
export async function getFeature(
code: string,
opts: { customerId: string; apiToken: string; baseUrl?: string }
): Promise<FeatureCheck>;
export async function getEntitlements(
opts: { customerId: string; apiToken: string; baseUrl?: string }
): Promise<EntitlementsBundle>;
export async function getCustomer(
opts: { customerId: string; apiToken: string; baseUrl?: string }
): Promise<Customer>;

Hydration: <PayleraProvider> accepts initialState whose shape mirrors the union of getFeature / getEntitlements / getCustomer results. The hooks consume that hydrated state on first render — zero client-side fetch on first paint.

Idiomatic Next.js App Router:

// app/pricing/page.tsx (server component)
import { PayleraProvider } from "@paylera/react";
import { getEntitlements } from "@paylera/react/server";
export default async function Page() {
const initialState = await getEntitlements({
customerId: session.user.payleraCustomerId,
apiToken: process.env.PAYLERA_API_TOKEN!,
});
return (
<PayleraProvider backendUrl="/api/paylera" initialState={initialState}>
<PricingClient />
</PayleraProvider>
);
}

Customer-id safety: the SSR helpers require an explicit customerId argument. They never read ambient request state — that would let one server-component render leak another user’s entitlements through a stale AsyncLocalStorage value.

OpenTelemetry contract

Every SDK emits standardised spans and metrics. Tracer/meter names + attribute keys are part of this protocol so external dashboards work uniformly.

ActivitySource / Tracer names

SDK layerSource name
Typed HTTP clientPaylera.Sdk
Backend SDK relayPaylera.Relay
Backend SDK webhookPaylera.Webhook

Span names

SpanEmitted byNotes
Paylera.<Op>typed cliente.g. Paylera.Check, Paylera.Track, Paylera.Attach. One per public API method.
Paylera.Relay.<Op>relaye.g. Paylera.Relay.Check. Wraps the inbound request → outbound forward.
Paylera.Webhook.<EventType>webhooke.g. Paylera.Webhook.invoice.paid. Wraps signature verify + handler dispatch.

Attribute keys (uniform across languages)

KeyTypeRequiredNotes
paylera.endpointstringyes"/v1/check", "/v1/attach", etc.
paylera.tenant_idstringwhen knownUUID from token resolution.
paylera.customer_idstringwhen knownUUID. Never set from frontend input directly.
paylera.subscription_idstringwhen knownUUID.
paylera.feature_codestringwhen knownE.g. "api_calls".
paylera.idempotency_keystringon writesTruncated to first 8 chars for log volume.
paylera.api_modestringyes"test" or "live", derived from token prefix.
paylera.providerstringon payment"stripe" or "toss".
paylera.problem.typestringon errorRFC 9457 type value (e.g. paylera.csrf_mismatch).

Metric names + units

MetricTypeUnitLabels
paylera.requests.totalcounter{request}endpoint, status_code, api_mode
paylera.requests.durationhistogramsendpoint, status_code, api_mode
paylera.relay.cache_hitscounter{hit}feature_code
paylera.relay.cache_missescounter{miss}feature_code
paylera.webhook.events_receivedcounter{event}event_type, verification_result
paylera.webhook.handler_durationhistogramsevent_type

Histogram buckets are language-default; clients SHOULD provide explicit buckets for paylera.requests.duration matching the backend’s existing configuration: 5ms, 10ms, 25ms, 50ms, 100ms, 150ms, 300ms, 400ms, 800ms, 1500ms, 3s, 8s.

No-op default: every SDK registers a no-op tracer + meter unless the host process has a registered OTel SDK (Node @opentelemetry/api, .NET System.Diagnostics.Activity, Python opentelemetry, Go go.opentelemetry.io/otel, Java io.opentelemetry). Zero overhead when unused.

Typed client surface conventions

The wire shape is the same across every SDK: same headers, same paths, same request/response JSON, same error envelope, same idempotency contract. The in-language surface shape is idiomatic per language, and is deliberately not uniform:

LanguageMethod namingGrouping shape
TypeScript (@paylera/sdk)camelCaseTag-grouped namespaces — client.customers.create(...), client.subscriptions.cancel(...)
.NET (Paylera.Sdk)PascalCaseAsyncFlat — client.Api.CreateCustomerAsync(...). The NSwag-generated client exposes the full surface; the hand-written PayleraClient facade adds ergonomic shortcuts
Python (paylera)snake_caseFlat — client.create_customer(...), client.cancel_subscription(...)
Go (paylera-go)PascalCaseFlat — client.CreateCustomer(ctx, ...), client.CancelSubscription(...)
Java (paylera-sdk-java)camelCaseFlat — client.createCustomer(...), client.cancelSubscription(...)

The TypeScript tag-grouped namespaces are a deliberate ergonomic choice matching the JS / Next.js mental model and the OpenAPI tag convention the spec already declares. Porting that pattern to Python / Go / Java adds an extra level of indirection that doesn’t match the host language’s typical SDK conventions (compare: stripe.Customer.create() in Python, stripe.NewClient().Customers().New(...) in Go — Stripe itself ships inconsistent grouping across languages).

The wire-compatibility test suite (sdks/_conformance/) verifies the same wire shape regardless of the in-language surface, so code written against one SDK migrates field-for-field to another.

Error class naming per SDK

The RFC 9457 typed exception hierarchy + the RFC 8628 OAuth-error subclasses use language-idiomatic suffixes (Error in Go/JS/Python, Exception in .NET/Java) and one Go-specific name deviation:

LanguageRFC 9457 typed errorsRFC 8628 OAuth errors
TypeScriptPayleraError, PayleraAuthenticationError, PayleraAuthorizationError, PayleraNotFoundError, PayleraPreconditionFailedError, PayleraRateLimitError, PayleraIdempotencyConflictError, PayleraIdempotencyInProgressError, PayleraServerErrorPayleraOAuthError (base), PayleraOAuthPendingError, PayleraOAuthSlowDownError, PayleraOAuthExpiredTokenError, PayleraOAuthAccessDeniedError, PayleraOAuthInvalidGrantError
.NETsame set with Paylera*Exception suffixPayleraOAuth*Exception set
PythonPaylera*Error setPayleraOAuth*Error set
Go*Error set (drops the Paylera prefix per Go idiom — the package name paylera already namespaces them)OAuthFlowError (base — named Flow to avoid colliding with the OpenAPI-generated OAuthError wire DTO), OAuthPendingError, OAuthSlowDownError, OAuthExpiredTokenError, OAuthAccessDeniedError, OAuthInvalidGrantError
JavaPaylera*Exception setPayleraOAuth*Exception set

The Go OAuthFlowError name is the one deliberate divergence — the oapi-codegen output already exports OAuthError as the wire DTO for the RFC 8628 §3.5 error envelope, so the typed exception base uses OAuthFlowError to keep both available without aliasing. Subtypes keep the canonical names. Cross-language CLI / migration tooling should branch on the OAuth error code (authorization_pending, slow_down, etc.), not on the exception class name.

Retry policy (typed clients)

Every typed HTTP client retries transient failures with a uniform curve. This section is the contract — implementations that diverge from these numbers cause merchant-visible behavior differences and should be treated as bugs.

Canonical curve:

ParameterValueNotes
maxAttempts5First attempt + 4 retries
baseDelayMs200Initial delay before retry 1
maxDelayMs8000Cap (8 seconds)
jitter"full"actualDelay = random_uniform(0, computedDelay) per AWS-style full-jitter — not half-jitter (which would be (computedDelay/2) + random_uniform(0, computedDelay/2)) and not no-jitter
backoffexponential, factor 2computedDelay = min(base * 2^attempt, cap)

The full-jitter formula expands as:

delay(attempt) = random_uniform(0, min(baseDelayMs * 2^attempt, maxDelayMs))

So a typical sequence looks like one of (random sampling):

  • attempt 1 fail → wait 0-400ms → attempt 2
  • attempt 2 fail → wait 0-800ms → attempt 3
  • attempt 3 fail → wait 0-1600ms → attempt 4
  • attempt 4 fail → wait 0-3200ms → attempt 5
  • attempt 5 fail → give up, surface the last error

Total worst-case retry budget: ~6 seconds. Median: ~3 seconds.

What’s retryable:

ConditionRetry?Notes
Network error (DNS, connect, TLS, dropped connection)yesTreat as transient
HTTP 408 (Request Timeout)yesServer says “try again”
HTTP 429 (Too Many Requests)yesHonor Retry-After header if present
HTTP 500 / 502 / 503 / 504yesServer-side transient
HTTP 4xx (other)NOClient error; retrying won’t help
HTTP 2xx / 3xxNOSuccess / explicit redirect; no retry
Method GET / HEADyesIdempotent by spec
Method POST / PUT / PATCH / DELETEyes, IF an Idempotency-Key is setAuto-stamped by the SDK’s idempotency handler — so writes ARE retryable safely
Server rejects with paylera.idempotency_conflictNOCaller bug; retrying would mask it

Honor Retry-After:

When the server returns 429 or 503 with a Retry-After: <delta-seconds> header, the client uses min(max(Retry-After * 1000, computedDelay), maxDelayMs) for that wait. Specifically:

  1. Floor at the server’s request — never wait shorter than Retry-After (the server is saying “don’t hammer me for at least N seconds”).
  2. Cap at maxDelayMs (8 000 ms default) — a hostile or buggy upstream returning Retry-After: 99999999 cannot pin the client to a multi-year sleep. The cap exists in all five SDKs (JS, Python, .NET, Go, Java).
  3. Non-positive values fall back to the local backoff curveRetry-After: 0, past HTTP-date values, and negative deltas are treated as “no hint” and the SDK uses its own exponential-with-full-jitter computation. This preserves jitter benefits when the server’s hint isn’t useful.

Retry-After accepts both formats per RFC 7231 §7.1.3:

  • delta-seconds — e.g. Retry-After: 5 (wait 5 s before retry).
  • HTTP-date — e.g. Retry-After: Wed, 21 Oct 2026 07:28:00 GMT (wait until that absolute time, then retry).

Implementation per language:

  • TypeScript (@paylera/sdk): Uses native fetch + custom retry loop in client.ts. Configurable via new PayleraClient({ retry: { maxAttempts, baseDelayMs, maxDelayMs, jitter } }).
  • .NET (Paylera.Sdk): Uses Polly v8 ResiliencePipeline with UseJitter = true. The default pipeline is built in Client/RetryHandler.cs; merchants can override via PayleraSdkOptions.RetryPolicy. Note: Polly’s UseJitter is decorrelated-jitter, not strict AWS full-jitter — the distribution differs slightly but the worst-case wall-clock bound is the same (8s cap), and the cross-language conformance fixture passes for both formulas. Documented as an accepted deviation in RetryHandler.cs.
  • Python (paylera): Custom retry loop in paylera/_retry.py. Configurable via PayleraClient(retry_config=RetryConfig(...)).
  • Go (paylera-go): Custom retry loop in client.go. Configurable via paylera.NewClient(paylera.WithRetry(paylera.RetryConfig{...})).
  • Java (paylera-sdk-java): OkHttp Interceptor in internal/RetryInterceptor.java. Configurable via PayleraClient.builder().retryConfig(...).

Conformance: the cross-language conformance harness includes a retry-budget fixture that verifies (a) the right number of attempts on a 503-then-200 sequence, (b) the curve’s worst-case wall-clock duration stays within budget, (c) Retry-After is honored. All 5 SDKs gate on this fixture in CI.

Customer-facing surface: the merchant’s retry config sets the curve; no implicit per-request override. If a particular call needs different retry semantics (e.g. a known-slow operation), the merchant constructs a second client instance with a tailored config.

Developer debug panel (<PayleraDebugPanel />)

@paylera/react ships an opt-in in-page debug overlay so merchant developers can verify SDK wiring without installing a browser extension.

Shape: fixed-position corner widget (bottom-right by default), collapsed on first mount, opens on click or via the Alt+P hotkey (configurable). Inline styles only — never collides with the merchant’s design system or build pipeline.

Mount idiom: the merchant gates it on non-production builds:

import { PayleraProvider, PayleraDebugPanel } from "@paylera/react";
<PayleraProvider backendUrl="/api/paylera">
<App />
{process.env.NODE_ENV !== "production" && <PayleraDebugPanel />}
</PayleraProvider>

Information surfaced:

FieldSource
Mode label (live / test / custom)Derived from the backendUrl heuristic — the React SDK never sees the API token directly, so the relay decides the upstream Paylera environment
Customer idRead from the cached useCustomer() query, no fetch triggered
Cache sizeCount of useFeature() / useEntitlements() keys currently in the TanStack cache
Last 50 relay callsEndpoint, method, status code, duration, timestamp — pushed by the provider’s relay wrapper to a small in-memory ring buffer

Why in-page over a Chrome DevTools extension: the in-page overlay requires zero install, works in every browser, ships in the same npm package as the provider, and surfaces identical information. A Chrome extension would require a separate manifest, content-script + devtools panel HTML, Chrome Web Store publishing pipeline, and browser-specific testing — none of which materially improves the developer experience over the overlay. The shape mirrors the small “Stripe Elements debug” / “Supabase Auth helper” patterns rather than the heavier React Query DevTools extension.

Production safety: the panel is a separate export — tree-shaken out of any bundle that doesn’t import it. Mounting it in production is discouraged (the request log retains 50 entries’ worth of metadata in memory) but causes no harm beyond that.

Webhook signatures (inbound to merchant)

When Paylera POSTs a webhook to a merchant endpoint, every backend SDK ships a verifyWebhookSignature(...) helper that validates the delivery.

Header: Paylera-Signature: t=<unix>,v1=<hex>[,v1=<hex>] — exactly the format documented in Verifying signatures.

HMAC body: ${t}.${raw_body_bytes} — literal dot separator, raw bytes (do not parse + re-serialise the JSON).

Algorithm: HMAC-SHA-256, secret as key, hex-encoded result.

Multi-v1: during secret rotation, deliveries are signed with both the old and new secrets. Verifiers accept if any v1 matches.

Replay window: default 300 s. Configurable per-handler.

Behaviour:

import { verifyWebhookSignature } from "@paylera/server";
const ok = verifyWebhookSignature(rawBodyBuffer, headers["paylera-signature"], secret, {
toleranceSeconds: 300,
});

The higher-level handler factory (payleraWebhookExpress, MapPayleraWebhooks, paylera_webhooks, webhook.Handler, @PayleraWebhookHandler) does verification + replay-protection + event dispatch in one call.

CLI declarative config schema

paylera.config.ts validation is done with Zod. The protocol-level schema (Phase 0 lock; Phase 4 implementation):

import { z } from "zod";
const PriceTier = z.object({
flatAmount: z.number().int().nonnegative().optional(),
unitAmount: z.number().int().nonnegative().optional(),
upTo: z.union([z.number().positive(), z.literal("inf")]).optional(),
});
const Price = z.object({
amount: z.number().int().nonnegative(),
currency: z.string().length(3),
interval: z.enum(["day", "week", "month", "year"]),
tiers: z.array(PriceTier).optional(),
});
const FeatureValue = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("boolean") }),
z.object({ kind: z.literal("numeric") }),
z.object({ kind: z.literal("text") }),
z.object({ kind: z.literal("metered"), billableMetric: z.string() }),
z.object({ kind: z.literal("unlimited") }),
]);
const FeatureDef = z.object({
code: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
name: z.string().optional(),
}).and(FeatureValue);
const PlanFeatureGrant = z.object({
value: z.union([z.boolean(), z.number(), z.string()]).optional(),
includedUsage: z.number().nonnegative().optional(),
usageCycle: z.enum(["subscription_period", "calendar_month", "calendar_year", "never"]).optional(),
creditGrant: z.number().nonnegative().optional(),
rolloverMax: z.number().nonnegative().optional(),
rolloverPercent: z.number().min(0).max(100).optional(),
});
const Plan = z.object({
code: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
name: z.string(),
product: z.string().optional(),
prices: z.array(Price).min(1),
features: z.record(z.string(), PlanFeatureGrant),
});
export const PayleraConfig = z.object({
apiVersion: z.literal("2026-05-01"),
features: z.array(FeatureDef),
plans: z.array(Plan),
});

The CLI applies a config via PUT /v1/admin/{features,plans,products,billable-metrics}/{code} (backend prereq B). Drift detection diff-walks the config against current state and prints a summary before applying.

Versioning + compatibility

Token-prefix mode selection. Every typed client switches base URL based on the token prefix:

  • pl_test_*https://api.test.paylera.dev
  • pl_live_*https://api.paylera.dev
  • Anything else → explicit baseUrl required

An explicit baseUrl argument always wins (self-hosted Paylera, staging environments).

API version pinning. Every typed client sends Paylera-Api-Version matching the SDK’s apiVersion build constant. Default is the date the SDK was generated against. Merchants may pin to a different version with new PayleraClient({ apiToken, apiVersion: "2026-04-01" }) — pinning is the contract; auto-rolling is dangerous and not done.

Per-package peer-dep policy. A given relay SDK pins its typed-client peer to its own major: @paylera/react@1.x@paylera/sdk@^1, etc. Mismatched majors emit a runtime console warning and refuse to mount in development; production builds log + continue.

Cross-language matrix. Conformance tests in sdks/_conformance/ verify every backend SDK round-trips identical wire shapes against a frozen recorded fixture set. CI runs the matrix on every push.

Source of truth for changes

This protocol is versioned with the OpenAPI spec at docs/billing_specs/openapi_public_yaml_clean.yaml. Wire-incompatible changes here require:

  1. OpenAPI spec change with a new apiVersion.
  2. This document updated in the same PR.
  3. All eleven SDK clients regenerated (Phase-1 client generators consume the spec automatically; relay adapters and the React SDK may need manual updates).
  4. Conformance test fixtures regenerated.
  5. Per-package CHANGELOGs updated.

The [scope-multi] commit-scope override is the expected workflow for these spec-wide changes (see tools/check-commit-scope).