Recipe: Next.js Server Action
A single "use server" file that quotes, submits, and hands a jobId back to the client — with the API key never leaving the server. ~60 lines including imports.
What you'll build
A form on /order/new that takes (sourceLanguage, useCase, pageCount, documentUrl) and triggers the full Verdacert lifecycle on submit.
- Server Action
placeOrder()that runsquotethensubmitin one round-trip from the client. - Result page at
/order/[jobId]that pollsgetStatuson the server and redirects to the artifact when ready. - Idempotent on form re-submit (browser refresh ⇒ same key ⇒ same job).
Prerequisites
Next.js 14+ App Router, a Verdacert sandbox key.
# .env.local — server-side only, NEVER prefix with NEXT_PUBLIC_
VERDACERT_API_KEY=vc_sandbox_…See /docs/authentication for the full security checklist.
The Server Action
One file. "use server" at the top scopes it to the server runtime so the key never gets bundled.
// app/order/actions.ts
"use server";
import { createHash } from "node:crypto";
import { redirect } from "next/navigation";
interface PlaceOrderInput {
sourceLanguage: string;
useCase: string;
pageCount: number;
documentUrl: string;
// Pass the signed-in user id so we can dedup retries — see notes below.
userId: string;
}
export async function placeOrder(input: PlaceOrderInput): Promise<void> {
const auth = {
Authorization: `Bearer ${process.env.VERDACERT_API_KEY!}`,
"Content-Type": "application/json",
};
// Reproducible key: same inputs ⇒ same idempotencyKey ⇒ same job on retry.
const idempotencyKey =
"ord_" +
createHash("sha256")
.update(`${input.userId}.${input.documentUrl}`)
.digest("hex")
.slice(0, 24);
const quoteBody = {
sourceLanguage: input.sourceLanguage,
useCase: input.useCase,
pageCount: input.pageCount,
speedTier: "standard" as const,
};
const quote = await fetch("https://verdacert.com/api/v1/quote", {
method: "POST",
headers: auth,
body: JSON.stringify(quoteBody),
}).then((r) => r.json());
const submit = await fetch("https://verdacert.com/api/v1/submit", {
method: "POST",
headers: auth,
body: JSON.stringify({
quoteId: quote.quoteId,
idempotencyKey,
echoedInput: quoteBody,
documents: [{ url: input.documentUrl }],
endUser: { externalId: input.userId },
}),
}).then((r) => r.json());
if (submit.error) throw new Error(submit.error.code + ": " + submit.error.message);
// Redirect to the order detail page; it'll poll status on the server.
redirect(`/order/${submit.jobId}`);
}placeOrder twice with the same inputs. A reproducible idempotencyKey guarantees only the first call creates an order — the second gets the same jobId back. See /docs/idempotency.The client form
A plain Server Component form that calls the action. No useState, no fetch from the client.
// app/order/new/page.tsx
import { auth } from "@/lib/auth";
import { placeOrder } from "../actions";
export default async function NewOrderPage() {
const session = await auth();
if (!session?.user) return <p>Please sign in.</p>;
async function action(formData: FormData) {
"use server";
await placeOrder({
userId: session!.user!.id!,
sourceLanguage: String(formData.get("sourceLanguage")),
useCase: String(formData.get("useCase")),
pageCount: Number(formData.get("pageCount")),
documentUrl: String(formData.get("documentUrl")),
});
}
return (
<form action={action} style={{ display: "grid", gap: 12, maxWidth: 480 }}>
<label>Source language
<select name="sourceLanguage" required>
<option value="fa">Farsi</option>
<option value="ar">Arabic</option>
<option value="ur">Urdu</option>
</select>
</label>
<label>Use case
<select name="useCase" required>
<option value="uscis">USCIS</option>
<option value="court">Court</option>
<option value="university">University</option>
</select>
</label>
<label>Page count
<input name="pageCount" type="number" min={1} max={500} defaultValue={1} required />
</label>
<label>Document URL (publicly reachable)
<input name="documentUrl" type="url" required />
</label>
<button type="submit">Place order</button>
</form>
);
}The order detail page
Server Component that fetches getStatus on every render. Auto-refresh in 30s via revalidate or a small client poller — your call.
// app/order/[jobId]/page.tsx
export const revalidate = 30;
async function getStatus(jobId: string) {
const r = await fetch(
`https://verdacert.com/api/v1/status/${jobId}`,
{
headers: { Authorization: `Bearer ${process.env.VERDACERT_API_KEY!}` },
next: { revalidate: 30 },
},
);
return r.json();
}
async function getResult(jobId: string) {
const r = await fetch(
`https://verdacert.com/api/v1/result/${jobId}`,
{ headers: { Authorization: `Bearer ${process.env.VERDACERT_API_KEY!}` } },
);
return r.json();
}
export default async function OrderDetailPage({
params,
}: { params: Promise<{ jobId: string }> }) {
const { jobId } = await params;
const status = await getStatus(jobId);
if (status.status === "ready" || status.status === "delivered") {
const result = await getResult(jobId);
return (
<>
<h1>{result.orderNumber}</h1>
<p>Status: <strong>{status.status}</strong></p>
<a href={result.artifacts[0].url} download>Download certified PDF</a>
<p>
Public verification:{" "}
<a href={result.certification.publicVerifyUrl}>
{result.certification.publicVerifyUrl}
</a>
</p>
</>
);
}
return (
<>
<h1>{status.orderNumber}</h1>
<p>Status: <strong>{status.statusDescription}</strong> ({status.progressPercent}%)</p>
<p>This page refreshes every 30 seconds. Or wait for the webhook.</p>
</>
);
}Production notes
Three things to wire up before you flip the env var to a live key.
1. Replace polling with webhooks
The revalidate: 30 trick is good for sandbox development. In production, subscribe to webhooks (/docs/webhooks) so the order page updates the moment an order.ready event lands.
2. Persist your own order_intent row
Write a row to your DB before calling placeOrder, keyed by your idempotencyKey. That way you can attribute the Verdacert jobId back to your user, surface order history, and recover from partial failures.
3. Handle PAYMENT_REQUIRES_ACTION on live keys
Live submit can return PAYMENT_REQUIRES_ACTIONwhen the cardholder needs to complete 3DS. The agent can't complete that off-session; you must surface Stripe's next_action to the human. See /docs/troubleshooting#payments.
