Adding payments to an Oracle APEX application is one of those things that sounds intimidating, but in reality follows a fairly standard pattern once you understand the moving parts.
There are numerous payment providers available, including Stripe, PayPal, Adyen, Worldpay, Braintree, and many more. While each has its own APIs and quirks, the core concepts are very similar:
You create a payment intent/session on the backend
You collect payment details securely on the frontend
In this blog, I’ll focus on Stripe because it’s extremely well-documented, it strikes a good balance between features and cost, and it’s been reliable across multiple real-world projects I’ve worked on. That said, the same architecture applies to most providers; you’ll just need to adapt the API calls based on their documentation.
Depending on your platform and language, you might find ready-made Stripe SDKs or plugins that handle most of the work for you. However, Oracle APEX is a bit different: there’s no official Stripe APEX plugin, you’re typically working with PL/SQL on the backend, and JavaScript is used only where necessary on the frontend.
Rather than relying on an SDK, I usually call Stripe’s REST APIs directly from PL/SQL and use Stripe.js on the frontend to handle sensitive payment data. This approach keeps things secure (no card data ever touches your server) while remaining flexible and portable to other backend systems if needed later.
Stripe gives you a few different ways to collect payments. Choosing the right one depends on how much control you want over the user experience.
What it is:
Stripe hosts the entire checkout page, and you simply redirect the user to it.
Pros:
Fastest to implement
Stripe handles UI, validation, and compliance
Very secure
Cons:
Limited branding/customisation
Harder to blend into an existing APEX UX
Best for:
Simple payment flows where branding isn’t critical.
What it is:
Pre-generated payment URLs created in the Stripe dashboard.
Pros:
No code required
Great for one-off payments
Cons:
Almost no control
Not suitable for dynamic pricing or logged-in users
Best for:
Manual payments or admin-driven processes.
What it is:
You build your own page, embed Stripe’s payment components using Stripe.js, and control the flow end-to-end.
Pros:
Full UX control
Seamless integration with APEX pages
Supports complex flows (courses, subscriptions, add-ons, etc.)
Cons:
Slightly more setup
Requires JavaScript and backend coordination
Best for:
Most serious APEX applications.
This is the approach I usually default to, and the one I’ll focus on here.
At a high level, the flow looks like this:
User initiates a payment in APEX
APEX backend (PL/SQL) creates a Stripe Checkout Session
Stripe returns a Client Secret
The Client Secret is passed to the page
Stripe.js renders secure payment components
User completes payment
Stripe confirms payment
APEX verifies the result and completes the process
This keeps all sensitive data inside Stripe, not your database.
In APEX, I typically create the Stripe Checkout Session from PL/SQL and then return the client_secret to the page, allowing Stripe.js to render the payment components.
The backend step has four jobs:
Build Stripe line_items[...] from your order/cart tables
Add any optional settings (billing address collection, invoice creation, discounts, taxes, etc.)
Call Stripe’s POST /v1/checkout/sessions endpoint securely
Parse the response and store the safe identifiers against your order
What to store (and what not to store):
Store session_id (useful for reconciliation, refunds, support queries, webhooks)
Store your internal order status (e.g. PENDING_PAYMENT)
Do not store client_secret in your database (treat it as browser-only)
Tip: Always pass your internal identifier in Stripe metadata (for example, order_id). It makes it far easier to match Stripe events back to your application later.
Using APEX Web Credentials for the Stripe secret key:
Your Stripe secret key must never be exposed to the browser. In APEX, the cleanest approach is to store it in a Web Credential, then reference it via p_credential_static_id when calling Stripe.
Example: Create Session + return client_secret:
Below is a simplified version of the backend function I tend to use. It builds dynamic line_items from the order, optionally applies a discount, calls Stripe, and returns the client_secret to APEX.
Note: This is intentionally “APEX-style” and uses the format Stripe expects for Checkout Sessions, where line_items are indexed (line_items[0], line_items[1], etc.).
FUNCTION create_session_elements (p_order_id IN NUMBER) RETURN VARCHAR2 ISv_request CLOB;v_line_items CLOB;v_response CLOB;v_client_secret VARCHAR2(255);v_session_id VARCHAR2(255);v_coupon_id VARCHAR2(255);BEGIN/*1) Build line_items[] from your order/cartStripe expects amounts in the smallest currency unit (EUR cents),so we multiply by 100.*/WITH cart_items AS (SELECT row_number() over (order by null) - 1 AS row_id,c.name AS name,l.cost AS item_value,c.summary AS item_summary,c.image_url AS item_url,1 AS item_quantityFROM tb_order_items iJOIN tb_courses c ON c.id = i.course_idJOIN tb_order_learners lON l.order_id = i.order_id AND l.course_id = i.course_idWHERE i.order_id = p_order_id)SELECTLISTAGG('&line_items['||row_id||'][price_data][currency]=EUR', '&') ||LISTAGG('&line_items['||row_id||'][price_data][product_data][name]='|| utl_url.escape(name), '&') ||LISTAGG('&line_items['||row_id||'][price_data][product_data][description]='|| utl_url.escape(item_summary), '&') ||LISTAGG('&line_items['||row_id||'][price_data][unit_amount_decimal]='|| (item_value * 100), '&') ||LISTAGG('&line_items['||row_id||'][quantity]='||item_quantity, '&')INTO v_line_itemsFROM cart_items;/*2) Optional discount logic (if you have vouchers/coupons)v_coupon_id := create_coupon(...);*//*3) Build request body- return_url: where Stripe redirects back to your APEX app- ui_mode=custom: required for this embedded flow- metadata: includes your internal order id*/SELECT'customer_email=' || utl_url.escape(o.email) ||'&return_url=' || utl_url.escape(apex_util.host_url || '/ords/r/YOUR_APP/home') ||'&mode=payment' ||'&ui_mode=custom' ||CASEWHEN v_coupon_id IS NOT NULL THEN'&discounts[0][coupon]=' || utl_url.escape(v_coupon_id)END ||'&billing_address_collection=auto' ||'&invoice_creation[enabled]=true' ||'&payment_method_types[0]=card' ||'&metadata[order_id]=' || o.id ||'&payment_intent_data[metadata][order_id]=Order #' || o.id ||v_line_itemsINTO v_requestFROM tb_orders oWHERE o.id = p_order_id;/*4) Call Stripe using a Web Credential that contains your secret key*/apex_web_service.set_request_headers('Stripe-Version', '2025-09-30.clover');v_response := apex_web_service.make_rest_request(p_url => api_url || '/v1/checkout/sessions',p_http_method => 'POST',p_body => v_request,p_credential_static_id => 'STRIPE_API_KEY');/*5) Parse response: id + client_secret*/SELECT jt.id, jt.client_secretINTO v_session_id, v_client_secretFROM json_table(v_response, '$'COLUMNS (id VARCHAR2 PATH '$.id',client_secret VARCHAR2 PATH '$.client_secret')) jt;/*6) Persist safe identifiers onlyDO NOT store client_secret (browser-only)*/UPDATE tb_ordersSET session_id = v_session_id,order_status = 'PENDING_PAYMENT'WHERE id = p_order_id;RETURN v_client_secret;END create_session_elements;
A couple of production notes:
Escape strings: Anything that could contain spaces, &, =, or non-ascii characters should be URL-escaped (names, summaries, emails, return URLs).
Idempotency: In production, it’s worth adding an idempotency key (e.g. based on order_id), so retries don’t create multiple Stripe sessions.
Verification: The frontend flow should never be the final source of truth — always verify payment status server-side (Stripe API and/or webhooks) before granting access to paid content.
One APEX-specific detail worth noting is where and when this backend code runs.
In my projects, I typically:
Run the PL/SQL checkout-session logic in a page rendering process
Store the returned client_secret in a hidden page item
Initialise Stripe.js via a Dynamic Action on page load
This aligns cleanly with how APEX pages are rendered and avoids timing issues.
Typical setup in APEX:
1) Page Process (After Header / Before Regions)
I run the backend function during page render - usually in After Header (or Before Regions, depending on the page).
Example:
:P40_CLIENT_SECRET := create_session_elements(:P40_ORDER_ID);
This ensures the Stripe session is created before any JavaScript executes, and the client_secret is available as soon as the page loads.
Important: The client_secret is stored only in session state (page item) and not persisted to the database.
2) Page Item
PXX_CLIENT_SECRET
Type: Hidden
Session state protected as needed
This acts as the handoff between PL/SQL and JavaScript.
3) Dynamic Action: On Page Load -> Execute JavaScript Code
Instead of putting Stripe logic directly in the page template, I trigger it via a Dynamic Action:
Event: Page Load
True Action: Execute JavaScript Code
Code: (Stripe.js initialisation logic)
This ensures:
The page is fully rendered
PXX_CLIENT_SECRET is already populated
Stripe Elements mount reliably every time
Why this approach works well in APEX:
Respects the APEX page lifecycle
Avoids race conditions between PL/SQL and JavaScript
Keeps responsibilities clear:
PL/SQL -> create session
JS -> render UI and confirm payment
Easy to debug (you can inspect the page item value)
It’s a simple pattern, but it removes a whole class of “why is my Stripe element undefined?” problems.
Once you have the client_secret, you can initialise Stripe on the page.
Here’s an example using Stripe’s JS library and APEX-friendly patterns:
const spinner$ = apex.util.showSpinner(
apex.jQuery("#payment-element").parent()
);
(async () => {
// 1) Init Custom Checkout
const stripe = Stripe('&STRIPE_PUBLIC_KEY.');
const checkout = await stripe.initCheckout({
clientSecret: $v('P40_CLIENT_SECRET'),
elementsOptions: {
appearance: {
theme: 'flat',
inputs: 'spaced',
labels: 'auto'
}
}
});
const clientSecret = $v('P40_CLIENT_SECRET');
if (!clientSecret || !clientSecret.startsWith('cs_')) {
apex.message.showErrors([{
type: 'error',
message: 'Payment information could not be loaded.'
}]);
spinner$ && spinner$.remove();
return;
}
// 2) Load actions
const load = await checkout.loadActions();
if (load.type !== 'success') {
console.error(load);
return;
}
const { actions } = load;
// 3) Mount the Payment Element
const paymentElement = checkout.createPaymentElement({
layout: { type: 'tabs' }
});
paymentElement.mount("#payment-element");
// 4) Mount the Billing Address Element
const billingAddressElement = checkout.createBillingAddressElement();
billingAddressElement.mount("#billing-address-element");
// 5) Button logic
const payBtn = document.getElementById("PAY_NOW");
let paymentComplete = false;
let billingComplete = false;
let paymentHasError = false;
const updateBtn = () => {
payBtn.disabled = !(paymentComplete && billingComplete) || paymentHasError;
};
paymentElement.on('ready', () => spinner$ && spinner$.remove());
paymentElement.on('change', e => {
paymentComplete = !!e.complete;
paymentHasError = !!e.error;
updateBtn();
});
billingAddressElement.on('change', e => {
billingComplete = !!e.complete;
updateBtn();
});
// 6) Confirm payment
payBtn.addEventListener('click', async (evt) => {
evt.preventDefault();
payBtn.disabled = true;
const { type, error } = await actions.confirm({
redirect: "if_required"
});
if (type === 'error') {
apex.message.showErrors([{
type: 'error',
message: error.message
}]);
updateBtn();
return;
}
const session = actions.getSession();
if (session?.status?.paymentStatus === 'paid') {
apex.submit('PAYMENT_CHECK');
}
});
})();
Why This Approach Works Well in APEX:
Stripe handles all sensitive data
APEX controls the business logic
You get a modern, accessible payment UI
The flow feels native to your application
You can easily:
Store transaction metadata
Handle subscriptions
Apply discounts
Support multiple currencies
Even though Stripe.js can tell you a payment looks successful, you should treat the browser as a convenience layer, not a source of truth.
The safest pattern is:
The frontend confirms payment with Stripe.
Your app then asks Stripe directly for the session status.
Only if Stripe confirms payment_status = paid do you mark the order as paid / grant access / send emails.
In my APEX flow, the JavaScript calls:
apex.submit('PAYMENT_CHECK');
That request is handled server-side, where I call Stripe’s API using the stored SESSION_ID.
Example: Server-side verification from PL/SQL:
This is the procedure I typically use. It:
PROCEDURE check_order_status (p_session_id IN VARCHAR2) ISv_response CLOB;v_status VARCHAR2(255);v_payment_intent VARCHAR2(255);v_order_id NUMBER;BEGIN-- 1) Ask Stripe directly for the session statusv_response := apex_web_service.make_rest_request(p_url => api_url || '/v1/checkout/sessions/' || p_session_id,p_http_method => 'GET',p_credential_static_id => 'STRIPE_API_KEY');-- 2) Decode status from JSON responseSELECT upper(jt.payment_status),jt.payment_intentINTO v_status,v_payment_intentFROM json_table(v_response, '$'COLUMNS (payment_status VARCHAR2 PATH '$.payment_status',payment_intent VARCHAR2 PATH '$.payment_intent')) jt;-- 3) Update order regardless (useful for failed/unpaid states too)UPDATE tb_ordersSET order_status = v_status,payment_intent_id = v_payment_intentWHERE session_id = p_session_idRETURNING id INTO v_order_id;-- 4) If Stripe confirms paid, persist payment details + trigger fulfilmentIF v_status = 'PAID' THEN/*IMPORTANT:Make this insert idempotent in production (see notes below).*/INSERT INTO tb_payments (order_id,stripe_id,subtotal,discount,tax,total,currency,customer_id,customer_name,customer_email,payment_intent,payment_status)SELECT *FROM json_table(v_response, '$'COLUMNS (order_id NUMBER PATH '$.metadata.order_id',stripe_id VARCHAR2(255) PATH '$.id',subtotal NUMBER PATH '$.amount_subtotal',discount NUMBER PATH '$.total_details.amount_discount',tax NUMBER PATH '$.total_details.amount_tax',total NUMBER PATH '$.amount_total',currency VARCHAR2(10) PATH '$.currency',customer_id VARCHAR2(255) PATH '$.customer',customer_name VARCHAR2(255) PATH '$.customer_details.name',customer_email VARCHAR2(255) PATH '$.customer_details.email',payment_intent VARCHAR2(255) PATH '$.payment_intent',payment_status VARCHAR2(50) PATH '$.payment_status'));email_order_details(v_order_id);email_order_details_learners(v_order_id);END IF;END check_order_status;
Production notes (small but important)
Idempotency / duplicate inserts:
Users can refresh, use the back button, or resubmit. Make sure your “PAYMENTS” table can’t insert the same Stripe session twice. Common approaches:
Unique constraint on stripe_id (session id) or payment_intent
Or a MERGE instead of INSERT
Handle more than just “PAID”:
You may want to explicitly handle unpaid, no_payment_required, and error states depending on your product.
Webhooks are the next step:
This “pull” verification works well and is easy to reason about in APEX. For higher reliability (e.g. user closes the browser mid-flow), add Stripe webhooks later and reconcile asynchronously, or add both!
Adding payments to Oracle APEX doesn’t require magic - just a clean separation of responsibilities:
APEX / PL/SQL -> business logic, session creation, verification
Stripe.js -> secure UI and payment handling
While I’ve used Stripe here, the same principles apply to most payment providers. Always review their documentation carefully and choose the flow that best matches your UX and security needs.
If you already have APEX calling REST APIs, you’re 90% of the way there.
For more information, check out our Oracle APEX Services, or contact us today, and one of our expert developers will be in touch.