Selling a SaaS plan or a digital product from a Next.js app usually means two jobs: taking the payment, and then handling VAT, sales tax and invoices for buyers in other countries. This post sets up both in two route handlers with @agentaos/nextjs. AgentaOS is a merchant of record: it sells the product in its own name, charges the buyer's VAT or sales tax, issues the invoice and pays you out.
You need:
- a Next.js app with the App Router
- an AgentaOS account (test mode works from the first minute)
- a product created in the AgentaOS dashboard, and its link ID from the product page
1. Install and set the keys
npm install @agentaos/nextjs
In the dashboard, create an API key under Developers → API keys (sk_test_... in test mode). Under Settings → Developers → Webhooks, set the URL to https://your-domain.com/api/webhooks/agentaos and copy the signing secret.
# .env.local
AGENTAOS_API_KEY=sk_test_...
AGENTAOS_WEBHOOK_SECRET=whsec_...
2. The checkout route
// app/checkout/route.ts
import { Checkout } from '@agentaos/nextjs';
export const GET = Checkout({
products: { pro: 'your-pro-link-id' },
successUrl: 'https://your-domain.com/thanks',
customer: async () => {
const user = await getCurrentUser(); // your own session
return user ? { email: user.email, metadata: { userId: user.id } } : null;
},
});
Now a plain link starts a checkout:
<a href="/checkout?product=pro">Buy Pro</a>
A few things the route does for you:
- Only slugs in
productscan be bought. Someone editing the URL cannot buy a product you did not list. - The buyer's email and the metadata come from your server through
customer(), never from the query string. - The route adds the slug to the metadata as
product, so your webhook knows what was bought. - It ignores prefetch requests. A
<Link>that scrolls into view, or a browser prefetch, does not open a checkout. With<Link>, setprefetch={false}anyway.
In test mode, pay with 4242 4242 4242 4242, any future date and any CVC. The buyer comes back to successUrl with ?sessionId=....
3. The webhook route
// app/api/webhooks/agentaos/route.ts
import { Webhooks } from '@agentaos/nextjs';
export const POST = Webhooks({
webhookSecret: process.env.AGENTAOS_WEBHOOK_SECRET!,
onCheckoutCompleted: async (event) => {
const { userId, product } = event.data.metadata;
// Renewals arrive as checkout.session.completed too, without your metadata.
if (typeof userId !== 'string') return;
await grantAccess(userId, product);
},
onSubscriptionCanceled: async (event) => {
await revokeAccessByEmail(event.data.customerEmail);
},
});
The route verifies an HMAC-SHA256 signature over the raw body and rejects anything older than 5 minutes. It refuses to start without a secret, so a missing environment variable fails at boot instead of accepting unsigned events. It needs no API key.
There is one function per event: onCheckoutCompleted, onSubscriptionCreated, onSubscriptionRenewed, onSubscriptionUpdated, onSubscriptionPaymentFailed, onSubscriptionCanceled, onDisputeCreated, onDisputeClosed, plus onEvent for everything. If your function throws, the route answers 500 and the event is delivered again.
Three things that trip people up
-
Renewals look like checkouts. A renewal or an upgrade charge also arrives as
checkout.session.completed, carrying asubscriptionIdinstead of your metadata. Save the link fromsubscriptionIdto your user on the first event. -
Events can arrive twice. Grant access once per
event.data.sessionId. -
Metadata keys arrive in camelCase.
user_idcomes back asuserId.
Using Better Auth?
If your app signs users in with Better Auth, @agentaos/better-auth does the checkout from the signed-in session instead:
const { data } = await authClient.agentaos.checkout({ slug: 'pro' });
window.location.href = data!.url;
Full guides: Next.js route handlers and Better Auth plugin. The packages are open source in AgentaOS/agentaos.
Top comments (1)
the renewals-look-like-checkouts point is the one that catches most integrations, because the event type says checkout and the first instinct is to treat it as a new sale. keying the grant on the session id is the right guard. the prefetch-ignore detail is underrated too: a link that opens a checkout on hover is how phantom sessions pile up in the dashboard and poll your conversion numbers. server-side buyer identity instead of the query string closes the last hole most hobby setups leave open.