Skip to main content

Online Payment Checkout Component

The Online Payment Checkout Component is a lightweight, embeddable checkout that drops into your website as an iframe. With a few lines of code you get a secure, mobile-friendly payment experience that keeps the customer on your page.

At a high level:

  1. Your server creates a payment and receives a unique payment_url.
  2. Your page renders that URL in an iframe with isEmbeddedInIframe=1.
  3. The component reports the result to your page with a postMessage event.
  4. Your server confirms the payment before fulfilling the order.

Demo site (staging)

ecom-php-demo.kody.com/checkout-iframe.php

A staging sample integration you can try before writing any code. Set the amount, currency, and order ID, tick Enable iframe mode, optionally set an expiry and countdown timer, then press Pay — the checkout appears in an embedded iframe and the page reports the postMessage outcome back to you.

Pay with a card from the test cards reference. No real money moves: the demo runs against the staging environment only.

The demo is hosted on a Kody domain

That domain is already on the CSP allowlist, which is why the iframe loads there immediately. Your own domains still need to be allowlisted before the component will render on your site — see Before you start.


Before you start

Use your own payment link — the links in this page are examples only

Wherever the snippets below show YOUR_PAYMENT_URL, that is a placeholder — it will not process a payment for your account.

Replace it with a payment_url that you generate through Initiate Payment.

A payment_url is single-use, per-order, and time-limited — generate a fresh one for every order and never hard-code one into your application.

Get your domain allowlisted first — required for the iframe to load

The checkout page restricts which parent sites may embed it (browser CSP validation). Until your domain is on the allowlist, the iframe will be blocked and your page will show an empty frame.

Email integrations@kody.com with every origin you need to embed from, and the Kody team will add them to the CSP allowlist. See Domain allowlisting and CSP for exactly what to send.


Environments

Test on staging first, then switch to live. The two environments are completely separate: a staging API key cannot create live payments, and a staging payment link will never work on a live site. When you go live you must change both the API host and the checkout origin your page trusts.

Staging (development and test)Live
gRPC API host — Asia-Pacificgrpc-staging-ap.kodypay.comgrpc-ap.kodypay.com
gRPC API host — Europegrpc-staging-eu.kodypay.comgrpc-eu.kodypay.com
API key (X-API-Key)Your staging keyYour live key
Checkout origin (payment_url host)https://p-staging.kody.comhttps://p.kody.com
CardsTest cards — no real money movesReal cards, real money

Use the regional host that matches your store. See the Payments API overview for the full list.

Three things must switch together
  1. The API host and key your server calls — a staging key against a live host is rejected.
  2. The checkout origin your message listener trusts — see Step 3. Hard-coding https://p.kody.com makes the origin check reject every legitimate message in staging, and vice versa.
  3. Your CSPframe-src must allow whichever checkout origin that environment serves.

You never build the payment link yourself: whichever host you call, InitiatePayment returns a payment_url on the matching checkout origin. Just never mix a link from one environment with the configuration of the other.


Call InitiatePayment from your backend, once per order, and keep the returned payment_url. Use the host and key for the environment you are testing in — see Environments.

Six fields are required — the request is rejected if any is missing:

store_id · payment_reference · amount_minor_units · currency · order_id · return_url

For the full request and response reference — including the optional fields (order_metadata, payer_email_address, payer_locale, tokenise_card, expiry, and others) — see the Initiate Payment reference.

Two fields are worth calling out for the embedded component:

  • return_url — the URL to redirect to after the payment is authorised.
  • expiry.expiring_seconds — defaults to 1800. When it lapses the component reports outcome: "expired". Set expiry.show_timer to show the customer a countdown.
Never generate the payment link in the browser

InitiatePayment requires your API key (X-API-Key). Call it from your server only — never from client-side JavaScript.


Step 2 — Embed the iframe (client side)

<iframe
id="kody-iframe"
title="Secure payment"
allow="payment"
src="YOUR_PAYMENT_URL?isEmbeddedInIframe=1"
></iframe>
Use ? or & depending on the payment_url you were given

isEmbeddedInIframe=1 is a query parameter, so the separator depends on whether the payment_url already has a query string:

payment_url returned by InitiatePaymentSeparatorResulting src
No query string — https://p.kody.com/P._pay.2HgE6Jj?…/P._pay.2HgE6Jj?isEmbeddedInIframe=1
Already has a query string — https://p-staging.kody.com/P._pay.2HgE6Jj?storeId=6c1b0a47-…&…?storeId=6c1b0a47-…&isEmbeddedInIframe=1

Do not assume one or the other — build the separator from the URL so both work:

const src = paymentUrl + (paymentUrl.includes("?") ? "&" : "?") + "isEmbeddedInIframe=1";

Using ? twice produces an invalid URL, and the checkout will not switch into embedded mode (so PAYMENT_COMPLETE never fires).

