payment
Toss, Stripe, and PayPal single payments are processed with a common payment function.
Full payment flow
- Before payment, order number, amount, currency, payment company, and pending status are first saved in the server DB.
- In payment.request, the order amount stored in the DB is passed, not the amount sent by the client.
- After user authentication is completed, the server calls payment.confirm, records the paid result in the DB, and provides the product.
- The browser success screen is not evidence of payment completion. Only server confirmation results or verified webhooks are trusted.
- Since webhooks can be missed or retransmitted, we store the event ID and ensure that the results are the same even if we process the same event multiple times.
- Refunds use the provider ID of the original payment record, and the successful refund results are also recorded in a separate history.
Create order (PENDING)
→ payment.request
→ User authentication on payment provider
→ payment.confirm
→ Record payment result in DB (PAID)
→ Fulfill product
Payment provider webhook
→ payment.verifyWebhook
→ Check for duplicate events
→ Reconcile payment status in DB
Refund request
→ payment.refund
→ Record refund result in DBPayment history model example
- This model is a recommended example that you can copy and modify and will not be automatically created or managed by Cake20.
- A Payment row represents a single payment attempt, not an order. If you pay for the same order again, a new row will be created.
- Provider stores toss, stripe or paypal. Even if you add another payment adapter, you can use it without changing the DB enum.
- providerId stores the Toss paymentKey, Stripe PaymentIntent ID, or PayPal Order ID.
- TransactionId stores the PayPal Capture ID, etc., which is different from the providerId and the actual transaction ID.
- requestKey is created before payment.request and stored in the DB, and the same value is passed when retrying the same request.
- Status stores ready, requires_action, pending, paid, canceled, refunded, partially_refunded, or failed.
- amount is an integer in the smallest unit of currency. When passing DB's BigInt to the payment function, change it to Number and check with Number.isSafeInteger.
- Create a ready line before requesting, and save the providerId and status of the request result, transactionId and final status of the confirm result, in that order.
- A relation with an existing order model or a unique combination of provider and providerId is added to suit the website structure. Do not combine multiple webhook events or refund history into one Payment row, but place them in separate models when necessary.
// server/db/payment.db.ts
export const Payment = {
id: z.id(),
orderId: z.string().max(200).index(),
provider: z.string().max(20),
providerId: z.string().max(200).nullable().index(),
transactionId: z.string().max(200).nullable(),
requestKey: z.string().max(38).nullable().unique(),
status: z.string().max(30).default("ready"),
amount: z.bigint().min(1),
currency: z.string().min(3).max(3),
createdAt: z.date().defaultNow().timestamp(),
updatedAt: z.raw("DateTime @updatedAt @db.Timestamp(0)")
};The website chooses how payments are recorded. Only fields required for existing ordering model Adding or extending this example will not make any difference to your use of the payment function.
Payment company settings
- Register at least one provider: toss, stripe, and paypal.
- If default is omitted, the first payment company is used, and if currency is omitted, KRW is used.
- mode is test or live; if omitted, test is safely used.
- The amount is a positive integer in smallest currency units. KRW 10,000 won is 10000, USD 12 is 1200.
// package.json
{
"payment": {
"providers": ["toss", "stripe", "paypal"],
"default": "toss",
"currency": "KRW",
"mode": "test"
}
}Payment authentication information
- Payment keys are stored in an encrypted Secret in website settings, not in package.json or server source.
- The clientKey and clientSecret from the payment.request result are passed only to the payment screen and are not logged.
- Editor API tests also call the actual payment company API, so only the test key and mode: test are used.
TOSS_CLIENT_KEY
TOSS_SECRET_KEY
STRIPE_PUBLISHABLE_KEY
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
PAYPAL_CLIENT_ID
PAYPAL_CLIENT_SECRET
PAYPAL_WEBHOOK_IDPayment preparation
- Toss returns clientKey and payment window request value.
- Stripe creates a PaymentIntent and returns a clientKey and clientSecret.
- PayPal creates the Order and returns a checkoutUrl for buyer approval.
- Order number and amount are determined by server DB values rather than relying on client input.
const ready = await payment.request({
provider: "stripe",
orderId: "order-20260724-1",
orderName: "strawberry cake",
amount: 1200,
currency: "USD",
returnUrl: "https://example.com/pay/success",
cancelUrl: "https://example.com/pay/fail",
idempotencyKey: "order-20260724-1"
});Approval and Verification
- Toss calls the payment authorization API.
- After Stripe.js authentication, Stripe checks the PaymentIntent again to verify the order number, amount, and currency.
- PayPal captures the Order approved by the buyer and returns the Capture ID as transactionId.
- Successful results are first recorded in the DB and then product offerings or follow-up actions are performed.
const result = await payment.confirm({
provider: "toss",
id: data.paymentKey,
orderId: order.id,
amount: order.amount,
idempotencyKey: `${order.id}-confirm`
});
if (result.status !== "paid") {
throw new Error("Payment has not been completed.");
}Full/partial refund
- If you omit the amount, you will receive a full refund; if you enter the value, you will receive a partial refund.
- Toss uses paymentKey, Stripe uses PaymentIntent ID as ID.
- PayPal uses the Capture ID, which is the transactionId of the confirm result, as the id.
- Save it with your order so that you use the same idempotencyKey when retrying the same operation.
await payment.refund({
provider: order.provider,
id: order.paymentId,
amount: 5000,
currency: order.currency,
reason: "customer request",
idempotencyKey: `${order.id}-refund-1`
});Webhook validation
- We need the original body without serializing it back to JSON.
- Stripe verifies with HMAC signature, while PayPal verifies with official verification API.
- The Toss general payment event looks up and compares the payment again by paymentKey or orderId.
- Webhooks can be retransmitted, making it safe to duplicate event IDs and state changes.
// server/api/payment/webhook.post.ts
export default async (request: Request) => {
const event = await payment.verifyWebhook({
provider: "stripe",
body: await request.text(),
headers: request.headers
});
// Prevents duplicate processing with event ID and updates DB status.
return { received: event.verified };
};