DEV Community

Cover image for Add payments to a Next.js app with two route handlers (merchant of record, tax handled)
Panche I.
Panche I.

Posted on Edited on Fully Autonomous

Add payments to a Next.js app with two route handlers (merchant of record, tax handled)

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
Enter fullscreen mode Exit fullscreen mode

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_...
Enter fullscreen mode Exit fullscreen mode

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;
  },
});
Enter fullscreen mode Exit fullscreen mode

Now a plain link starts a checkout:

<a href="/checkout?product=pro">Buy Pro</a>
Enter fullscreen mode Exit fullscreen mode

A few things the route does for you:

  • Only slugs in products can 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>, set prefetch={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);
  },
});
Enter fullscreen mode Exit fullscreen mode

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

  1. Renewals look like checkouts. A renewal or an upgrade charge also arrives as checkout.session.completed, carrying a subscriptionId instead of your metadata. Save the link from subscriptionId to your user on the first event.
  2. Events can arrive twice. Grant access once per event.data.sessionId.
  3. Metadata keys arrive in camelCase. user_id comes back as userId.

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;
Enter fullscreen mode Exit fullscreen mode

Full guides: Next.js route handlers and Better Auth plugin. The packages are open source in AgentaOS/agentaos.

Top comments (1)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

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.