Skip to content
docs

In-app enforcement

By default the toll runs in front of your origin: an agent's request reaches us first, we decide and price it, then we proxy the read to you. That works until your origin sits behind an edge that rate-limits by IP: a serverless host's per-IP throttle, a WAF, an aggressive CDN. All fleet traffic shares one egress IP, so a busy site can trip that limit and start seeing 429s on legitimate reads.

In-app enforcement moves the decision into your own app instead. You run a small piece of middleware; the agent's request hits your origin directly, on the agent's own IP, and the middleware decides right there whether to serve the page or answer 402. Nothing proxies through us, so there's no shared IP to rate-limit. The money and the catalog still run through the control plane over HTTPS, so you never hold funds and you don't maintain a price list unless you want to.

Use this mode when your origin's edge rate-limits the fleet, and whenever your site runs its own code: a Node app, or self-hosted WordPress. Nothing about your DNS changes, so there's no propagation to wait through and no routing to undo later. If your site is static or hosted somewhere you can't run code, the front-of-origin toll is the one that works, so start there.

What you install

npm install @naulon/enforce

One middleware, wired to your framework's request pipeline. On Next.js App Router that's the root proxy file, proxy.ts on Next 16 (it was middleware.ts before; Next 16 renamed it and always runs it on the Node.js runtime, which is what the enforce kernel needs, so do not set a runtime in the config):

proxy.ts
// proxy.ts — Next 16 (older Next: middleware.ts). Runs on the Node.js runtime.
import { NextRequest, NextResponse } from "next/server";
import {
  naulonMiddleware,
  httpQuoteSource,
  httpPublisherConfigSource,
  httpObservationSink,
} from "@naulon/enforce";

// Toll stays OFF until NAULON_API_KEY is set — fail open, never break the site or charge a human.
const apiKey = process.env.NAULON_API_KEY;
const toll = apiKey
  ? naulonMiddleware({
      // WHAT is tolled, and which crawlers you free, charge or refuse — read from your
      // dashboard, so a settings change takes effect without redeploying this file.
      config: httpPublisherConfigSource("https://gate.naulon.app/_naulon/enforce-config", apiKey),
      quote: httpQuoteSource("https://gate.naulon.app/_naulon/quote", apiKey),
      verifyUrl: "https://gate.naulon.app/_naulon/verify",
      apiKey,
      // Required. Without it an agent that already paid is charged again on its next read.
      licenseVerification: {},
      // Reports the verdicts only your process can see (free reads, refusals) to your
      // dashboard. Fire-and-forget; drop this line and the audit page stays empty.
      observe: httpObservationSink("https://gate.naulon.app/_naulon/observe", apiKey),
    })
  : null;

export async function proxy(request: NextRequest) {
  if (toll) {
    const { response, setHeaders } = await toll(request);
    if (response) return response; // 402 payment required / 403 blocked
    const res = NextResponse.next();
    for (const [k, v] of Object.entries(setHeaders ?? {})) res.headers.set(k, v);
    return res;
  }
  return NextResponse.next(); // toll disabled — pass through untouched
}

// Wake on everything but Next's own internals. What counts as an article is your dashboard
// setting, not a path list kept in step by hand — anything out of scope passes straight
// through. If you toll only a few prefixes and want fewer invocations, you may narrow this
// to those paths; a whole-site toll needs it left as is.
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };

@naulon/enforce/next also ships createNaulonMiddleware(opts, NextResponse), a thin wrapper that does the NextResponse.next() + header dance for you when the proxy has no other job. Use the core naulonMiddleware (above) when you're composing the toll with existing proxy logic, an auth or session refresh, say.

The key

NAULON_API_KEY is an nln_live_ key. It authenticates the hosted calls and binds them to your tenant: a key for your site can only quote and settle your site's tolls, never another publisher's.

Get one from the domains card for the site, under Self-enforce in your app → Mint an API key, which mints it against that site and shows the secret once. We store a hash, so there is no version of us that could show it again; if you lose it, mint another and revoke the old one. The same key is also buildable by hand in Settings → API & webhooks → Create key: pick the site, give it read access, and leave the expiry empty.

