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 dataThe 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 route | Forwards to | Auth on relay | Idempotency? |
|---|---|---|---|---|
| 1 | POST /attach | POST /v1/attach | session + CSRF | Required |
| 2 | POST /check | POST /v1/check | session + CSRF | Optional |
| 3 | POST /track | POST /v1/track | session + CSRF | Required (auto-stamped) |
| 4 | GET /entitlements | GET /v1/subscriptions/{id}/entitlements | session | N/A |
| 5 | GET /me | GET /v1/customers/{id} (or auto-create) | session | N/A |
| 6 | GET /plans | GET /v1/plans | session (optional) | N/A |
| 7 | GET /invoices | GET /v1/customers/{id}/invoices | session | N/A |
| 8 | POST /billing-portal | POST /v1/customers/{id}/billing-portal-sessions | session + CSRF | Required |
| 9 | POST /subscriptions/{id}/upgrade | POST /v1/subscriptions/{id}/change-plan | session + CSRF | Required |
| 10 | POST /subscriptions/{id}/cancel | POST /v1/subscriptions/{id}/cancel | session + CSRF | Required |
A relay-only route — not forwarded — completes the picture:
| Auxiliary route | Purpose |
|---|---|
GET /csrf-token | Mints 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— neithersuccess_url+cancel_urlnorreturn_urlsuppliedpaylera.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 statuslimit,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:
| Field | Type | Required | Notes |
|---|---|---|---|
customer_id | string | null | one of customer_id or email | Paylera customer id; null triggers auto-create when auto_create_customer=true. |
email | string | null | one of | Used for auto-create and idempotency keying. |
name | string | null | optional | Used for auto-create only. |
currency | string | null | optional | ISO-4217. Used only for auto-create; default "USD". |
metadata | object | null | optional | Free-form; merged into the auto-created customer’s metadata. |
Per-language shapes (all wire-compatible):
// TStype 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// Gotype 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"`}// Javapublic 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-segment —
paylera-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 (fromidentify()for Node/Python/Go, fromPayleraRelayOptions.TenantIdfor .NET, frompaylera.tenant-idfor Spring). - 2-segment —
paylera-relay-autocreate:{email}— legacy form emitted bypaylera-spring-boot-starterwhenpaylera.tenant-idis 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 settingpaylera.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:
- Pre-warm: before flipping the property, walk the existing
customer set and call
POST /v1/customerswith an explicitIdempotency-Keymatching the new shape for each email. Subsequent auto-creates hit the cache and resolve to existing customers. - 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.
| Setting | Requirement |
|---|---|
| Name | PAYLERA_RELAY_SECRET |
| Required | Yes, in production. Dev fall-back is a per-process random — fine for one instance, fatal in a fleet. |
| Format | At least 32 bytes of entropy, base64url-encoded recommended (openssl rand -base64 32) |
| Rotation | Hot — 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. |
| Storage | A secrets manager (AWS SM / GCP Secret Manager / HashiCorp Vault), never committed to source. |
| Scope | One 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 envPAYLERA__RELAYSECRET(standard .NET env-var rewriting).AddPaylerapanics 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-secretproperty, 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/serverexport 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 layer | Source name |
|---|---|
| Typed HTTP client | Paylera.Sdk |
| Backend SDK relay | Paylera.Relay |
| Backend SDK webhook | Paylera.Webhook |
Span names
| Span | Emitted by | Notes |
|---|---|---|
Paylera.<Op> | typed client | e.g. Paylera.Check, Paylera.Track, Paylera.Attach. One per public API method. |
Paylera.Relay.<Op> | relay | e.g. Paylera.Relay.Check. Wraps the inbound request → outbound forward. |
Paylera.Webhook.<EventType> | webhook | e.g. Paylera.Webhook.invoice.paid. Wraps signature verify + handler dispatch. |
Attribute keys (uniform across languages)
| Key | Type | Required | Notes |
|---|---|---|---|
paylera.endpoint | string | yes | "/v1/check", "/v1/attach", etc. |
paylera.tenant_id | string | when known | UUID from token resolution. |
paylera.customer_id | string | when known | UUID. Never set from frontend input directly. |
paylera.subscription_id | string | when known | UUID. |
paylera.feature_code | string | when known | E.g. "api_calls". |
paylera.idempotency_key | string | on writes | Truncated to first 8 chars for log volume. |
paylera.api_mode | string | yes | "test" or "live", derived from token prefix. |
paylera.provider | string | on payment | "stripe" or "toss". |
paylera.problem.type | string | on error | RFC 9457 type value (e.g. paylera.csrf_mismatch). |
Metric names + units
| Metric | Type | Unit | Labels |
|---|---|---|---|
paylera.requests.total | counter | {request} | endpoint, status_code, api_mode |
paylera.requests.duration | histogram | s | endpoint, status_code, api_mode |
paylera.relay.cache_hits | counter | {hit} | feature_code |
paylera.relay.cache_misses | counter | {miss} | feature_code |
paylera.webhook.events_received | counter | {event} | event_type, verification_result |
paylera.webhook.handler_duration | histogram | s | event_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:
| Language | Method naming | Grouping shape |
|---|---|---|
TypeScript (@paylera/sdk) | camelCase | Tag-grouped namespaces — client.customers.create(...), client.subscriptions.cancel(...) |
.NET (Paylera.Sdk) | PascalCaseAsync | Flat — client.Api.CreateCustomerAsync(...). The NSwag-generated client exposes the full surface; the hand-written PayleraClient facade adds ergonomic shortcuts |
Python (paylera) | snake_case | Flat — client.create_customer(...), client.cancel_subscription(...) |
Go (paylera-go) | PascalCase | Flat — client.CreateCustomer(ctx, ...), client.CancelSubscription(...) |
Java (paylera-sdk-java) | camelCase | Flat — 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:
| Language | RFC 9457 typed errors | RFC 8628 OAuth errors |
|---|---|---|
| TypeScript | PayleraError, PayleraAuthenticationError, PayleraAuthorizationError, PayleraNotFoundError, PayleraPreconditionFailedError, PayleraRateLimitError, PayleraIdempotencyConflictError, PayleraIdempotencyInProgressError, PayleraServerError | PayleraOAuthError (base), PayleraOAuthPendingError, PayleraOAuthSlowDownError, PayleraOAuthExpiredTokenError, PayleraOAuthAccessDeniedError, PayleraOAuthInvalidGrantError |
| .NET | same set with Paylera*Exception suffix | PayleraOAuth*Exception set |
| Python | Paylera*Error set | PayleraOAuth*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 |
| Java | Paylera*Exception set | PayleraOAuth*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:
| Parameter | Value | Notes |
|---|---|---|
maxAttempts | 5 | First attempt + 4 retries |
baseDelayMs | 200 | Initial delay before retry 1 |
maxDelayMs | 8000 | Cap (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 |
backoff | exponential, factor 2 | computedDelay = 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:
| Condition | Retry? | Notes |
|---|---|---|
| Network error (DNS, connect, TLS, dropped connection) | yes | Treat as transient |
| HTTP 408 (Request Timeout) | yes | Server says “try again” |
| HTTP 429 (Too Many Requests) | yes | Honor Retry-After header if present |
| HTTP 500 / 502 / 503 / 504 | yes | Server-side transient |
| HTTP 4xx (other) | NO | Client error; retrying won’t help |
| HTTP 2xx / 3xx | NO | Success / explicit redirect; no retry |
| Method GET / HEAD | yes | Idempotent by spec |
| Method POST / PUT / PATCH / DELETE | yes, IF an Idempotency-Key is set | Auto-stamped by the SDK’s idempotency handler — so writes ARE retryable safely |
Server rejects with paylera.idempotency_conflict | NO | Caller 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:
- 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”). - Cap at
maxDelayMs(8 000 ms default) — a hostile or buggy upstream returningRetry-After: 99999999cannot pin the client to a multi-year sleep. The cap exists in all five SDKs (JS, Python, .NET, Go, Java). - Non-positive values fall back to the local backoff curve —
Retry-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 nativefetch+ custom retry loop inclient.ts. Configurable vianew PayleraClient({ retry: { maxAttempts, baseDelayMs, maxDelayMs, jitter } }). - .NET (
Paylera.Sdk): Uses Polly v8ResiliencePipelinewithUseJitter = true. The default pipeline is built inClient/RetryHandler.cs; merchants can override viaPayleraSdkOptions.RetryPolicy. Note: Polly’sUseJitteris 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 inRetryHandler.cs. - Python (
paylera): Custom retry loop inpaylera/_retry.py. Configurable viaPayleraClient(retry_config=RetryConfig(...)). - Go (
paylera-go): Custom retry loop inclient.go. Configurable viapaylera.NewClient(paylera.WithRetry(paylera.RetryConfig{...})). - Java (
paylera-sdk-java): OkHttpInterceptorininternal/RetryInterceptor.java. Configurable viaPayleraClient.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:
| Field | Source |
|---|---|
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 id | Read from the cached useCustomer() query, no fetch triggered |
| Cache size | Count of useFeature() / useEntitlements() keys currently in the TanStack cache |
| Last 50 relay calls | Endpoint, 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.devpl_live_*→https://api.paylera.dev- Anything else → explicit
baseUrlrequired
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:
- OpenAPI spec change with a new
apiVersion. - This document updated in the same PR.
- All eleven SDK clients regenerated (Phase-1 client generators consume the spec automatically; relay adapters and the React SDK may need manual updates).
- Conformance test fixtures regenerated.
- Per-package CHANGELOGs updated.
The [scope-multi] commit-scope override is the expected workflow for these
spec-wide changes (see tools/check-commit-scope).