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 }));
// Resolve the price from the database, not from an amount supplied by the browser.
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 }),
});
// After a duplicate confirmation or lost response, recheck the payment provider using the order ID.
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 {
// The confirmation result is uncertain. Do not mark it failed or create another order or payment.
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.
// Add the following element and SDK to the checkout page first.
// <div id="payment-method"></div><div id="agreement"></div>
// <button id="pay" disabled>Pay</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('Check the login session and order information.');
const order = await response.json();
// Use only the client key in the browser. Never include a secret key.
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', 'Review the payment request.');
button.disabled = false;
}
});// This is a separate browser module for /payments/success. Add a #result element first.
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); // remove sensitive query values before the user follows another link
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
? 'Payment confirmed.' : 'The payment status is still being checked. Review it again in your order history.';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.