Read access is all of it. The four legs the middleware calls (/quote, /verify, /enforce-config, /observe) each ask for tenant.read and nothing more, so there is no write permission to grant and none to leak. And leave it non-expiring: this key is the deployed site's permanent runtime credential, and an expired key is refused, which the middleware treats as "toll off", so your site would go on serving agents for free about the day it lapsed, with nothing broken to see. (The 30-day Connect WordPress preset is a different credential for a different job; don't use it here.)

Set it as a server-side variable. It must never reach the browser bundle: no NEXT_PUBLIC_ prefix, and don't read it in a client component.

Enforcement ships as its own small package, @naulon/enforce, separate from @naulon/sdk (the credits-route and settlement helpers), so you add only what you need. @naulon/enforce is the framework-agnostic core; @naulon/enforce/next is the thin Next.js adapter. It has no hard next dependency; you hand it your app's NextResponse, which is why the same core works outside Next behind withNaulon (a generic fetch handler wrapper).

WordPress

Everything above assumes a Node runtime. If your site is self-hosted WordPress, you don't install a package, you install a plugin, and it does the same job in PHP: the agent's request hits your WordPress directly, the plugin decides there, and nothing proxies through us.

  1. Download naulon.zip. That link always resolves to the current release.
  2. In WP Admin, open Plugins → Add New → Upload Plugin, choose that file, Install Now, then Activate.
  3. Open Settings → naulon and paste your API key.

It isn't listed in the wordpress.org plugin directory yet, so searching for it from inside WP Admin won't turn it up. Install it from the zip.

Updates

You install by uploading the zip, and today you update the same way: download the current naulon.zip and install it over the top. WordPress keeps your settings, your authors' wallet addresses and your earnings record. Those live in the database, not in the plugin's files.

Earlier releases shipped an updater that announced its own versions on the Plugins screen. That is gone, and deliberately: a plugin published in the wordpress.org directory may not serve its own updates, and the plugin is in review for a listing. When it is listed, updates arrive the way every other plugin's do: Dashboard → Updates, one click, auto-updates if you want them. Nothing about your site is sent to us to make that work. Until then it is the zip.

One thing worth knowing while you are on the zip: nothing will tell you a new version exists, so check the changelog when you want to know. A version that fixes something you are hitting is worth taking; otherwise the plugin does not go stale on its own.

How a request flows