RequirementWhy it matters
isEmbeddedInIframe=1 must be appended to the payment_url, with ? or & as appropriateSwitches the page into embedded mode. Without it the component will not emit PAYMENT_COMPLETE.
allow="payment" on the <iframe>The Permissions Policy default for payment is self, so a cross-origin frame cannot use the Payment Request API unless the parent delegates it. Omit this and Apple Pay / Google Pay fail with a SecurityError.
An explicit heightThe component does not emit resize events, so it cannot grow the host frame. Give it a fixed height (640px or more is recommended) and a responsive width.
title attributeScreen-reader accessibility.
If you use the sandbox attribute

The default (no sandbox) is correct for most integrations. If your CSP policy forces you to add it, you must keep at least: sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-top-navigation-by-user-activation". Removing any of these can break 3-D Secure or wallet flows.


Step 3 — Handle the payment outcome

When the customer finishes, the component posts a message to the parent window:

{ "type": "PAYMENT_COMPLETE", "outcome": "success" }
FieldTypeValues
typestringAlways "PAYMENT_COMPLETE".
outcomestring"success" · "expired" · "error"

Your listener must apply all four checks below. A message event can be sent by any frame, extension, or script on the page, so an unchecked handler can be tricked into showing a successful payment that never happened.

#CheckCode
1Verify the sender's originif (event.origin !== "https://p.kody.com") return;
2Verify the sender is your iframeif (event.source !== iframe.contentWindow) return;
3Validate outcome against a known list["success","expired","error"].includes(outcome)
4Ignore duplicate messagesHandle the first PAYMENT_COMPLETE only
The checkout origin differs between environments

Staging payment links are served from https://p-staging.kody.com, live ones from https://p.kody.com (see Environments). Read this origin from your environment configuration rather than hard-coding one value, or the check will reject every legitimate message in the other environment. The origin is the scheme + host of the payment_url you were given, with no path.

Redirect or update your UI

With all four checks in place, branch on outcome and take the action that suits your flow:

// Read this from config: "https://p-staging.kody.com" in staging, "https://p.kody.com" in live.
const KODY_CHECKOUT_ORIGIN = "https://p.kody.com";
let handled = false;

window.addEventListener("message", (event) => {
// 1. Trust only the Kody checkout origin.
if (event.origin !== KODY_CHECKOUT_ORIGIN) return;

const data = event.data;
if (!data || data.type !== "PAYMENT_COMPLETE") return;

// 2. Trust only your own iframe, not any other frame on the page.
const iframe = document.getElementById("kody-iframe");
if (iframe && event.source !== iframe.contentWindow) return;

// 3. Ignore duplicate messages.
if (handled) return;
handled = true;

// 4. Handle the payment outcome.
if (data.outcome === "success") {
// Redirect to your success page.
// Confirm with PaymentDetails server side before fulfilling the order.
} else if (data.outcome === "expired") {
// Redirect to your expired page, or offer a new payment link.
} else if (data.outcome === "error") {
// Redirect to your error page, or let the customer retry.
} else {
// Unknown outcome — fail safe and treat it as an error.
}
});
outcome: "success" is a UI signal, not proof of payment

Use it to update the screen — never to release goods, send a receipt, or mark an order paid. Confirm server side with PaymentDetails (or PaymentDetailsStream) using your payment_reference before fulfilling the order.


Examples

Choose the code snippet that best fits your implementation. We've provided ready-to-use examples to suit different frontend setups.

Replace YOUR_PAYMENT_URL — never ship a hard-coded src

The src in these snippets is a placeholder. A payment link is single-use, per-order, and time-limited, so it cannot be written into your page's markup.

For every order, your server must call Initiate Payment, take the returned payment_url, append isEmbeddedInIframe=1, and pass that value into the page at render time — via your template engine, or as a prop / state in React and Vue.

Append it with ? if the payment_url has no query string, or & if it already has one (for example …?storeId=6c1b0a47-…). The snippets below derive the separator from the URL so both forms work.

A hard-coded src means every customer is sent to the same expired link, so the checkout will show an error or an expired page instead of taking the payment.

