DSP Blog

Adding Payments to Oracle APEX Using Stripe

Written by Jamie Lambertstock | 25-Feb-2026 15:58:29

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

  • You confirm the payment
  • You verify the result server-side

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.

 

Why Stripe (and why not a pre-built SDK)?

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 Checkout Flow Options

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.

1. Stripe Hosted Checkout Page

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.


3. Embedded Checkout / Payment Elements (My Default)

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.

 

High-Level Flow (APEX + Stripe)

At a high level, the flow looks like this:

  1. User initiates a payment in APEX

  2. APEX backend (PL/SQL) creates a Stripe Checkout Session

  3. Stripe returns a Client Secret

  4. The Client Secret is passed to the page

  5. Stripe.js renders secure payment components

  6. User completes payment

  7. Stripe confirms payment

  8. APEX verifies the result and completes the process

This keeps all sensitive data inside Stripe, not your database.

 

Backend: Creating the Checkout Session (PL/SQL)

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:

  1. Build Stripe line_items[...] from your order/cart tables

  2. Add any optional settings (billing address collection, invoice creation, discounts, taxes, etc.)

  3. Call Stripe’s POST /v1/checkout/sessions endpoint securely

  4. 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 IS

  v_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/cart
       Stripe 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_quantity
    FROM   tb_order_items i
           JOIN tb_courses c ON c.id = i.course_id
           JOIN tb_order_learners l
             ON l.order_id = i.order_id AND l.course_id = i.course_id
    WHERE  i.order_id = p_order_id
  )
  SELECT
    LISTAGG('&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_items
  FROM 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' ||
    CASE
      WHEN 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_items
  INTO v_request
  FROM tb_orders o
  WHERE 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_secret
  INTO   v_session_id, v_client_secret
  FROM json_table(v_response, '$'
       COLUMNS (
         id            VARCHAR2 PATH '$.id',
         client_secret VARCHAR2 PATH '$.client_secret'
       )) jt;

  /*
    6) Persist safe identifiers only
       DO NOT store client_secret (browser-only)
  */
  UPDATE tb_orders
  SET    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.

 

APEX Page Flow: Ensuring Things Run in the Right Order

One APEX-specific detail worth noting is where and when this backend code runs.

In my projects, I typically:

  1. Run the PL/SQL checkout-session logic in a page rendering process

  2. Store the returned client_secret in a hidden page item

  3. 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.

 

Frontend: Rendering the Payment UI with Stripe.js

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

 

Verifying the Payment (Don’t Skip This!)

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:

  1. The frontend confirms payment with Stripe.

  2. Your app then asks Stripe directly for the session status.

  3. 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:

  • Fetches the checkout session from Stripe (GET /v1/checkout/sessions/{id})
  • Reads payment_status and the payment_intent
  • Updates the order status
  • If paid, writes a payment record and triggers email confirmations
PROCEDURE check_order_status (
  p_session_id IN VARCHAR2
) IS
  v_response        CLOB;
  v_status          VARCHAR2(255);
  v_payment_intent  VARCHAR2(255);
  v_order_id        NUMBER;
BEGIN
  -- 1) Ask Stripe directly for the session status
  v_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 response
  SELECT upper(jt.payment_status),
         jt.payment_intent
  INTO   v_status,
         v_payment_intent
  FROM 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_orders
  SET    order_status      = v_status,
         payment_intent_id = v_payment_intent
  WHERE  session_id = p_session_id
  RETURNING id INTO v_order_id;

  -- 4) If Stripe confirms paid, persist payment details + trigger fulfilment
  IF 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!

 

Final Thoughts

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.