Same decision as the front-of-origin toll, made in your runtime:

  1. Human? Browser-shaped requests return NextResponse.next(), so your route renders normally, free. The classifier biases toward "human"; an unsure request reads free. You never risk charging a person.
  2. Agent, on a tolling path? The middleware asks for a quote. No quote (the slug isn't gated, or has no wallet) means a free read, and your route renders.
  3. A price comes back. The middleware short-circuits with 402 Payment Required and the payment terms. Your route never runs.
  4. The agent pays. It retries with a signed payment. The middleware POSTs that to the verify endpoint; we settle it on-chain, buyer → author, and return a receipt plus a short-lived license. The middleware attaches the receipt headers and lets your route render. The agent can re-read that piece under the license without paying again.

Steps 3 and 4 build a 402 that is byte-identical to the one the front-of-origin toll produces, with the same price, the same payees and the same protocol, because the price comes from the same resolver either way.

Where prices come from

On WordPress this is already decided: the plugin asks the hosted quote endpoint, and your credits endpoint is what answers "who wrote this and where does the money go". The two sources below are the Node middleware's quote option.

The quote option is a source of price-and-payees, and there are two:

  • Hosted: httpQuoteSource(url, key). Each gated read asks the quote endpoint what the resource costs. Use this when the control plane already knows your catalog (for example, we scraped it), so you don't keep a price list in your app. A 204 means "no toll", the deliberate free-read signal.
  • Your own data: localQuoteSource(fn). If your app already knows its authors and prices, because you run a credits API or the data is in your database, wrap that lookup. fn returns the price and payee wallets for a slug, or nothing for a free read. No round-trip per quote; only the payment leg leaves your app.

Either way a quote carries payTo addresses and a price, never a wallet key. The split between co-authors is computed the same as everywhere else: integer micro-USDC, each share settling to its own wallet in the same transaction.

What still runs through the control plane

Three HTTPS calls, all authenticated with your nln_live_ key, all scoped to your tenant by the resource host. The first two are the toll; the third is what puts your traffic on your Audit page:

GET /_naulon/quote

Price a resource. Called by httpQuoteSource.

GET https://gate.naulon.app/_naulon/quote?resource=<url>&slug=<slug>&kind=read|citation
Authorization: Bearer nln_live_...
  • resource: the full URL being decided. Its host must be your tenant.
  • slug: the article identifier.
  • kind: read (default) or citation; a citation prices higher.

Responses: 200 with the quote (price + payees), 204 for no toll (free read), 401 for a bad key, 403 if the resource isn't yours.

POST /_naulon/verify

Settle a presented payment. Called by the middleware after an agent pays.

POST https://gate.naulon.app/_naulon/verify
Authorization: Bearer nln_live_...
Content-Type: application/json

{ "payment": "<signed payment>", "legs": [...], "quote": {...}, "resource": "<url>" }

legs and quote are what the middleware built from the price; we verify the payment against them and settle buyer → author. Custody-free: the money moves directly and we never pool it. Responses: 200 with the receipt (settlementRef, payer, and the response header to echo back), 400 for a malformed body, 401 for a bad key, 403 if the resource isn't yours or the site is suspended.

POST /_naulon/observe

Report what your runtime decided, so it shows up on your Audit page. Optional, since the toll works without it, but without it that page can only show traffic that went through our proxy, and yours doesn't.

POST https://gate.naulon.app/_naulon/observe
Authorization: Bearer nln_live_...
Content-Type: application/json

{
  "resource": "<url>",
  "slug": "<slug>",
  "verdict": "denied",
  "classifiedAs": "agent",
  "kind": "read",
  "priceMicro": 1000,
  "at": 1754120000000,
  "agent": { "ua": "GPTBot/1.0", "classifyReason": "ua-pattern:bot" }
}

Send one object or an array of up to 50. verdict is one of served-free, agent-reread, denied, blocked: the four outcomes only your runtime saw. priceMicro is integer micro-USDC and drives the "earnings missed" figure. Everything but resource, verdict and classifiedAs is optional; at defaults to now and is clamped if it's wildly off.

paid and payment-failed are refused here, deliberately. We write those ourselves from the settle outcome at /verify, so what your dashboard reports as earned is always money that actually moved, and no key can claim otherwise. That also means paid reads appear on your Audit page whether or not you wire this up.

Responses: 202 with { ok: true, accepted: n }, 400 for a malformed report or a money verdict, 401 for a bad key, 403 if the resource isn't yours.

To turn it on in the Node middleware:

import { naulonMiddleware, httpObservationSink } from "@naulon/enforce";

naulonMiddleware({
  // ...
  observe: httpObservationSink("https://gate.naulon.app/_naulon/observe", apiKey),
});

One request per decision, fire-and-forget. It can't delay or fail a reader's page, and if we're unreachable you lose some visibility and nothing else.

You never call the first two by hand; the middleware does. They're documented so you can see exactly what leaves your app: a signed payment and a resource URL, under a key that can only touch your own site. No reader data, no page content. What /observe adds is the User-Agent and the verdict, still no reader identity, no IP, no page content.

Going live (Node apps)

On WordPress, use Going live on WordPress instead: there's no package to add and no environment variable to set.

  1. Add @naulon/enforce to your app and drop in the middleware above, with your tenant id and tolling prefixes.
  2. Put your nln_live_ key in the environment (NAULON_API_KEY). Keep it server-side. Middleware runs on the server; never ship the key to the browser.
  3. Set the matcher to your tolling paths so the middleware stays asleep on everything else.
  4. Watch a real agent request a gated article, get a 402, pay, and read, on its own IP, straight to your origin, no 429. The author's wallet receives the fee directly. See settlement for what lands and where.

That first agent request is also what flips your dashboard from "waiting" to connected: the quote leg fires on every gated request, so it's the heartbeat that tells us the middleware is live. A human page view won't do it, because the classifier reads humans as free and never calls the quote endpoint. If the dashboard still says "waiting", send one agent-shaped request yourself: curl -A GPTBot https://your-site/articles/<a-gated-slug> should return 402.