Skip to content

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

application.yaml
paylera:
webhook-secret: ${PAYLERA_WEBHOOK_SECRET}
webhook-prefix: /webhooks/paylera # optional, this is the default
@Component
class 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

  1. 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.
  2. Validates Paylera-Signature with PayleraWebhookVerifier.verifyMulti against every configured secret (the primary plus any paylera.accepted-secrets[]). A constant-time comparison is used to avoid timing leaks.
  3. Enforces the replay window (paylera.webhook-tolerance-seconds, default 300 s). Deliveries whose t= timestamp drifts further than this from local wall-clock time get a 401.
  4. Parses the envelope into a typed PayleraWebhookEvent.
  5. Dispatches to every matching @PayleraWebhookHandler method in discovery order. The first thrown exception short-circuits and responds 500.

Response codes

StatusWhenPaylera retry?
200Signature valid + dispatch finished (handler ran or no handler matched)No
400Signature valid but the body did not parse as a Paylera envelopeNo
401Missing / invalid signature, or replay-window violationNo
500A handler threwYes — 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 typeMatch
PayleraWebhookEventEvery event (typed-base catch-all).
InvoiceEventEvery invoice.* event.
InvoicePaidEventOnly invoice.paid.
SubscriptionEventEvery subscription.* event.
SubscriptionCanceledEventOnly subscription.canceled.
PaymentEventEvery payment.* event.
CustomerEventEvery customer.* event (including external_ref_discovered).
CustomerExternalRefDiscoveredEventOnly customer.external_ref_discovered.
RevenueEventEvery revenue.* event.
RevenueCapturedEventOnly revenue.captured.
RevenueRefundedEventOnly revenue.refunded.
RevenueSubscriptionStartedEventOnly revenue.subscription_started.
RevenueSubscriptionRenewedEventOnly revenue.subscription_renewed.
RevenueSubscriptionCanceledEventOnly revenue.subscription_canceled.
RevenueSubscriptionGracePeriodEnteredEventOnly revenue.subscription_grace_period_entered.
RevenueSubscriptionRecoveredEventOnly revenue.subscription_recovered.
RevenueSubscriptionExpiredEventOnly revenue.subscription_expired.
RevenueOneTimePurchaseEventOnly revenue.one_time_purchase.
RevenueUnknownCurrencySeenEventOnly 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:

MethodReturns
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 overlap

Paylera 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:

Terminal window
curl -X POST http://localhost:8080/webhooks/paylera \
-H 'Content-Type: application/json' \
-H 'Paylera-Signature: t=1700000000,v1=...' \
-d @invoice-paid-fixture.json

To 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 (@Async or 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 JsonObject surface is for read-only field access.

See also