Next.js
Serve every customer domain from one Next.js app. Find the tenant from the Host header and trust only requests Apexly signed.
How requests arrive
Your customer's visitors reach careers.acme.com; Apexly terminates TLS and forwards the request to your origin with the original Host header and four headers of its own. Your app needs two things: map the host to a tenant, and refuse any request for a host you don't own that Apexly did not sign.
Host: careers.acme.com
X-Forwarded-Proto: https
X-Apexly-Domain-Id: dom_8f3a
X-Apexly-App-Id: app_hf_2k9x
X-Apexly-Timestamp: 1760587971
X-Apexly-Signature: 9a3f…Verify the signature
X-Apexly-Signature is the hex HMAC-SHA256 of <timestamp>.<host> keyed with your signing secret (whsec_…, Settings → Signing secret). Put the secret in APEXLY_SIGNING_SECRET and add this helper. It is tested against the shared vectors on every build.
// lib/apexly.ts
import { createHmac, timingSafeEqual } from "node:crypto";
/** True when X-Apexly-Signature is valid for this Host (the edge signs `${timestamp}.${host}`). */
export function verifyApexlyOrigin(
host: string,
timestamp: string | null | undefined,
signature: string | null | undefined,
secret: string,
now = Math.floor(Date.now() / 1000),
): boolean {
if (!timestamp || !signature || !/^\d{1,12}$/.test(timestamp)) return false;
if (Math.abs(now - Number(timestamp)) > 300) return false;
const name = host.replace(/:\d+$/, "").toLowerCase();
const expected = createHmac("sha256", secret).update(`${timestamp}.${name}`).digest();
const got = Buffer.from(signature, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
}Then check it in proxy.ts (called middleware.ts before Next.js 16), which runs before every route:
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { verifyApexlyOrigin } from "./lib/apexly";
// Hosts you serve yourself; everything else must come through Apexly.
const OWN_HOSTS = new Set(["app.hireflow.com", "localhost:3000"]);
export function proxy(req: NextRequest) {
const host = req.headers.get("host") ?? "";
if (OWN_HOSTS.has(host)) return NextResponse.next();
const ok = verifyApexlyOrigin(
host,
req.headers.get("x-apexly-timestamp"),
req.headers.get("x-apexly-signature"),
process.env.APEXLY_SIGNING_SECRET!,
);
if (!ok) return new NextResponse("Unknown host", { status: 421 });
return NextResponse.next();
}
export const config = { matcher: "/((?!_next/static|_next/image|favicon.ico).*)" };Find the tenant
Read the host in a Server Component, route handler or Server Action and look the tenant up by it. Store the hostname with the tenant when you create the domain (or put your tenant id in metadata and store the domain id).
// app/page.tsx
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { tenantByHost } from "@/lib/tenants";
export default async function Home() {
const host = (await headers()).get("host")!.replace(/:\d+$/, "");
const tenant = await tenantByHost(host);
if (!tenant) notFound();
return <h1>Careers at {tenant.name}</h1>;
}Allow your customers' hostnames
Next.js does not block unknown hosts, so nothing else is needed. If you build absolute URLs, build them from the request host and x-forwarded-proto, not from a fixed base URL. Server Actions compare Origin with Host; Apexly keeps both as the visitor sent them, so they work on custom domains unchanged.
Receive webhooks
Add a route handler that verifies Apexly-Signature over the raw body before parsing it. The verifyApexlySignature function is on the Webhooks page.
// app/webhooks/apexly/route.ts
import { verifyApexlySignature } from "@/lib/apexly-webhooks";
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyApexlySignature(req.headers.get("apexly-signature") ?? undefined, raw, process.env.APEXLY_SIGNING_SECRET!)) {
return new Response(null, { status: 400 });
}
const event = JSON.parse(raw);
if (event.type === "domain.active") await markLive(event.data.domain.metadata.customer);
return new Response(null, { status: 204 });
}Try it
Create a test key and a test domain with the Quickstart: test domains reach active on a ten-second timer. Point a live domain at the sandbox origin to see the exact headers your app will get, with signature_valid worked out for you.