Package Exports
- @odematik/billing
- @odematik/billing/package.json
Readme
@odematik/billing
Drop-in React component that renders a secure checkout UI on top of your configured payment provider (iyzico, PayTR, Param, …).
The merchant configures their existing PSP credentials in the ödematik dashboard. ödematik then orchestrates the flow — but card data never touches ödematik servers and never touches your merchant's servers. It flows directly from the buyer's browser to the PSP's hosted iframe.
- 🔒 Zero card data exposure for ödematik and the merchant
- 🧱 Zero runtime dependencies (only React peer)
- ⚛️ Works with React 18 and 19, ESM + CJS, full
.d.ts - 🪟 Strict postMessage origin validation
- 🌍 SSR-safe (no DOM access at module load)
Architecture
Merchant backend ödematik API
│ POST /v1/checkout_sessions (Authorization: Bearer sk_…)
│ ───────────────────────────────────────────────────────▶
│ ◀──────────── { client_secret: "cs_…" } ────────────────
│
│ passes client_secret to the frontend
▼
Browser ──── <OdematikCheckout clientSecret="cs_…"/> ──┐
│
ödematik API: GET /v1/checkout_sessions/lookup ◀─┘
returns provider iframe URL
│
│ iframe loads from PSP (iyzico / PayTR / Param)
▼
PSP's hosted form ── card data ──▶ PSP servers
│
│ postMessage("odematik-checkout", { type: "success" }) to parent
▼
SDK fires onSuccess(payment)The merchant uses the SDK with a single-use clientSecret. No
publishable keys, no card data. The SDK fetches the session, embeds the
PSP's iframe, and forwards the result via callbacks.
Install
npm install @odematik/billing react react-dompnpm add @odematik/billing react react-domRequires
react@>=18. Zero runtime dependencies beyond React.
Quick start
1. Server: create a checkout session
// /api/checkout/route.ts — runs on your server, using your secret key
const res = await fetch(`${process.env.ODEMATIK_API_BASE}/v1/checkout_sessions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ODEMATIK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: 125000, // minor units (1.250,00 ₺)
currency: 'TRY',
merchant_reference: 'ORDER-42', // your internal order id
return_url: 'https://shop.example.com/checkout/done',
}),
});
const { client_secret } = await res.json();
return Response.json({ clientSecret: client_secret });2. Client: render <OdematikCheckout>
'use client';
import { OdematikCheckout, type PaymentResult } from '@odematik/billing';
export function Checkout({ clientSecret }: { clientSecret: string }) {
return (
<OdematikCheckout
clientSecret={clientSecret}
apiBase={process.env.NEXT_PUBLIC_ODEMATIK_API_BASE!} // required, no default
onSuccess={(p: PaymentResult) => {
window.location.href = `/order/${p.paymentId}`;
}}
onError={(err) => console.error(err.code, err.message)}
onCancel={() => console.log('user closed')}
onExpired={() => location.reload()}
/>
);
}
apiBaseis required as of 0.3.0 — there is no default. Always pass the URL ödematik gave you (e.g.https://api.your-odematik-host.example). This prevents the SDK from silently sending lookups to a hostname you don't control.
That's it. The SDK never sees the card; your backend never sees the card; ödematik never sees the card.
Props
| Prop | Type | Notes |
|---|---|---|
clientSecret |
string |
Required. The cs_… returned by POST /v1/checkout_sessions. The SDK throws on mount if a sk_… secret is passed by mistake. |
apiBase |
string |
Required. Base URL of your ödematik API. Must be HTTPS in production (localhost allowed for dev). |
showCardPreview |
boolean |
Show the animated 3D card above the form (iframe mode only). Default true. |
onSuccess |
(payment) => void |
Fires once the PSP confirms a successful payment. Iframe mode only — in redirect mode the user lands on your successUrl instead. |
onError |
(err: OdematikBillingError) => void |
Network / lookup / PSP errors. |
onCancel |
() => void |
User dismissed the PSP form. |
onExpired |
() => void |
Session 404'd or expiresAt passed. |
className / style |
— | Outer wrapper. |
Embed modes
The session response from /v1/checkout_sessions/lookup includes an
embed object that tells the SDK how to render:
embed: {
type: 'iframe' | 'redirect',
url: string, // PSP-hosted page or iframe src
height?: number, // initial iframe height (overridden later via postMessage)
allowedOrigins: string[], // origins the SDK will accept postMessage from
}type: 'iframe'— the SDK embeds the URL in a sandboxed iframe and waits for the PSP topostMessagethe result. Works for PSPs that support a postMessage protocol (or for custom ödematik-hosted forms).type: 'redirect'— the SDK renders a styled CTA button. Click performswindow.location.href = url. The PSP's hosted page processes the payment, then redirects back to the merchant's configuredsuccessUrl/failureUrl. Use this for iyzico Checkout Form, PayTR iframe API, Param POS, and any other PSP whose result is delivered via HTTP redirect rather than postMessage.
onSuccess does not fire in redirect mode — the user has left the page
by then. Show success on your successUrl route instead.
Security model
- No publishable key, no card data crosses our SDK. The SDK fetches
a single-use session via
clientSecret, then either embeds the PSP's hosted form in a sandboxed iframe or redirects to it. The card form lives on the PSP's domain (e.g.*.iyzipay.com). - Strict origin validation. Inbound
postMessageevents are dropped unless the sender's origin appears inembed.allowedOrigins. - Origin handshake. On iframe load the SDK posts the parent's origin
to the iframe so the iframe can
targetOrigin-target every reply instead of broadcasting with'*'. - PAN mask defense-in-depth. Even if a compromised PSP iframe sent
an unmasked PAN in
numberDisplay, the SDK refuses to render any string containing more than 10 digits (BIN + last4 only). - HTTPS enforced for both
apiBaseand the embedurl. - Secret-key trap. If you accidentally pass
sk_…instead ofcs_…, the SDK throws on mount before contacting the network. credentials: 'omit',cache: 'no-store',referrerPolicy: 'no-referrer'on the lookup fetch.- Iframe sandbox is
allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox— enough for 3DS pop-ups, no parent-window hijacking. - No card data is ever logged, persisted, or exposed in callbacks.
Only post-success metadata (
last4,brand,paymentId) is observable. - Zero runtime dependencies — minimal supply-chain surface.
What ödematik does and doesn't do
| ödematik | merchant | PSP (iyzico/…) | |
|---|---|---|---|
| Has merchant's PSP credentials | ✅ encrypted at rest | ✅ owns them | ✅ |
| Sees card PAN / CVC | ❌ never | ❌ never | ✅ |
| Holds funds | ❌ | depends on PSP | ✅ |
| Issues invoices, manages subscriptions | ✅ | — | ❌ |
| Brand-routes / falls over to backup PSP | ✅ | — | ❌ |
| Needs PCI-DSS L1 | ❌ (SAQ A scope) | — | ✅ |
| Needs BDDK ödeme kuruluşu lisansı | ❌ (no fund custody) | — | ✅ |
Browser support
Modern evergreen browsers (Chrome, Edge, Firefox, Safari ≥ 14).
Fonts
The package does not load any fonts itself (perf + privacy). Add
this to your <head> for the exact look:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"
rel="stylesheet"
/>Otherwise the component falls back to the system font stack.
Development
cd packages/billing
npm install
npm run typecheck
npm run buildLicense
MIT © ödematik