Use the Onerway Direct API to build a fully customized subscription flow. This approach gives you complete control over checkout UX and billing logic — choose between letting Onerway automatically handle renewals (managed) or triggering renewals yourself (self-managed). Because you directly handle cardholder data (CHD), this integration requires PCI DSS compliance.
Integration Complexity
Use Cases
Key Features
By the end of this guide, you'll have a working subscription system that:
Choose your subscription mode before you begin — the integration steps differ depending on which mode you select.
Use case: Let Onerway handle all renewals automatically, reducing development and maintenance overhead.
Features:
subscriptionManageUrl)BIND_CARD (store credential tokenization result) + SALE (initial CIT result)Workflow:
| Object | Description |
|---|---|
| Customer | Represents the customer in your system, identified by merchantCustId. All subscriptions and renewals for a customer are linked through this identifier. |
| Subscription | Subscription agreement containing product, billing cadence, billing anchor, trial settings, mode, expiration, and notification email |
| Contract | Subscription contract identified by , used to reference MIT charges |
| Token | Stored credential token identified by , used for subsequent billing |
After a successful initial subscription, track these fields:
contractId
Subscription contract ID — required for renewals and cancellations
tokenId
Stored credential token — required for self-managed MIT renewals
subscriptionManageUrl
Customer self-service portal URL (managed subscriptions only)
status
Transaction result:
"S" success"F" failure"R" requires 3DS challenge/redirect (final outcome delivered asynchronously via webhook)dataStatus
Contract status:
0 Pending1 Active2 Disabled3 CanceledsubscriptionStatus
Current subscription state (trialing, active, pastdue, etc.); see mapping table below
subscriptionStatus | Description | dataStatus |
|---|---|---|
paymentdue | Awaiting payment: initial CIT not completed | 0 Pending |
trialing | Trial period: zero-amount verification, charge after trial | 0 Pending |
active | Active: charge successful | 1 Active |
pastdue | Overdue: renewal charge failed | 1 Active |
paused | Paused | 1 Active |
canceled | Canceled by customer | 3 Canceled |
ended | Ended: naturally expired | 2 Disabled |
See Set Up Your Development Environment to get your merchantNo, appId, and Secret Key.
Onerway identifies customers via merchantCustId. No Onerway API call is needed to create a customer — simply generate a unique identifier in your system and pass it when creating the subscription:
// Generate and store a unique customer identifier
String merchantCustId = "CUST-" + UUID.randomUUID();
userRepository.saveMerchantCustId(userId, merchantCustId);
merchantCustId is your system's unique identifier for the customer. All subscriptions and renewals for the same user are linked through it.The two subscription modes are distinguished by the selfExecute field: "1" for managed, "2" for self-managed.
Onerway handles all renewal cycles automatically. Trial periods and customer notification emails are optional.
cycleCount (total billing cycles) or expireDate (contract end date), not both.notificationEmail sends payment notifications to your customer.mode to "2" for an immediate CIT charge (required for card-based subscriptions). "1" is a zero-amount verification mode only supported for specific local payment methods such as DANA or WeChat Pay — credit cards do not support this value.Map<String, Object> subscription = new HashMap<>();
subscription.put("requestType", "0"); // Initial purchase
subscription.put("merchantCustId", merchantCustId);
subscription.put("selfExecute", "1"); // Managed: Onerway auto-renews
subscription.put("mode", "2"); // "1"=zero-auth, "2"=immediate charge
subscription.put("frequencyType", "M"); // D=day, M=month, Y=year
subscription.put("frequencyPoint", "1"); // Every 1 month
subscription.put("cycleCount", 12); // Total billing cycles (or use expireDate)
subscription.put("notificationEmail", email); // Optional: customer email notifications
merchantNo, merchantTxnId, orderAmount, orderCurrency, cardInfo, billingInformation, shippingInformation, and a request signature. See the API ReferencePayments API for the full parameter list.3DS verification flow:
status == "R", retrieve the redirectUrlredirectUrl to complete verificationreturnUrlstatus == "R" does not indicate success)// Check whether a 3DS redirect is needed
if ("R".equals(status)) {
return Map.of("action", "redirect", "url", redirectUrl);
}
// Perform redirect (use replace to avoid adding an intermediate page to history)
if (result.action === 'redirect') {
window.location.replace(result.url);
}
async/await or .then() callbacks may be blocked as popups in Safari and other browsers.Set up a webhook endpoint to receive payment notifications from Onerway. Use the status, subscriptionStatus, and scenarios fields to drive your business logic. Treat delivery as at-least-once and implement idempotent processing with transactionId.
transactionId value (plain text) only after successful processing. transactionId is unique per transaction, and retries for the same transaction use the same value. If Onerway receives no valid response, it retries after 30 minutes, up to 2 times.@PostMapping(value = "/webhook", produces = "text/plain")
public String handleWebhook(@RequestBody Map<String, Object> data) {
String transactionId = (String) data.get("transactionId");
// 1. Verify signature (see API docs)
if (!verifySignature(data)) {
return "";
}
// 2. Idempotency guard: transactionId is the deduplication key
if (alreadyProcessed(transactionId)) {
return transactionId;
}
try {
// 3. Parse key fields
String txnType = (String) data.get("txnType");
String status = (String) data.get("status"); // S=success, F=failure
String merchantCustId = (String) data.get("merchantCustId");
// 4. Dispatch by transaction type
if ("BIND_CARD".equals(txnType)) {
handleBindCard(data, status, merchantCustId);
} else if ("SALE".equals(txnType)) {
handleSale(data, status, merchantCustId);
}
// 5. Mark success and acknowledge
markProcessed(transactionId);
return transactionId;
} catch (Exception ex) {
// Return empty response to trigger Onerway retry
log.error("Webhook processing failed, transactionId={}", transactionId, ex);
return "";
}
}
// Handle card-binding notification (BIND_CARD does not include contractId)
private void handleBindCard(Map<String, Object> data, String status, String merchantCustId) {
if (!"S".equals(status)) {
log.warn("Card binding failed: merchantCustId={}", merchantCustId);
return;
}
saveTokenId(merchantCustId, (String) data.get("tokenId"));
}
// Handle charge notification
private void handleSale(Map<String, Object> data, String status, String merchantCustId) {
String scenarios = (String) data.get("scenarios");
String subscriptionStatus = (String) data.get("subscriptionStatus");
String contractId = (String) data.get("contractId");
String tokenId = (String) data.get("tokenId");
// Save subscription credentials first (idempotent)
saveSubscription(merchantCustId, contractId, tokenId);
// Check transaction result
if (!"S".equals(status)) {
handlePaymentFailure(merchantCustId, subscriptionStatus);
return;
}
// Dispatch by subscription event type
switch (scenarios) {
case "SUBSCRIPTION_INITIAL":
handleInitialSubscription(merchantCustId, subscriptionStatus);
break;
case "SUBSCRIPTION_RENEWAL":
handleRenewalSuccess(merchantCustId, contractId);
break;
case "SUBSCRIPTION_CANCELED":
case "SUBSCRIPTION_ENDED":
revokeAccess(merchantCustId);
break;
}
}
// Initial subscription: activate service based on subscriptionStatus
private void handleInitialSubscription(String merchantCustId, String subscriptionStatus) {
switch (subscriptionStatus) {
case "active": grantFullAccess(merchantCustId); break;
case "trialing": grantTrialAccess(merchantCustId); break;
// paymentdue: awaiting payment, do not activate yet
}
}
// Renewal succeeded
private void handleRenewalSuccess(String merchantCustId, String contractId) {
extendUserAccess(merchantCustId);
subscriptionRepository.updateNextBillingDate(contractId);
}
// Payment failed
private void handlePaymentFailure(String merchantCustId, String subscriptionStatus) {
if ("pastdue".equals(subscriptionStatus)) {
sendPaymentReminder(merchantCustId); // Renewal failed — send dunning notification
}
}
Webhooks by subscription mode:
| Timing | Managed Subscription | Self-Managed Subscription |
|---|---|---|
| Initial subscription | BIND_CARD + SALE (one each) | BIND_CARD + SALE (one each) |
| Subsequent renewals | SALE (auto-triggered by Onerway) | SALE (triggered after merchant calls renewal API) |
| Field | Description |
|---|---|
status | Transaction result: S success / F failure |
scenarios | Subscription event: SUBSCRIPTION_INITIAL, SUBSCRIPTION_RENEWAL, etc. |
subscriptionStatus | Subscription state: active, trialing, pastdue, etc. |
contractId | Contract ID (returned in SALE only, not in BIND_CARD) |
tokenId | Payment token (returned in both notification types) |
transactionId | Unique payment order ID per transaction; use as the webhook idempotency key |
BIND_CARD and SALE may arrive in either order during initial subscription — ensure idempotent handling. For the full field list and signature verification, see the NotificationPayments API.Verify the following events fire correctly during testing:
| Event | When it fires |
|---|---|
BIND_CARD | After the customer submits card details |
SALE + SUBSCRIPTION_INITIAL | After 3DS completes and the initial charge succeeds |
SALE + SUBSCRIPTION_RENEWAL | After each renewal cycle |
SALE + SUBSCRIPTION_CANCELED | After a cancellation request is processed |
Use the subscriptionStatus from the SALE webhook to manage customer access:
subscriptionStatus | Recommended action |
|---|---|
active | Grant full access |
trialing | Grant trial access |
paymentdue | Awaiting payment — do not activate |
pastdue | Maintain access + send dunning notification (grace period policy) |
paused | Suspend access, retain data |
canceled / ended | Revoke access |
contractId and tokenId — no raw card data is transmittedSelf-managed subscriptions require a renewal scheduler in your system. Update business state only after receiving the webhook — do not update immediately after the API call:
@Scheduled(cron = "0 0 2 * * ?") // Run daily at 2 AM to process due renewals
public void processRenewals() {
List<Subscription> dueSubscriptions =
subscriptionRepository.findByNextBillingDateBefore(LocalDate.now());
for (Subscription sub : dueSubscriptions) {
Map<String, Object> subscription = new HashMap<>();
subscription.put("requestType", "1"); // Renewal
subscription.put("merchantCustId", sub.getMerchantCustId());
subscription.put("contractId", sub.getContractId());
subscription.put("tokenId", sub.getTokenId());
// billingInformation can be empty: "{}"
// Call renewal API (see API docs for full parameter list)
httpClient.post(ONERWAY_API_URL, buildRequest(subscription));
// Wait for the SALE webhook before updating business state
}
}
// Update business state after receiving the renewal SALE webhook
private void handleRenewalSale(JSONObject data) {
if ("S".equals(data.getString("status"))) {
extendUserAccess(data.getString("merchantCustId"));
subscriptionRepository.updateNextBillingDate(data.getString("contractId"));
}
}
Allow customers to cancel their subscriptions from your interface. Update business state based on the webhook notification — do not revoke access immediately after the API call:
// Frontend: confirm and submit cancellation
async function cancelSubscription(contractId) {
if (!confirm('Are you sure you want to cancel your subscription?')) return;
await fetch('/api/cancel-subscription', {
method: 'POST',
body: JSON.stringify({ contractId })
});
}
// Backend: call cancellation API (access is revoked when the SUBSCRIPTION_CANCELED webhook arrives)
@PostMapping("/api/cancel-subscription")
public ResponseEntity<?> cancelSubscription(@RequestBody Map<String, String> req) {
Map<String, Object> cancelRequest = new HashMap<>();
cancelRequest.put("contractId", req.get("contractId"));
// Call cancellation API (see API docs for full parameter list)
httpClient.post(ONERWAY_CANCEL_API_URL, buildRequest(cancelRequest));
// Access is revoked when the SUBSCRIPTION_CANCELED webhook arrives
return ResponseEntity.ok(Map.of("message", "Cancellation request submitted"));
}
Use the following test cards to verify different scenarios (see Test CardPayments API for the full list):
Success scenarios:
| Card Number | Country | 3DS Behavior |
|---|---|---|
4000 0209 5159 5032 | US | Challenge (verification required) |
2221 0081 2367 7736 | US | Challenge (verification required) |
Failure scenarios:
| Card Number | Country | Failure Reason |
|---|---|---|
4000 1284 4949 8204 | US | Do not honor |
4021 9371 9565 8141 | GB | Insufficient funds |
Use these test credentials for all cards above:
Complete the following based on your chosen subscription mode:
Common (both modes):
Managed subscriptions (verify only):
Self-managed subscriptions (verify only):
Identify subscription event types via the scenarios field:
| Event | Description |
|---|---|
SUBSCRIPTION_INITIAL | Initial subscription purchase |
SUBSCRIPTION_RENEWAL | Periodic renewal |
SUBSCRIPTION_CARD_REPLACEMENT | Payment method update |
SUBSCRIPTION_CHANGED | Subscription modification |
SUBSCRIPTION_CANCELED | Subscription cancellation |
SUBSCRIPTION_ENDED | Subscription expiration |