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:
- Your server creates a payment and receives a unique
payment_url. - Your page renders that URL in an iframe with
isEmbeddedInIframe=1. - The component reports the result to your page with a
postMessageevent. - 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.
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
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.
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-Pacific | grpc-staging-ap.kodypay.com | grpc-ap.kodypay.com |
| gRPC API host — Europe | grpc-staging-eu.kodypay.com | grpc-eu.kodypay.com |
API key (X-API-Key) | Your staging key | Your live key |
Checkout origin (payment_url host) | https://p-staging.kody.com | https://p.kody.com |
| Cards | Test cards — no real money moves | Real cards, real money |
Use the regional host that matches your store. See the Payments API overview for the full list.
- The API host and key your server calls — a staging key against a live host is rejected.
- The checkout origin your
messagelistener trusts — see Step 3. Hard-codinghttps://p.kody.commakes the origin check reject every legitimate message in staging, and vice versa. - Your CSP —
frame-srcmust 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.
Step 1 — Create the payment link (server side)
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 to1800. When it lapses the component reportsoutcome: "expired". Setexpiry.show_timerto show the customer a countdown.
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>
? or & depending on the payment_url you were givenisEmbeddedInIframe=1 is a query parameter, so the separator depends on whether the
payment_url already has a query string:
payment_url returned by InitiatePayment | Separator | Resulting 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).
| Requirement | Why it matters |
|---|---|
isEmbeddedInIframe=1 must be appended to the payment_url, with ? or & as appropriate | Switches 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 height | The 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 attribute | Screen-reader accessibility. |
sandbox attributeThe 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" }
| Field | Type | Values |
|---|---|---|
type | string | Always "PAYMENT_COMPLETE". |
outcome | string | "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.
| # | Check | Code |
|---|---|---|
| 1 | Verify the sender's origin | if (event.origin !== "https://p.kody.com") return; |
| 2 | Verify the sender is your iframe | if (event.source !== iframe.contentWindow) return; |
| 3 | Validate outcome against a known list | ["success","expired","error"].includes(outcome) |
| 4 | Ignore duplicate messages | Handle the first PAYMENT_COMPLETE only |
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 paymentUse 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.
YOUR_PAYMENT_URL — never ship a hard-coded srcThe 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.
- JavaScript
- React
- Vue3
<!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>
import { useState, useEffect, useCallback } from "react";
/**
<style>
iframe {
border: 0;
display: block;
margin: 0 auto;
max-width: 768px;
}
.payment-status-expired {
color: blue;
}
.payment-status-success {
color: green;
}
.payment-status-error {
color: red;
}
</style>
**/
// Pass the payment URL in as a prop — one freshly generated URL per order.
const Demo = ({ paymentUrl }) => {
// "&" if the payment_url already has a query string (e.g. ?storeId=...), otherwise "?".
const src = `${paymentUrl}${paymentUrl.includes("?") ? "&" : "?"}isEmbeddedInIframe=1`;
// Save payment status
const [paymentStatus, setPaymentStatus] = useState();
const [iframeVisible, setIframeVisible] = useState(true);
const handleMessage = useCallback((event) => {
// Check if message is payment complete
if (event.data && event.data.type === "PAYMENT_COMPLETE") {
setIframeVisible(false);
setPaymentStatus(event.data.outcome);
}
}, []);
useEffect(() => {
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, []);
return (
<>
<h1>Demo</h1>
{iframeVisible && (
<iframe
width="80%"
height="640px"
src={src}
></iframe>
)}
{paymentStatus && (
<h2 className={`payment-status-${paymentStatus}`}>
Payment {paymentStatus}
</h2>
)}
</>
);
};
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
// Pass the payment URL in as a prop — one freshly generated URL per order.
const props = defineProps({ paymentUrl: { type: String, required: true } });
// "&" if the payment_url already has a query string (e.g. ?storeId=...), otherwise "?".
const src = computed(
() =>
`${props.paymentUrl}${
props.paymentUrl.includes("?") ? "&" : "?"
}isEmbeddedInIframe=1`
);
// Save payment status
const paymentStatus = ref();
const iframeVisible = ref(true);
const handleMessage = (event) => {
// Check if message is payment complete
if (event.data && event.data.type === "PAYMENT_COMPLETE") {
iframeVisible.value = false;
paymentStatus.value = event.data.outcome;
}
};
onMounted(() => {
window.addEventListener("message", handleMessage);
});
onBeforeUnmount(() => {
window.removeEventListener("message", handleMessage);
});
</script>
<template>
<div>
<h1>Demo</h1>
<iframe
v-if="iframeVisible"
width="80%"
height="640px"
:src="src"
></iframe>
<h2 v-if="paymentStatus" :class="`payment-status-${paymentStatus}`">
Payment {{ paymentStatus }}
</h2>
</div>
</template>
<style scoped>
iframe {
border: 0;
display: block;
margin: 0 auto;
max-width: 768px;
}
.payment-status-expired {
color: blue;
}
.payment-status-success {
color: green;
}
.payment-status-error {
color: red;
}
</style>
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:
| Direction | Who sets it | What it controls |
|---|---|---|
frame-ancestors on the checkout page | Kody | Which parent sites may embed the checkout. |
frame-src on your page | You | Which 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
| Symptom | Likely cause | Fix |
|---|---|---|
Iframe is blank; console shows a frame-ancestors / “refused to connect” error | Your domain is not allowlisted | Email integrations@kody.com with your exact origins |
Iframe is blank; console shows a frame-src violation | Your own CSP blocks the checkout origin | Add frame-src for that environment's origin (p.kody.com live, p-staging.kody.com staging) |
PAYMENT_COMPLETE never arrives | isEmbeddedInIframe=1 is missing, or was appended with ? onto a URL that already had a query string | Append it with ? when the payment_url has no query string and & when it does |
| Checkout loads once, then shows an error on retry | The payment_url was reused or has expired | Call InitiatePayment again for a new link |
PAYMENT_COMPLETE is posted but your handler ignores it | The origin check still points at the other environment's checkout origin | Read KODY_CHECKOUT_ORIGIN from config — p-staging.kody.com in staging, p.kody.com in live |
| The API rejects a request that works in staging | A staging key or host is still configured | Switch the host and the key together — see Environments |
outcome: "expired" sooner than expected | expiring_seconds default of 1800s elapsed | Raise expiring_seconds, or set show_timer so the customer sees the countdown |
| Content is cut off or scrolls inside the frame | Height too small — the component cannot resize the host frame | Set the iframe height to 640px or more |
Integration checklist
Access
- Your embedding origins are allowlisted by Kody (staging and live)
- Your CSP allows
frame-srcfor that environment's checkout origin
Server
- Host and API key match the environment you are testing
-
InitiatePaymentruns on your backend, with all six required fields - A fresh
payment_urlper order — never hard-coded or reused
Client
-
isEmbeddedInIframe=1appended with?or&as appropriate - The
<iframe>hasallow="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
PaymentDetailscheck
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