Design notes for a Kill Bill payment plugin that talks to any downstream connector (Stripe, Adyen, …) through the Hyperswitch Prism unified SDK.
Background reading:
../reference/NOTES.md— recon on Adyen / Stripe plugins, the Kill Bill plugin framework, and the Prism Java SDK- Kill Bill — Payment Plugin docs
- Prism repo
docs/architecture/(the publicdocs.hyperswitch.io/prism/prismURL currently 404s; canonical content is in the cloned repo)
Prism is in-process: the Java SDK loads a Rust core via JNA/UniFFI and talks to the connector's HTTPS API directly from the plugin JVM. There is no Prism sidecar to run.
+----------------------------------------------------------------------+
| Kill Bill server |
| |
| +--------------------+ +-------------------------------+ |
| | Payment API |------->| PrismPaymentPluginApi (OSGi) | |
| | (core) | | | |
| +--------------------+ | - per-tenant config handler | |
| | - Prism PaymentClient cache | |
| +--------------------+ | - DAO (kb<->prism mapping) | |
| | Webhook servlet |<------ | | |
| | /webhook/{conn} | +-----+-------------------------+ |
| +--------------------+ | |
| v |
| +----------------------+ |
| | io.hyperswitch:prism | (Java SDK) |
| +----------+-----------+ |
+---------------------------------------+------------------------------+
| JNA / UniFFI
v
+----------------------+
| Prism Rust core | (in-process)
| connector adapters |
+----------+-----------+
| HTTPS
+------------------------+------------------------+
v v v
+-------------+ +-------------+ +-------------+
| Stripe | | Adyen | | ... |
+-------------+ +-------------+ +-------------+
^
| (webhook)
|
Connector --POST-> /plugins/prism-plugin/webhook/{connector}
Implications:
- No network hop to a Prism service —
PaymentClient.authorize(...)is a direct in-JVM call that fans out to the connector via OkHttp inside the Rust core. - Statelessness is on Prism's side; we still need a DAO to map Kill Bill payment IDs ↔ connector transaction IDs (for follow-up captures/voids/refunds and webhook correlation). This matches the Adyen and Stripe plugins' pattern.
- The single shared Rust core means the plugin JAR ships a native
.so/.dylib/.dllextracted at startup. The Kill Bill bundle dir needs to be writable; OrbStack-managed Linux containers handle this transparently.
A Kill Bill PaymentMethod is plugin-scoped but otherwise opaque to Kill Bill — the plugin decides what it represents. To route the same prism-plugin to different downstream connectors per payment method:
- Persist a
prismConnectorfield on each plugin payment-method row (prism_payment_methods.connector), set whenaddPaymentMethodis called. - The value is one of the connector slugs Prism recognises:
stripe,adyen,checkout,worldpay, … (whatever is wired in the Rust core). - Plugin-property override: callers can pass
prismConnector=<slug>on each payment call to force a specific connector, useful for one-off charges that don't have a stored method.
addPaymentMethod(kbPaymentMethodId, props={
prismConnector = "stripe",
prismConnectorToken = "pm_1234..." // optional tokenized credential
})
-> persists row in prism_payment_methods(kb_payment_method_id, connector="stripe", connector_token="pm_1234...")
purchasePayment(kbPaymentMethodId, ...)
-> SELECT connector, connector_token FROM prism_payment_methods WHERE kb_payment_method_id = ?
-> resolve PrismConfigProperties for (tenantId, "stripe")
-> PaymentClient configured for Stripe → client.authorize(...)
Plugin properties (read on each call, override anything stored):
| Property | Purpose |
|---|---|
prismConnector |
connector slug, e.g. stripe, adyen |
prismConnectorToken |
pre-tokenized payment method (e.g. Stripe pm_…); avoids PCI on our side |
prismCaptureMethod |
AUTOMATIC (purchase) or MANUAL (auth-then-capture) — defaulted by which KB method is called |
prismAuthType |
NO_THREE_DS or THREE_DS (v2; v1 = NO_THREE_DS) |
Mirror the Adyen plugin's pattern (see reference/killbill-adyen-plugin/src/main/java/org/killbill/billing/plugin/adyen/core/AdyenConfigurationHandler.java):
-
Extend
PluginTenantConfigurableConfigurationHandler<PrismConfigProperties>. Kill Bill fires aTENANT_CONFIG_CHANGEevent whenever the tenant uploads new config; the handler caches the parsedPrismConfigPropertiesper tenant. -
Upload is via Kill Bill's tenant-config endpoint:
POST /1.0/kb/tenants/uploadPluginConfig/prism-plugin Content-Type: text/yaml -
YAML lets us nest credentials per connector (the SDK's
ConnectorConfigis shaped that way —connectorConfig.stripe.apiKey,connectorConfig.adyen.apiKey + merchantAccount, …):org.killbill.billing.plugin.prism: environment: SANDBOX # or PRODUCTION; applied to all connectors unless overridden connectors: stripe: apiKey: sk_test_xxx webhookSecret: whsec_xxx adyen: apiKey: AQEqhmfxKx... merchantAccount: MyMerchant_TEST hmacKey: 11223344... # for inbound webhook HMAC
-
PrismConfigProperties.forConnector("stripe")returns the per-connector slice used to build a PrismConnectorConfig. -
Nothing is committed to disk by the plugin. We hold credentials in-memory inside the handler (the framework's caching) and rebuild a Prism
ConnectorConfigper request, then construct or look up a cachedPaymentClientkeyed by(tenantId, connector). -
A bootstrap default for local dev only is read from
org.killbill.billing.plugin.prism.*system properties on plugin start — same precedence rules Adyen uses.
PrismPaymentPluginApi currently implements PaymentPluginApi (see PrismPaymentPluginApi.java and the TODO in README.md). Once the jOOQ DAO records exist we switch to extends PluginPaymentPluginApi<...>; the body of each method below stays the same.
Shorthand in the table:
req=PaymentService<Verb>Request.Builderpopulated from KB call argsclient = paymentClientFor(tenantId, connector)— cachedPaymentClientkeyed by(tenantId, connector)dao=PrismDao(to be built; mirrorsAdyenDao)
| Kill Bill method | Prism call | Request fields we set | Response → PaymentTransactionInfoPlugin |
|---|---|---|---|
| authorizePayment | client.authorize(req) |
merchantTransactionId=kbTransactionId, merchantOrderId=kbPaymentId, amount.minorAmount (KB amount × currency scale), amount.currency, captureMethod=MANUAL, connectorToken or raw paymentMethod.card, authType=NO_THREE_DS (v1) |
status → PaymentPluginStatus (§5), connectorTransactionId → firstPaymentReferenceId, connectorResponseReferenceId → secondPaymentReferenceId, raw response stored in prism_responses. Persist row before returning. |
| capturePayment | client.capture(req) |
merchantCaptureId=kbTransactionId, connectorTransactionId (looked up via dao.getAuthorizeResponse(kbPaymentId)), amountToCapture |
Same mapping; new prism_responses row with transaction_type=CAPTURE. |
| purchasePayment | client.authorize(req) with captureMethod=AUTOMATIC |
Same as authorize but captureMethod=AUTOMATIC. |
Same mapping; transaction_type=PURCHASE. |
| voidPayment | client.void(req) |
connectorTransactionId from prior auth |
Same mapping; transaction_type=VOID. |
| refundPayment | client.refund(req) |
merchantRefundId=kbTransactionId, connectorTransactionId of the captured/purchased txn, refundAmount (≤ original), reason (from PluginProperty if present) |
Map RefundStatus (§5), connectorRefundId → firstPaymentReferenceId. |
| getPaymentInfo | (no Prism call by default) | – | Return rows from prism_responses for kbPaymentId. Force-refresh via client.get(req) (using connectorTransactionId) only if the inbound PluginProperty carries prismForceRefresh=true. This matches PluginPaymentPluginApi's default behaviour. |
| addPaymentMethod | (none yet — v2) | – | v1: persist kb_payment_method_id + prismConnector + optional prismConnectorToken. No call to Prism. v2 will call PaymentMethodClient.tokenize(...). |
| deletePaymentMethod | (none) | – | Delete the local row. |
| processNotification | EventClient.handleEvent(req) (see §6) |
raw bytes, headers, connector slug |
EventClient returns a normalized event; we re-emit a Kill Bill GatewayNotification. |
creditPayment, searchPayments, setDefaultPaymentMethod, searchPaymentMethods, resetPaymentMethods, buildFormDescriptor |
(unimplemented in v1) | – | Return UnsupportedOperationException (current stub behaviour). |
Currency conversion: Kill Bill passes BigDecimal amount + ISO currency. Prism's Money.minorAmount is in minor units. Use the currency's exponent (e.g. Currency.getDefaultFractionDigits()) — amount.movePointRight(exp).longValueExact(). Zero-decimal currencies (JPY, KRW) must not be silently rounded.
Kill Bill's status semantics (from the payment plugin docs):
PaymentPluginStatus |
Meaning | Kill Bill transaction status |
|---|---|---|
PROCESSED |
Gateway accepted, terminal success | SUCCESS |
ERROR |
Gateway-confirmed rejection | PAYMENT_FAILURE |
PENDING |
Async, expect a webhook to finalize | PENDING |
CANCELED |
We never reached the gateway (DNS/SSL/connect-refused) | PLUGIN_FAILURE |
UNDEFINED |
Outcome truly unknown (read timeout, 5xx) — possible silent success | UNKNOWN |
The KB docs are emphatic:
CANCELEDandUNDEFINEDare for plugin-side failures. Don't reach forUNDEFINEDwhenever a status doesn't fit — it forces the operator into manual reconciliation.
From crates/types-traits/grpc-api-types/proto/payment.proto:151-201:
| Prism status | KB status | Reason |
|---|---|---|
CHARGED, PARTIAL_CHARGED, PARTIAL_CHARGED_AND_CHARGEABLE, AUTHORIZED, PARTIALLY_AUTHORIZED, VOIDED, VOIDED_POST_CAPTURE, AUTHENTICATION_SUCCESSFUL, AUTO_REFUNDED |
PROCESSED |
Terminal success states from the gateway's perspective. AUTHORIZED is PROCESSED for authorizePayment (capture is a separate KB transaction). |
ROUTER_DECLINED, AUTHORIZATION_FAILED, AUTHENTICATION_FAILED, CAPTURE_FAILED, VOID_FAILED, FAILURE, EXPIRED |
ERROR |
Gateway explicitly declined / failed. |
STARTED, PAYMENT_METHOD_AWAITED, DEVICE_DATA_COLLECTION_PENDING, CONFIRMATION_AWAITED, AUTHENTICATION_PENDING, AUTHORIZING, CAPTURE_INITIATED, VOID_INITIATED, COD_INITIATED, PENDING |
PENDING |
Async — expect a webhook callback. |
UNRESOLVED, PAYMENT_STATUS_UNSPECIFIED |
UNDEFINED |
Prism itself doesn't know. |
| (no Prism status maps here) | CANCELED |
Reserved for the exception path below. |
From payment.proto:221-229:
| Prism refund status | KB status |
|---|---|
REFUND_SUCCESS |
PROCESSED |
REFUND_FAILURE, REFUND_TRANSACTION_FAILURE |
ERROR |
REFUND_PENDING, REFUND_MANUAL_REVIEW |
PENDING |
REFUND_STATUS_UNSPECIFIED |
UNDEFINED |
| Exception (from Prism SDK) | KB status | Notes |
|---|---|---|
IntegrationError |
ERROR |
We built a malformed request — surfaces as a configuration bug, but the gateway has not been charged. Treat as ERROR so the operator sees a real failure; log loudly. |
ConnectorError |
ERROR |
Connector returned an error we could parse. |
NetworkError with errorCode = CONNECT_TIMEOUT | NETWORK_FAILURE |
CANCELED |
We never spoke to the gateway. |
NetworkError with errorCode = RESPONSE_TIMEOUT | TOTAL_TIMEOUT |
UNDEFINED |
Request was sent; outcome unknown. Reconciler must follow up. |
| Anything else / uncaught | UNDEFINED |
Default fallback only when we genuinely can't classify. |
Always set gatewayError (free-text) and gatewayErrorCode (Prism error code string) on the returned PaymentTransactionInfoPlugin so it surfaces in Kaui.
Connectors deliver webhooks directly to us (Stripe → its configured webhook URL, Adyen → its notification URL). Prism is not in the network path; we receive raw bodies. Prism's SDK does provide EventClient.handleEvent(...) to normalize the event payload once we've authenticated it locally.
Endpoint shape — one path per connector so we can wire each connector's dashboard to the right URL and keep signature schemes separate:
POST /plugins/prism-plugin/webhook/{connector}
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
{connector} ∈ {stripe, adyen, ...}
Per-request flow:
- Verify signature in the servlet using the connector-specific secret from the per-tenant config (§3):
- Stripe: HMAC-SHA256 over raw body using
webhookSecret, compared againstStripe-Signature(with timestamp tolerance). - Adyen:
com.adyen.util.HMACValidator.validateHMAC(...)over the parsedNotificationRequestItemusinghmacKey. - Reject with
400(or whatever the connector documents) on mismatch. Never trust the body before verification.
- Stripe: HMAC-SHA256 over raw body using
- Pass to
EventClient.handleEvent(...)with the raw bytes + connector hint to get a normalized event (event type, normalized payment status,connectorTransactionId). - Correlate to a Kill Bill transaction:
dao.getResponseByConnectorTransactionId(connector, connectorTransactionId)→(kbPaymentId, kbTransactionId).- This works because we stored
connectorTransactionIdon the outbound request's response (see §4).
- Idempotency:
INSERT IGNORE INTO prism_notifications(connector, event_id, ...). If the row already exists, return200immediately — the gateway is replaying. - Advance Kill Bill state:
osgiKillbillAPI.getPaymentApi().notifyPendingTransactionOfStateChanged(account, kbTransactionId, newStatus, callContext)if the prior KB transaction isPENDING. - Persist the normalized event into
prism_notificationsand update theprism_responsesrow with the latest status. - Reply with whatever the connector expects (Adyen:
"[accepted]"; Stripe:200).
What we are deliberately not doing:
- We do not unify all connectors behind one
/webhookendpoint. That works only if every connector's signature scheme matches the same byte representation we hand to a single verifier — and they don't. - We do not trust
EventClient.handleEventto verify signatures. Treat it as a parsing helper only; verification is our responsibility because the secret is per-tenant config.
Two layers — outbound (KB → Prism → connector) and inbound (connector → KB webhook):
Map Kill Bill identifiers onto Prism's per-call idempotency keys:
| Kill Bill | Prism field | Connector field (Stripe ex.) |
|---|---|---|
kbPaymentId (UUID) |
merchantOrderId |
metadata.kb_payment_id |
kbTransactionId (UUID, distinct per attempt) |
merchantTransactionId (auth/purchase), merchantCaptureId (capture), merchantRefundId (refund) |
Stripe Idempotency-Key header (Prism sets it from merchantTransactionId) |
(Prism returns) connectorTransactionId |
— | Stripe pi_… / Adyen pspReference |
kbTransactionId is the right idempotency key because Kill Bill generates a fresh one for every retry: the same business intent (re-trying a failed authorize) gets a new UUID, but Kill Bill's notifyPendingTransactionOfStateChanged and replay-on-restart paths preserve it within a single attempt. Using kbPaymentId would collapse retries into one connector call.
connectorTransactionId is persisted as prism_responses.connector_transaction_id so subsequent capture/void/refund calls don't depend on Kill Bill carrying it through plugin properties.
prism_notificationshas a unique index on(connector, event_id).INSERT IGNORE(MySQL/MariaDB) orON CONFLICT DO NOTHING(PostgreSQL).- Stripe webhooks include
id(e.g.evt_…); Adyen webhooks includeeventCode + pspReference + originalReference— we'll derive a syntheticevent_idfor connectors that don't ship one. - The Adyen plugin currently stores every notification but doesn't dedupe — we won't repeat that.
In (this milestone):
- Connectors: Stripe, Adyen.
- Flows:
authorizePayment,capturePayment,purchasePayment,voidPayment,refundPayment,getPaymentInfo. processNotificationfor both connectors (Stripe HMAC + Adyen HMAC) with the per-connector servlet pattern in §6.- Per-tenant YAML config loader (§3).
prism_responses,prism_payment_methods,prism_notificationstables inddl.sql; switchPrismPaymentPluginApito extendPluginPaymentPluginApi<...>once the jOOQ records are generated.
Out (v2+):
- Hosted Payment Page (
buildFormDescriptor). - 3D-Secure / SCA flow (Prism
authType=THREE_DS, redirect handling,verify_redirect_response). - Tokenization (
PaymentMethodClient.tokenize, recurring detail references). - Additional connectors (Checkout, Worldpay, PayPal, …).
creditPayment,searchPayments,searchPaymentMethods,setDefaultPaymentMethod,resetPaymentMethods.- A reconciler / Janitor job for
UNDEFINED-status transactions (Stripe plugin has one — useful but not blocking for v1).
This section is internal scaffolding documentation, not a v1 design choice. It tracks why we don't statically link io.hyperswitch:prism yet and the conditions under which we will.
io.hyperswitch:prism is not on Maven Central. PrismClientFactory therefore performs every SDK call through Class.forName(...) + Method.invoke(...), using these canonical FQNs:
| Reflective lookup | Role |
|---|---|
com.juspay.hyperswitch.prism.PaymentClient |
Constructor takes a SdkConfig$ConnectorConfig |
com.juspay.hyperswitch.prism.types.SdkConfig$ConnectorConfig |
Top-level proto message with newBuilder() |
com.juspay.hyperswitch.prism.types.Payment$ConnectorSpecificConfig |
oneof proto with setStripe(...) / setAdyen(...) |
com.juspay.hyperswitch.prism.types.Payment$StripeConfig |
setApiKey(SecretString) |
com.juspay.hyperswitch.prism.types.Payment$AdyenConfig |
setApiKey, setMerchantAccount |
com.juspay.hyperswitch.prism.types.Payment$SecretString |
setValue(String) |
PrismActivator.start(...) probes for the first FQN with the plugin's own classloader. On miss, it logs ERROR and skips PaymentPluginApi registration — it does not throw, because a thrown start() aborts the entire Kill Bill plugin loader. Kill Bill simply won't route payments to the plugin until the SDK is wired and the bundle is reloaded.
Class.forName / Method.invoke are confined to PrismClientFactory.java. No other class in src/main/ references the SDK by name. Removing reflection is a one-file change.
FQN caveat to verify against a real JAR. The cloned Prism repo's Kotlin source declares
package payments(e.g.sdk/java/src/main/kotlin/payments/Configs.kt:18), but the docs sample (docs/getting-started/installation.md) and the SDK README (sdk/java/README.md) usecom.juspay.hyperswitch.prism.*andcom.hyperswitch.payments.*respectively. We've standardized oncom.juspay.hyperswitch.prism.*because that's what the docs publish for plugin authors. If the actually-published JAR exposes a different surface, the FQN constants inPrismClientFactoryare the only thing to update.
./gradlew publishToMavenLocal against reference/hyperswitch-prism/sdk/java fails with Unresolved reference 'types' in payments/Payments.kt because the Kotlin compile happens before the proto stubs are generated. The supported build path is the SDK's Makefile:
make build-lib # crates/ffi with --features uniffi
make generate-bindings # uniffi-bindgen --language kotlin
make generate-proto # protoc --java_out (requires protoc on PATH)
make pack-archive # ./gradlew jar → artifacts/sdk-java/prism-*.jar
This needs cargo (have it), protoc (not installed), and the Kotlin/Gradle toolchain. We haven't taken the install hit yet; the reflective factory unblocks the rest of the plugin in the meantime.
Option A — local install (works today).
brew install protobuf
cd reference/hyperswitch-prism/sdk/java
make pack
mvn install:install-file \
-Dfile=artifacts/sdk-java/prism-*.jar \
-DgroupId=io.hyperswitch \
-DartifactId=prism \
-Dversion=0.0.4 \
-Dpackaging=jarThen uncomment the <dependency>io.hyperswitch:prism</dependency> block in pom.xml and proceed to "Removing reflection" below.
Option B — wait for Maven Central. The SDK's build.gradle.kts is set up to publish via the central-publisher Gradle plugin, gated on CENTRAL_TOKEN_* env vars. The README claims version 0.0.6 was published; https://search.maven.org/solrsearch/select?q=g:io.hyperswitch+AND+a:prism returns zero. Track that endpoint; when it returns a hit, uncomment the dep with the latest version and proceed.
The reflective code can be deleted once all of these are true:
io.hyperswitch:prismresolves from the project's normal Maven repositories (local M2 or Central).pom.xmldeclares the dep at compile scope andmvn dependency:treeshows it transitively in scope.PrismClientFactoryno longer referencesClass.forNameorMethod.invoke. All SDK calls are direct imports ofcom.juspay.hyperswitch.prism.*.PrismActivator.start(...)no longer probes; the missing-SDK branch is removed.src/test/java/com/juspay/hyperswitch/prism/(the test stubs marked "TEST FIXTURE ONLY") is deleted. The unit test insrc/test/java/org/killbill/billing/plugin/prism/sdkstub/PrismClientFactoryTest.javasurvives but now exercises the real classes — assertions onclient.getConfig()...getApiKey().getValue()remain valid.- CI proves it:
grep -r "Class.forName\|Method.invoke" src/main/returns no results.
Until step 6 is green, this section stays in DESIGN.md.