Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.neuraldraft.io/llms.txt

Use this file to discover all available pages before exploring further.

This snippet is a working integration for Next.js 15 (App Router) — the fastest path to a production site whose content, blog, and webhooks all live in Neural Draft.

Install

npm install @neuraldraft/sdk
Add the env vars:
NEURAL_DRAFT_API_KEY=ndsk_live_...
NEURAL_DRAFT_WEBHOOK_SECRET=whsec_...

The shared client

import { NeuralDraft } from "@neuraldraft/sdk";
import { createHmac, timingSafeEqual } from "node:crypto";

if (!process.env.NEURAL_DRAFT_API_KEY) {
  throw new Error("Missing NEURAL_DRAFT_API_KEY in env.");
}

export const neuralDraft = new NeuralDraft({
  apiKey: process.env.NEURAL_DRAFT_API_KEY,
});

/**
 * Verify the HMAC-SHA256 signature on an incoming Neural Draft webhook.
 * Reject deliveries older than 5 minutes (replay protection).
 */
export function verifyNeuralDraftSignature(
  body: string,
  header: string,
  secret = process.env.NEURAL_DRAFT_WEBHOOK_SECRET ?? "",
  toleranceSeconds = 300
): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string])
  );
  const t = Number(parts.t);
  const v1 = parts.v1;
  if (!Number.isFinite(t) || !v1 || !secret) return false;

  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - t) > toleranceSeconds) return false;

  const signed = `${t}.${body}`;
  const expected = createHmac("sha256", secret).update(signed).digest("hex");

  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

A blog page that re-builds when content changes

Next.js + ISR + the content.changed / blog_post.published webhooks gives you a static site that re-renders the second something updates.
import Link from "next/link";
import { neuralDraft } from "@/lib/neural-draft";

export const revalidate = 3600; // hourly fallback; webhooks force on-change.

export default async function BlogIndex() {
  const { data: posts } = await neuralDraft.blogPosts.list({
    status: "published",
    lang: "en",
    per_page: 20,
  });

  return (
    <main className="prose mx-auto py-12">
      <h1>Blog</h1>
      <ul>
        {posts.map((p) => (
          <li key={p.id}>
            <Link href={`/blog/${p.slug}`}>
              <h2>{p.title}</h2>
              <p>{p.excerpt}</p>
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}
import { notFound } from "next/navigation";
import { neuralDraft } from "@/lib/neural-draft";

export const revalidate = 3600;

export async function generateStaticParams() {
  const { data } = await neuralDraft.blogPosts.list({
    status: "published",
    per_page: 100,
  });
  return data.map((p) => ({ slug: p.slug }));
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await neuralDraft.blogPosts.bySlug(slug, { lang: "en" }).catch(() => null);
  if (!post) notFound();

  return (
    <article className="prose mx-auto py-12">
      <h1>{post.title}</h1>
      {post.featured_image && (
        <img src={post.featured_image} alt={post.title} />
      )}
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

A webhook receiver that triggers re-validation

import { revalidatePath, revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
import { verifyNeuralDraftSignature } from "@/lib/neural-draft";

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("x-neural-draft-signature") ?? "";
  if (!verifyNeuralDraftSignature(body, sig)) {
    return new NextResponse("invalid signature", { status: 401 });
  }

  const event = JSON.parse(body) as { type: string; data: any };

  switch (event.type) {
    case "blog_post.published":
    case "blog_post.translated":
      revalidatePath("/blog");
      revalidatePath(`/blog/${event.data.slug}`);
      break;
    case "content.changed":
      revalidatePath("/", "layout"); // full-site invalidate
      break;
    default:
      break;
  }

  return NextResponse.json({ received: true });
}

Server action to create a post

"use server";

import { redirect } from "next/navigation";
import { neuralDraft } from "@/lib/neural-draft";

export async function createDraft(formData: FormData) {
  const post = await neuralDraft.blogPosts.create({
    title: String(formData.get("title")),
    content: String(formData.get("content")),
    language_code: "en",
    status: "draft",
  });

  redirect(`/blog/${post.slug}`);
}
import { createDraft } from "./actions";

export default function NewPostPage() {
  return (
    <form action={createDraft} className="prose mx-auto py-12 space-y-4">
      <h1>New post</h1>
      <input name="title" required className="block w-full border p-2" />
      <textarea name="content" required rows={8} className="block w-full border p-2" />
      <button type="submit" className="rounded bg-purple-600 px-4 py-2 text-white">
        Save draft
      </button>
    </form>
  );
}

A storefront product card

import { neuralDraft } from "@/lib/neural-draft";

export async function ProductCard({ slug }: { slug: string }) {
  const product = await neuralDraft.publicProducts.bySlug(slug);

  return (
    <article className="rounded-lg border p-6">
      {product.images?.[0] && (
        <img src={product.images[0]} alt={product.name} className="rounded" />
      )}
      <h3 className="mt-3 text-lg font-semibold">{product.name}</h3>
      <p className="mt-1 text-sm text-zinc-500">{product.short_description}</p>
      <p className="mt-2 font-bold">
        {(product.price / 100).toFixed(2)} {product.currency.toUpperCase()}
      </p>
    </article>
  );
}

Notes

  • revalidatePath requires Next.js 14+. On 13, swap to res.revalidate() in a Pages Router handler.
  • The webhook route MUST read raw body via req.text()req.json() parses and re-encodes, breaking the HMAC.
  • Build-time generateStaticParams is bounded by your rate limit. Use per_page: 100 and paginate if you have more than a few hundred posts.