Credits API: GET /credits/:slug¶
The contract your site serves so the gate knows who to pay for a given article. One read-only endpoint: a slug in, an author-wallet split out, or a 404 that means "this one is free." The gate validates every response before a wallet can become a payment target, so a malformed or hostile reply is rejected, never silently mispaid. Schema lives in
@naulon/sdk(creditsSchema,parseCredits); the gate consumes it through theCreditsResolverseam.
The endpoint¶
GET ${CREDITS_API_URL}/credits/:slug
:slugis the article path slug the gate derives from the request URL.- Auth is optional. If you set a bearer token, the gate sends
Authorization: Bearer <token>on every call. Set one whenever the endpoint is reachable on the public internet (it always is when the cloud fleet calls you). 404means free. A 404 is the deliberate "don't gate this slug" signal, so the gate serves the article to machines for free. This is the seam for everything you don't want tolled (drafts, membership-only posts, anything without a wallet): return 404 and it reads free.200returns JSON in theArticleCreditsshape below.- Any other non-2xx is treated as an error: the gate throws rather than guess, so a transient outage fails closed instead of mispaying.
The shape¶
{
"slug": "on-stillness", // non-empty; should match the request
"title": "On Stillness", // non-empty
"contributors": [ // at least one
// a leaf author resolves to a wallet
{ "authorId": "mira", "wallet": "0x1111111111111111111111111111111111111111", "weight": 2 },
// a composite re-splits among its own members (recursively)
{ "authorId": "the-collective",
"members": [
{ "authorId": "okonkwo", "wallet": "0x2222222222222222222222222222222222222222" },
{ "authorId": "petrova", "wallet": "0x3333333333333333333333333333333333333333" }
] }
]
}
Schema rules (a violation = the response is rejected, payment blocked)¶
| Rule | Detail |
|---|---|
| Leaf XOR composite | Each contributor has exactly one of wallet (leaf) or members (composite, ≥1), never both and never neither. |
| Wallet format | wallet matches ^0x[0-9a-fA-F]{40}$. |
| Weight | weight is optional, positive, default 1. It's a relative weight among siblings, normalized to shares. |
| Author id | authorId is a non-empty string. |
| Strict | The object is .strict(), so any unknown key is rejected. |
| Recursion | A member can itself be a composite, nested to any depth. |
The gate runs exactly this schema (parseCredits) on every response. A leaf wallet
becomes a payTo only after it validates, which is why a typo or an injected field
fails the read instead of routing money somewhere wrong.
How the split is computed¶
Weights normalize to integer micro-USDC shares; the rounding remainder is
assigned to the largest share so the parts always sum to the whole (no dust). The
on-chain payment leg pays the primary author as a single payTo, while the full
recursive co-author split is recorded on the settlement event (see
settlement-notifications.md). Money is never floated; it is
integer micro-USDC end to end.
Building it¶
With the SDK¶
@naulon/sdk/next ships a one-line App Router handler that pins the
404-means-free semantics for you:
// app/api/credits/[slug]/route.ts
import { createCreditsRoute } from "@naulon/sdk/next";
import { httpResolver } from "@naulon/sdk";
// Resolve a slug however you like: here, your own CMS endpoint. Or pass a
// fixtureResolver(map) for static credits, or implement CreditsResolver yourself.
export const GET = createCreditsRoute(httpResolver(process.env.MY_CMS_URL!), {
token: process.env.CREDITS_API_TOKEN, // optional bearer gate
});
The resolver is your policy. Everything site-specific, meaning which slugs are free, how an
author maps to a wallet, and membership rules, lives in your CreditsResolver; the
adapter only frames the HTTP (bearer check, 404 = not_found, else 200).
Hand-rolled (no dependency)¶
The adapter is ~20 lines of HTTP framing; if you'd rather not take the dependency, the contract is small enough to serve directly:
// app/api/credits/[slug]/route.ts
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const credits = await lookupCredits(slug); // your CMS/DB lookup
if (!credits) return Response.json({ error: "not_found" }, { status: 404 }); // free read
return Response.json(credits); // must match ArticleCredits
}
You still want to validate your own output against the contract before shipping.
buildCredits(obj) from @naulon/sdk runs the same schema and throws on a
malformed payload, so a bad shape surfaces in your tests, not mid-payment.
Security: this is a money-routing trust boundary¶
The credits response decides where a payment goes. Two things follow:
- Secure the endpoint. The gate validates that a response is well-formed, but it cannot tell that a valid wallet was swapped by a compromised or MITM'd endpoint. The integrity of this endpoint is the one path that can reroute a payment, so keep its auth tight and serve it over TLS.
- Never emit a wallet you don't mean. An author with no wallet set should be
omitted from
contributors, or the slug should 404 (read free) until a wallet exists, never a zero or placeholder address. The gate guards the zero address as a backstop, but don't rely on it.
Reference¶
A complete, runnable consumer (this endpoint + the signed settlement webhook,
against a static fixture) lives in
packages/sdk/examples/next-credits/. Copy
it, swap the resolver for your own source, and you have a tollable site.