Java SDK — Webhooks
The Java starter ships a webhook receiver that handles signature verification, replay-window enforcement, and typed dispatch — so a merchant writes one annotated method per event type and nothing else.
The protocol contract behind every cross-language webhook receiver lives at Relay protocol § Webhook signatures.
Quick wiring
paylera: webhook-secret: ${PAYLERA_WEBHOOK_SECRET} webhook-prefix: /webhooks/paylera # optional, this is the default@Componentclass InvoiceHandlers {
@PayleraWebhookHandler("invoice.paid") public void onInvoicePaid(InvoicePaidEvent event) { log.info("Paid: invoice={} amount={} {}", event.invoiceId(), event.totalAmount(), event.currency()); }}The starter binds the receiver at the configured prefix only when
paylera.webhook-secret is non-blank. A blank secret means the
receiver is intentionally dormant — the relay alone can run during
partial provisioning.
What the receiver does on every delivery
- Reads the raw body as
byte[]. Spring’s JSON converter would parse and re-serialise; that would change the HMAC-input bytes and every signature would fail. The receiver intentionally bypasses the converter. - Validates
Paylera-SignaturewithPayleraWebhookVerifier.verifyMultiagainst every configured secret (the primary plus anypaylera.accepted-secrets[]). A constant-time comparison is used to avoid timing leaks. - Enforces the replay window
(
paylera.webhook-tolerance-seconds, default 300 s). Deliveries whoset=timestamp drifts further than this from local wall-clock time get a 401. - Parses the envelope into a typed
PayleraWebhookEvent. - Dispatches to every matching
@PayleraWebhookHandlermethod in discovery order. The first thrown exception short-circuits and responds 500.
Response codes
| Status | When | Paylera retry? |
|---|---|---|
200 | Signature valid + dispatch finished (handler ran or no handler matched) | No |
400 | Signature valid but the body did not parse as a Paylera envelope | No |
401 | Missing / invalid signature, or replay-window violation | No |
500 | A handler threw | Yes — exponential backoff per the retry schedule |
200 for “no handler matched” is intentional — it keeps a single endpoint reusable as the merchant grows their handler coverage, without forcing every un-handled event into a retry loop.
Handler signatures
The dispatcher only invokes single-argument methods whose parameter
is PayleraWebhookEvent or one of its typed subclasses:
| Parameter type | Match |
|---|---|
PayleraWebhookEvent | Every event (typed-base catch-all). |
InvoiceEvent | Every invoice.* event. |
InvoicePaidEvent | Only invoice.paid. |
SubscriptionEvent | Every subscription.* event. |
SubscriptionCanceledEvent | Only subscription.canceled. |
PaymentEvent | Every payment.* event. |
CustomerEvent | Every customer.* event (including external_ref_discovered). |
CustomerExternalRefDiscoveredEvent | Only customer.external_ref_discovered. |
RevenueEvent | Every revenue.* event. |
RevenueCapturedEvent | Only revenue.captured. |
RevenueRefundedEvent | Only revenue.refunded. |
RevenueSubscriptionStartedEvent | Only revenue.subscription_started. |
RevenueSubscriptionRenewedEvent | Only revenue.subscription_renewed. |
RevenueSubscriptionCanceledEvent | Only revenue.subscription_canceled. |
RevenueSubscriptionGracePeriodEnteredEvent | Only revenue.subscription_grace_period_entered. |
RevenueSubscriptionRecoveredEvent | Only revenue.subscription_recovered. |
RevenueSubscriptionExpiredEvent | Only revenue.subscription_expired. |
RevenueOneTimePurchaseEvent | Only revenue.one_time_purchase. |
RevenueUnknownCurrencySeenEvent | Only revenue.unknown_currency_seen. |
The 10 revenue.* events are multi-source observability projections
from Stripe / Toss / Apple App Store Server Notifications V2 / Google
Play RTDN — see
Multi-source revenue capture
for the family overview and the money-bearing vs. lifecycle split.
CustomerExternalRefDiscoveredEvent is emitted by the
RevenueAttribution worker when a previously-orphaned revenue.*
event is back-linked to an existing customer via email-fingerprint
match.
The @PayleraWebhookHandler("...") value must match the delivered
type field verbatim — wildcards are not supported. Paylera’s
protocol explicitly discourages wildcard subscribers in production.
A method may live on any Spring bean — @Service, @Component,
@Configuration, etc. The dispatcher scans the bean graph at
startup, so a runtime-registered bean is also picked up if it goes
through the regular Spring lifecycle.
Reading the payload
Every event class exposes:
| Method | Returns |
|---|---|
id() | Delivery id (evt_*). Stable across retries. |
type() | Event type — "invoice.paid", etc. |
created() | RFC 3339 instant when Paylera created the event. |
schemaVersion() | Integer version of the data envelope. |
data() | Raw JsonObject of the data field — escape hatch. |
previousAttributes() | Raw JsonObject of the diff field (when present). |
rawEnvelope() | Raw JsonObject of the full envelope. |
Typed subclasses surface the common fields:
@PayleraWebhookHandler("invoice.paid")public void onPaid(InvoicePaidEvent event) { event.invoiceId(); // String event.customerId(); // String event.totalAmount(); // Long (smallest currency unit) event.currency(); // String — ISO 4217 event.status(); // String — "paid" event.paidAt(); // String — ISO 8601 instant}Need a field the typed subclass doesn’t surface? Drop to
event.data() and read the JsonObject directly — same shape as the
JSON in the dashboard’s webhook log.
Idempotency on the handler side
Paylera retries failed deliveries with the same delivery id. If
your handler has a non-idempotent side-effect (sending email,
provisioning a resource, charging an external system), deduplicate
on event.id() before doing the work:
@PayleraWebhookHandler("invoice.paid")public void onPaid(InvoicePaidEvent event) { if (deliveries.markProcessed(event.id())) { sendReceipt(event); }}The starter does not deduplicate for you — the right scope (Redis, database, in-process cache, …) is application-specific.
Secret rotation
To rotate the signing secret with zero downtime:
paylera: webhook-secret: ${PAYLERA_WEBHOOK_SECRET_NEW} accepted-secrets: - ${PAYLERA_WEBHOOK_SECRET_OLD} # honoured during the 24h overlapPaylera signs each delivery with both the old and the new secret for
24 h after a rotation. The receiver tries the primary secret first;
on failure it tries each entry in accepted-secrets[]. Drop the old
entry from the config after the 24 h window closes.
Testing locally
Replay a fixture against the local receiver:
curl -X POST http://localhost:8080/webhooks/paylera \ -H 'Content-Type: application/json' \ -H 'Paylera-Signature: t=1700000000,v1=...' \ -d @invoice-paid-fixture.jsonTo generate a signature for a fixture body in tests:
String header = PayleraWebhookSignatures.sign( bodyBytes, secret.getBytes(StandardCharsets.UTF_8), Instant.now());The starter’s own test suite uses exactly this pattern (see
PayleraWebhookVerifierTest).
What the receiver doesn’t do
- No business-logic retries. A thrown handler is a 500; Paylera re-delivers per its retry schedule. The starter does not buffer or replay handlers in-process.
- No async dispatch. Handlers run on the request thread. Move
long-running work to a background executor (
@Asyncor your own queue) and acknowledge fast — Paylera’s retry timer is 30 s per attempt. - No payload mutation. The raw bytes are signed; the receiver
reads them, dispatches, and discards them. The
JsonObjectsurface is for read-only field access.
See also
- Spring Boot starter — autoconfig overview.
- Relay protocol § Webhook signatures — the cross-language contract.