The sign-up form fires signup_completed in the browser. DataFast counts 180
this month. Your database has 231 new accounts. The missing 51 had blockers,
closed the tab on the redirect, or lost signal.
Anything you would investigate if it were wrong belongs on the server.
The API
POST https://datafa.st/api/v1/goals
Authorization: Bearer df_...
Content-Type: application/json
{
"datafast_visitor_id": "a3ab2331-989f-4cfa-91c6-2461c9e3c6bd",
"name": "signup_completed",
"metadata": { "method": "email" }
}
- The key is a website API key,
df_, from Website settings, API. Server only. NeverNEXT_PUBLIC_. namefollows the same rules as browser goals: lowercase, digits,_,-,:, 64 characters.identifyis reserved.metadata: at most 10 keys matching^[a-z0-9_-]+$, values up to 255 characters.
Where the visitor id comes from
A goal belongs to a visitor. The browser has the id in the
datafast_visitor_id cookie, and that cookie is first-party, so it arrives on
every request to your server. Read it in the server action:
import { cookies } from "next/headers";
const visitorId = (await cookies()).get("datafast_visitor_id")?.value;
From a webhook or a background job there is no browser cookie. Store the visitor id on the user row at signup and read it from there.
Why it 404s
404 means "this visitor has no page views on this website". Common causes:
- Local development. The script skips localhost, so no page view was ever recorded for your dev visitor. Every server goal from dev 404s.
- Consent not given. The script never ran, so the cookie is stale or absent.
- Wrong site. The
df_key belongs to a different website than the id.
Treat 404 as a log line, never as a failed request.
Send it after the response
The goal must never slow a sign-up down, and on serverless an un-awaited
fetch can be frozen mid-flight when the response is sent. after() from
next/server fixes both: it runs once the response is out, and on Vercel it
keeps the function alive until the promise settles.
import { cookies } from "next/headers";
import { after } from "next/server";
export function trackGoalServer(name: string, metadata?: Record<string, string>): void {
// Read the cookie now. Inside after() the request is gone.
const pending = cookies().then((jar) => jar.get("datafast_visitor_id")?.value);
after(async () => {
const visitorId = await pending;
const key = process.env.DATAFAST_API_KEY;
if (!visitorId || !key) return;
const response = await fetch("https://datafa.st/api/v1/goals", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ datafast_visitor_id: visitorId, name, metadata }),
signal: AbortSignal.timeout(5_000),
}).catch(() => null);
if (response && !response.ok) {
console.warn(`[analytics] goal ${name} answered ${response.status}`);
}
});
}
It returns void on purpose. Nobody can await it in the request path.
Two traps:
after()throws outside a request. In a script or a cron job, catch that and send inline.- Fire after the write succeeds. A goal for an account that failed to save is a lie in your funnel.
Server or browser, never both
Pick one side per goal name. checkout_started in the browser is intent.
signup_completed on the server is a fact. Sending the same name from both
doubles the count, with no way to tell the copies apart.
Check it
curl -X POST https://datafa.st/api/v1/goals \
-H "Authorization: Bearer $DATAFAST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"datafast_visitor_id":"<id from your cookie>","name":"signup_completed"}'
200 with an eventId means the key, the visitor and the name are all good.