Integration boundary
The VietBilling API key is a server secret. A Next.js browser should call your NestJS backend, which maps users to Customers, creates Checkouts, and receives signed webhooks.
Next.js browser → NestJS API → VietBilling Public API → PayOS
↑ ↓
└─ verified signed webhook
Configure server-only values
VIETBILLING_API_URL=https://api.vietbilling.com/public/v2
VIETBILLING_API_KEY=vb_live_...
VIETBILLING_WEBHOOK_SECRET=...
VIETBILLING_PRODUCT_ID=...
Never use a NEXT_PUBLIC_ prefix. Store production values in the hosting platform's secret manager and rotate a key if it appears in logs or a client bundle.
Build a small NestJS client
@Injectable()
export class VietBillingClient {
async createCheckout(input: {
userId: string
email: string
successUrl: string
cancelUrl: string
}) {
const response = await fetch(
`${process.env.VIETBILLING_API_URL}/checkouts`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VIETBILLING_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `checkout_${input.userId}_${currentOfferVersion()}`,
},
body: JSON.stringify({
external_customer_id: input.userId,
customer_email: input.email,
product_id: process.env.VIETBILLING_PRODUCT_ID,
success_url: input.successUrl,
cancel_url: input.cancelUrl,
}),
}
)
if (!response.ok)
throw new ServiceUnavailableException("Billing unavailable")
return response.json()
}
}
Read user identity from authenticated server context. Never let the browser freely choose external_customer_id or a Product outside the backend allowlist.
Create and display Checkout
A NestJS controller calls the client and returns only the Checkout ID and URL needed for redirect or polling. If an HTTP attempt times out, reuse the same idempotency key.
The Next.js result page must not grant access from success_url. Show a confirming state and ask your backend for Checkout status. The backend must verify ownership before returning it.
export default async function BillingResult({ searchParams }) {
const { checkout_id: checkoutId } = await searchParams
const status = await getCheckoutForCurrentUser(checkoutId)
return <BillingStatus status={status} />
}
Apply signed webhook state
Preserve the raw body, verify the signature, deduplicate event_id, and update Subscription mapping plus entitlement in one transaction. Return 2xx for an event already processed.
@Post("webhooks/vietbilling")
async receive(@RawBody() body: Buffer, @Headers("x-signature") signature: string) {
const event = this.webhooks.verify(body, signature)
await this.webhooks.processOnce(event)
return { received: true }
}
End-to-end verification
Create a test user and Checkout, confirm the browser never sees the API key, complete a small PayOS payment, verify one Subscription mapping, replay the event, then test reconciliation after a temporarily unavailable webhook endpoint.
See request shapes in VietBilling Docs and verify provider behavior against PayOS Docs.