Docs / Payment gateway integration
Payment gateway integration
Implement server-side orders, payment confirmation, reconciliation, webhooks and recurring billing.
Start with the right payment type
This guide covers payment integration inside customer projects. Separate Korean one-time payments, recurring billing and global SaaS sales, and use the supported onboarding routes.
- Toss Payments · one-time payments
- Toss Payments · recurring billing
- Dodo Payments · global SaaS payments
The examples assume a verified app session and test merchant keys. Publishing this documentation does not create a payment-gateway contract or checkout UI.
Fix the amount on the server
Read the price from the product database and store the order ID, buyer, amount and status before checkout. Never charge the amount supplied by the browser.
CREATE TABLE payment_orders (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
product_id INTEGER NOT NULL REFERENCES products(id),
amount INTEGER NOT NULL CHECK(amount > 0),
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','paid','cancelled')),
created_at INTEGER NOT NULL
);
CREATE INDEX idx_payment_orders_user ON payment_orders(user_id,created_at,id);
CREATE INDEX idx_payment_orders_pending ON payment_orders(status,created_at,id);Recheck ownership and amount during confirmation. Do not store the raw paymentKey or the full gateway response. Preserve the order’s agreed amount even if the product price later changes.
Confirm payments only on the server
Register TOSS_SECRET_KEY as a project Secret and read it only on the server. The example covers order creation, amount checks, duplicate confirmation and reconciliation after a lost response.
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { requireSession } from '../middleware/session';
import type { AppEnv } from '../types';
type PaymentEnv = AppEnv & {
Bindings: AppEnv['Bindings'] & { TOSS_SECRET_KEY: string };
};
type Order = { id: string; user_id: string; amount: number; status: string };
export const payments = new Hono<PaymentEnv>();
payments.use('*', requireSession);
payments.use('*', bodyLimit({ maxSize: 4096 }));
// 가격은 브라우저가 보낸 금액이 아니라 DB에서 확정합니다.
payments.post('/orders', async (c) => {
const input = await c.req.json().catch(() => null);
if (!input || !Number.isSafeInteger(input.productId))
return c.json({ error: 'invalid_product' }, 400);
const product = await c.env.DB.prepare(
'SELECT id,name,price FROM products WHERE id=? AND published=1'
).bind(input.productId).first<{ id: number; name: string; price: number }>();
if (!product) return c.json({ error: 'not_found' }, 404);
const orderId = crypto.randomUUID();
await c.env.DB.prepare(
'INSERT INTO payment_orders(id,user_id,product_id,amount,created_at) VALUES(?,?,?,?,?)'
).bind(orderId, c.get('identity').user_id, product.id, product.price, Date.now()).run();
return c.json({ orderId, orderName: product.name, amount: product.price });
});
payments.post('/confirm', async (c) => {
const input = await c.req.json().catch(() => null);
if (!input || typeof input.orderId !== 'string' || input.orderId.length > 64 ||
typeof input.paymentKey !== 'string' || input.paymentKey.length > 200 ||
!input.paymentKey || !Number.isSafeInteger(input.amount))
return c.json({ error: 'invalid_payment' }, 400);
const order = await c.env.DB.prepare(
'SELECT id,user_id,amount,status FROM payment_orders WHERE id=? AND user_id=?'
).bind(input.orderId, c.get('identity').user_id).first<Order>();
if (!order) return c.json({ error: 'not_found' }, 404);
if (input.amount !== order.amount) return c.json({ error: 'amount_mismatch' }, 400);
if (order.status === 'paid') return c.json({ ok: true });
if (order.status !== 'pending') return c.json({ error: 'order_conflict' }, 409);
const headers = { Authorization: 'Basic ' + btoa(c.env.TOSS_SECRET_KEY + ':') };
try {
let result = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': order.id },
body: JSON.stringify({ orderId: order.id, paymentKey: input.paymentKey, amount: order.amount }),
});
// 중복 승인·응답 손실 뒤에는 주문 번호로 PG의 실제 상태를 재확인합니다.
if (!result.ok) result = await fetch(
'https://api.tosspayments.com/v1/payments/orders/' + encodeURIComponent(order.id), { headers });
if (!result.ok) return c.json({ error: 'payment_verification_pending' }, 503);
const payment = await result.json() as { orderId: string; totalAmount: number; currency: string; status: string };
if (payment.orderId !== order.id || payment.totalAmount !== order.amount || payment.currency !== 'KRW')
return c.json({ error: 'payment_verification_failed' }, 409);
if (payment.status !== 'DONE') return c.json({ status: 'pending' }, 202);
await c.env.DB.prepare("UPDATE payment_orders SET status='paid' WHERE id=? AND status='pending'")
.bind(order.id).run();
const saved = await c.env.DB.prepare('SELECT status FROM payment_orders WHERE id=?')
.bind(order.id).first<{ status: string }>();
if (saved?.status !== 'paid') return c.json({ error: 'order_conflict' }, 409);
return c.json({ ok: true });
} catch {
// 승인 여부가 불명확합니다. Failed로 확정하거나 주문/Billing를 새로 만들지 않습니다.
return c.json({ error: 'payment_verification_pending' }, 503);
}
});import { payments } from './routes/payments';
app.route('/api/payments', payments);Keep the same order ID for retries. Treat a payment as complete only after the server verifies the gateway state and persists the matching order state.
Browser checkout and result screens
The browser module renders the payment UI, while order creation requires an authenticated app session. Do not confuse the public client key with the server Secret.
// 주문서 페이지에 아래 요소와 SDK를 먼저 추가합니다.
// <div id="payment-method"></div><div id="agreement"></div>
// <button id="pay" disabled>Billing하기</button>
// <script src="https://js.tosspayments.com/v2/standard"></script>
const response = await fetch('/api/payments/orders', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: 1 }),
});
if (!response.ok) throw new Error('로그인과 주문 정보를 확인하세요.');
const order = await response.json();
// 클라이언트 키만 브라우저에서 사용합니다. 시크릿 키를 넣으면 안 됩니다.
const toss = TossPayments('YOUR_TOSS_CLIENT_KEY');
const widgets = toss.widgets({ customerKey: TossPayments.ANONYMOUS });
await widgets.setAmount({ currency: 'KRW', value: order.amount });
await widgets.renderPaymentMethods({ selector: '#payment-method', variantKey: 'DEFAULT' });
await widgets.renderAgreement({ selector: '#agreement', variantKey: 'AGREEMENT' });
const button = document.querySelector('#pay');
button.disabled = false;
button.addEventListener('click', async () => {
button.disabled = true;
try {
await widgets.requestPayment({
orderId: order.orderId, orderName: order.orderName,
successUrl: location.origin + '/payments/success',
failUrl: location.origin + '/payments/fail',
});
} catch {
document.querySelector('#payment-method').insertAdjacentText('beforebegin', 'Billing 요청을 확인하세요.');
button.disabled = false;
}
});// /payments/success의 별도 브라우저 모듈입니다. #result 요소를 준비하세요.
const params = new URLSearchParams(location.search);
const payload = { orderId: params.get('orderId'), paymentKey: params.get('paymentKey'), amount: Number(params.get('amount')) };
history.replaceState(null, '', location.pathname); // 민감한 쿼리가 이후 링크에 남지 않도록 제거
const response = await fetch('/api/payments/confirm', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
const result = await response.json();
document.querySelector('#result').textContent = response.ok && result.ok
? 'Billing가 확인되었습니다.' : 'Billing 상태를 확인 중입니다. 주문 내역에서 다시 확인하세요.';Create separate checkout, success and failure routes. Show query values with textContent and never retry payment automatically. Serve result pages with noindex, no-store and Referrer-Policy: no-referrer so paymentKey query values do not leak into logs or analytics.
Webhooks and lost-response recovery
One-time confirmation does not cover deposits, cancellations or refunds. Authenticate each webhook according to the provider contract, then query the gateway from the server using the order ID. Do not trust webhook amount or completion fields by themselves.
- Store the webhook ID under a unique constraint.
- Verify order, currency, amount and latest status from the gateway.
- Write the allowed state transition and fulfillment outbox in one transaction.
- Return 2xx only after persistence; allow temporary failures to be retried.
Periodically reconcile a bounded set of pending orders. Handle cancellation and partial refunds with explicit balance and fulfillment-reversal rules.
Recurring and global payments
Recurring billing is not repeated use of the one-time confirmation API. Design consent, billing-key issuance, billing cycles, cancellation, retries and duplicate-charge protection. Encrypt billing keys and use a unique charge ID for every cycle.
For Dodo Payments, create checkout on the server, redirect the browser to the returned URL and update access only from a verified webhook. Follow the provider’s current event and signature documentation.
Verify in a test merchant
- Successful payment, user cancellation, expired login, amount tampering and cross-user access
- Duplicate confirmation clicks, lost responses and reconciliation after database failure
- Duplicate and out-of-order webhooks, pending bank transfers, cancellation and partial refunds
- Idempotent fulfillment, pending-order reconciliation and absence of secrets in logs
Local code tests do not replace an end-to-end payment through the provider’s test merchant. Verify the entire flow before applying production keys.