Package Exports
- @odematik/billing
- @odematik/billing/next
- @odematik/billing/package.json
- @odematik/billing/server
Readme
@odematik/billing
Drop-in React + Next.js checkout for ödematik. One server handler, one button — the PSP iframe (PayTR, iyzico, Stripe) is rendered for you, payment is verified server-side, fulfillment runs in your code.
- 🔒 No publishable key. Your API key never leaves your server.
- ⚛️ Works with React 18 and 19, ESM + CJS, full
.d.ts - 🌍 SSR-safe
- 🪝 Optional webhook handler auto-mounted (HMAC SHA-256)
- 🧱 Zero runtime dependencies beyond React
Install
npm install @odematik/billing
pnpm add @odematik/billingQuick start (Next.js — ~10 lines)
1. Get your API key. Sign in to your ödematik dashboard → Settings →
API → "Yeni anahtar". The key starts with wk_.
2. Mount the server handler — define your products + fulfillment in one file:
// app/api/odematik/[...path]/route.ts
import { createOdematikHandler } from '@odematik/billing/next';
export const { GET, POST } = createOdematikHandler({
products: {
p100: { amount: 500, currency: 'TRY', name: '100 Coin paketi', vat_rate: 20 },
plan_monthly: { amount: 129, currency: 'TRY', name: 'Aylık Üyelik', vat_rate: 20 },
},
onPaid: async ({ productId, customer }) => {
// Runs after the payment is verified server-side. Idempotent —
// may fire twice (once from the browser's /verify, once from the
// webhook). Use your own dedupe (e.g. unique payment_id).
await fulfillOrder(customer.id, productId);
},
});3. Drop in the button anywhere in your React app:
'use client';
import { OdematikButton } from '@odematik/billing';
<OdematikButton
productId="p100"
customer={{ id: user.id, email: user.email }}
onPaid={() => router.refresh()}
>
100 Coin al — 500₺
</OdematikButton>4. Env vars — one required, two optional:
ODEMATIK_API_KEY=wk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# ODEMATIK_API_BASE=https://api.odematik.com # default; only set for self-hosted / staging
# ODEMATIK_WEBHOOK_SECRET=whsec_… # set if you enable the webhook (see below)That's it. The button opens a modal, the modal embeds the PSP iframe the
backend returns, the buyer pays, and your onPaid runs server-side.
Amount and currency are taken from the server-side products map —
the browser only sends productId, so prices can't be tampered with.
Production checklist
Authenticate /checkout
Pass an authenticate callback so anonymous callers can't create
sessions for arbitrary customers:
createOdematikHandler({
authenticate: async (req) => {
const user = await getUserFromRequest(req);
if (!user) return null; // null → 401
return { customerId: user.id };
},
products: { ... },
onPaid: async ({ customer }) => { ... },
});Returning { customerId } is required if you want the checkout
modal's preview screen to show the buyer's saved fatura bilgileri
with a "Değiştir" link. The GET /billing endpoint refuses to look
up records by browser-supplied id — it relies on the authenticated
session to know who's asking. Returning anything else still passes
/checkout and /verify auth (backwards compatible) but the saved-
billing preview falls back to the empty-form flow.
The buyer still has to enter a real card to actually charge, so the worst case of skipping auth is resource exhaustion / fake payment links — but you should still gate this.
Enable webhooks
For belt-and-suspenders fulfillment (the /verify path can be skipped
by a buyer who closes the tab), turn on webhooks:
- In your ödematik dashboard: Webhook Endpoints → Add
- URL:
https://your-app.com/api/odematik/webhook - Events:
payment.success(and others if you care) - Copy the secret
- URL:
- Set
ODEMATIK_WEBHOOK_SECRET=...in your.env - The bundled handler already accepts webhook POSTs at
/api/odematik/webhook— no extra code needed. It verifies the HMAC SHA-256 signature timing-safely and dispatchespayment.successto youronPaid.
First-purchase invoicing
ödematik refuses /charge without a billing block on the first
purchase from a given customer_id (Turkish invoice law). Collect
unvan / VKN / address / etc. up-front and forward via the button:
<OdematikButton
productId="p100"
customer={{
id: user.id,
email: user.email,
billing: {
unvan: 'ACME Yazılım Ltd. Şti.',
vkn_tckn: '1234567890',
adres: 'Test Cad. No: 1',
il: 'İstanbul',
ilce: 'Kadıköy',
},
}}
>
Satın al
</OdematikButton>Once a customer has a billing record stored, subsequent purchases don't need to repeat it. The checkout modal's preview screen will show a "Faturalandırma: {unvan} · VKN ••••1234 — Değiştir" row; tapping Değiştir opens the form prefilled with the saved values so the buyer can correct typos or use different fatura bilgisi for a one-off purchase. Submitting the override updates the stored record AND uses the new values on this invoice (always-update policy — there is no per-transaction override that leaves the saved record untouched).
Backend requirement. This preview row is populated by
GET /api/odematik/billing, which proxies GET /customers/:id/billing
on the ödematik API. The backend must:
- Implement
GET /customers/:id/billing→{ billing: BillingInfo | null }(authorized byX-Api-Key; merchant only sees own customers). - On every
POST /charge(and/subscribe) with abillingblock, update the customer's saved billing record — don't treat it as per-transaction-only.
Lower-level API
If you don't use Next.js or need something custom, use the server client directly:
import { OdematikClient, verifyWebhookSignature } from '@odematik/billing/server';
const client = new OdematikClient({
apiKey: process.env.ODEMATIK_API_KEY!,
apiBase: process.env.ODEMATIK_API_BASE!,
});
const checkout = await client.charge.create({
customer_id: 'user_42',
email: 'buyer@example.com',
items: [{ name: '100 Coin', quantity: 1, unit_price: 500, vat_rate: 20 }],
});
// checkout.iframeUrl → embed in browser
// checkout.payment_id → use to verify later
const payment = await client.payments.get(checkout.payment_id);
if (payment.status === 'success') { /* fulfill */ }verifyWebhookSignature({ body, signature, timestamp, secret }) returns
a boolean and can be used inside Express / Fastify / any other server.
Pass the raw request body string (not the parsed JSON) and the
headers X-Webhook-Signature + X-Webhook-Timestamp.
How payment flows
buyer clicks <OdematikButton/>
│
▼
your handler ── POST /charge ──▶ ödematik backend
◀── { iframeUrl, payment_id } ──
│
▼
modal embeds iframeUrl → PSP collects card directly
│
│ webhook to ödematik
▼
ödematik backend updates payment
│
│ webhook to your handler
▼
your handler verifies HMAC, runs onPaid
│
buyer sees success (in parallel) iframe posts "success"
│ │
│ ◀── browser sends /verify ──┤
▼
your /verify runs onPaid (idempotent)Card data never touches your server or ödematik's. The PSP collects it
directly; ödematik only sees post-success metadata (last4, brand,
payment_id).
Browser support
Modern evergreen browsers (Chrome, Edge, Firefox, Safari ≥ 14).
License
MIT © ödematik