demo.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KodyPay Online Payment Checkout Component Demo</title>
<style>
iframe {
border: 0;
display: block;
margin: 0 auto;
max-width: 768px;
}
.payment-status-hidden {
display: none;
}
.payment-status-expired {
display: block;
color: blue;
}
.payment-status-success {
display: block;
color: green;
}
.payment-status-error {
display: block;
color: red;
}
</style>
<script type="text/javascript">
// Script to handle payment completion
window.addEventListener("DOMContentLoaded", () => {
window.addEventListener("message", (event) => {
// Check if message is payment complete
if (event.data && event.data.type === "PAYMENT_COMPLETE") {
// Find and hide the iframe
const iframe = document.getElementById("kody-iframe");
iframe.style.display = "none";

const h2 = document.getElementById("payment-status");
h2.textContent = `Payment ${event.data.outcome}`;

// Add class based on outcome
h2.classList.remove("payment-status-hidden");
h2.classList.add(`payment-status-${event.data.outcome}`);
}
});
});
</script>
</head>
<body>
<h1>Demo</h1>
<!--
Build this src per order from the payment_url returned by InitiatePayment.
Use "?" when the payment_url has no query string:
https://p.kody.com/P._pay.2HgE6Jj?isEmbeddedInIframe=1
Use "&" when it already has one (e.g. a storeId parameter):
https://p-staging.kody.com/P._pay.2HgE6Jj?storeId=6c1b0a47-...&isEmbeddedInIframe=1
The script below picks the right separator, so either form works.
-->
<iframe id="kody-iframe" width="80%" height="640px"></iframe>
<script type="text/javascript">
// Injected per order by your server — never hard-coded.
const paymentUrl = "YOUR_PAYMENT_URL";
document.getElementById("kody-iframe").src =
paymentUrl + (paymentUrl.includes("?") ? "&" : "?") + "isEmbeddedInIframe=1";
</script>
<h2 id="payment-status" class="payment-status-hidden"></h2>
</body>
</html>
Before going live, harden the listener

These snippets show the minimal wiring. Add the four checks from Step 3 to the message listener — validate event.origin, validate event.source, validate outcome, and ignore duplicate messages — otherwise any script or frame on the page can fake a successful payment.


Domain allowlisting and CSP

Embedding works only when both sides of the relationship agree:

DirectionWho sets itWhat it controls
frame-ancestors on the checkout pageKodyWhich parent sites may embed the checkout.
frame-src on your pageYouWhich sources your page may load in a frame.

If either side blocks the other, the browser cancels the load and the iframe stays blank.

1. Ask Kody to allowlist your domains

Send an email to integrations@kody.com including:

  • Every origin that will embed the component — full scheme, host, and port, e.g. https://shop.example.com, https://checkout.example.com:8443
  • Your staging, preview, and local origins as well (e.g. https://staging.example.com, http://localhost:3000)
  • Your store ID, and which environment each origin is for (staging / live)

Allowlisting is per environment: an origin approved for staging is not automatically approved for live. Request both at the same time so going live is not blocked by a second round trip.

Wildcards are avoided for security reasons, so list subdomains explicitly. Allow time for this before your go-live date.

2. Update your own CSP

If your site sends a Content-Security-Policy header, permit the checkout origin:

# Live
Content-Security-Policy: frame-src https://p.kody.com; child-src https://p.kody.com;

# Staging
Content-Security-Policy: frame-src https://p-staging.kody.com; child-src https://p-staging.kody.com;

child-src is only needed for older browsers. A restrictive default-src also applies to frames, so add frame-src explicitly whenever default-src is set. If one build serves both environments, list both origins.

3. Keep the payment permission delegated

If your page sends a Permissions-Policy header, it must not revoke payment, otherwise the allow="payment" attribute has nothing to delegate:

Permissions-Policy: payment=(self "https://p.kody.com")

Use https://p-staging.kody.com in staging, or list both.


Troubleshooting

SymptomLikely causeFix
Iframe is blank; console shows a frame-ancestors / “refused to connect” errorYour domain is not allowlistedEmail integrations@kody.com with your exact origins
Iframe is blank; console shows a frame-src violationYour own CSP blocks the checkout originAdd frame-src for that environment's origin (p.kody.com live, p-staging.kody.com staging)
PAYMENT_COMPLETE never arrivesisEmbeddedInIframe=1 is missing, or was appended with ? onto a URL that already had a query stringAppend it with ? when the payment_url has no query string and & when it does
Checkout loads once, then shows an error on retryThe payment_url was reused or has expiredCall InitiatePayment again for a new link
PAYMENT_COMPLETE is posted but your handler ignores itThe origin check still points at the other environment's checkout originRead KODY_CHECKOUT_ORIGIN from config — p-staging.kody.com in staging, p.kody.com in live
The API rejects a request that works in stagingA staging key or host is still configuredSwitch the host and the key together — see Environments
outcome: "expired" sooner than expectedexpiring_seconds default of 1800s elapsedRaise expiring_seconds, or set show_timer so the customer sees the countdown
Content is cut off or scrolls inside the frameHeight too small — the component cannot resize the host frameSet the iframe height to 640px or more

Integration checklist

Access

  • Your embedding origins are allowlisted by Kody (staging and live)
  • Your CSP allows frame-src for that environment's checkout origin

Server

  • Host and API key match the environment you are testing
  • InitiatePayment runs on your backend, with all six required fields
  • A fresh payment_url per order — never hard-coded or reused

Client

  • isEmbeddedInIframe=1 appended with ? or & as appropriate
  • The <iframe> has allow="payment" and an explicit height

Outcome

  • The listener applies all four checks from Step 3
  • All three outcomes lead somewhere in your UI, not just success
  • The order is only marked paid after a server-side PaymentDetails check

Before go-live

  • Tested end to end in staging with a link you generated, on mobile and desktop
  • Switched host, API key, and trusted checkout origin from staging to live — all three