Next.js 15 — App Router
Stack assumido: Next 15 + TypeScript + Prisma + zfiscoo-sdk.
1. Setup
pnpm add zfiscoo-sdk.env.local
ZFISCOO_API_URL=https://api.zfiscoo.zek.app.br
ZFISCOO_API_KEY=fk_live_xxxxxxxxxxxxxxxx
ZFISCOO_WEBHOOK_SECRET=whsec_xxxxxxxxlib/zfiscoo.ts
import { ZfiscooClient } from 'zfiscoo-sdk';
export const zfiscoo = new ZfiscooClient({
apiUrl: process.env.ZFISCOO_API_URL!,
apiKey: process.env.ZFISCOO_API_KEY!,
});2. Server Action — emitindo NFC-e
app/pedidos/[id]/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import crypto from 'node:crypto';
import { prisma } from '@/lib/prisma';
import { zfiscoo } from '@/lib/zfiscoo';
export async function emitirNfce(pedidoId: string) {
const pedido = await prisma.pedido.findUniqueOrThrow({
where: { id: pedidoId },
include: { items: true },
});
// Idempotency-Key determinístico por pedido — retry seguro
const idemKey = `nfce:${pedidoId}`;
const result = await zfiscoo.nfce.create({
body: {
issuer_id: process.env.ZFISCOO_ISSUER_ID!,
external_ref: pedidoId,
items: pedido.items.map((i) => ({
description: i.nome,
quantity: i.qty,
unit_price: Number(i.preco),
ncm: i.ncm,
cfop: '5102',
unit: 'UN',
cst_icms: '00',
})),
payment: { method: pedido.metodoPagamento, amount: Number(pedido.total) },
},
idempotencyKey: idemKey,
});
await prisma.pedido.update({
where: { id: pedidoId },
data: { nfceId: result.id, nfceStatus: result.status },
});
revalidatePath(`/pedidos/${pedidoId}`);
return result;
}3. Webhook handler
Next.js 15 com Route Handlers. Crítico: precisa do raw body pra validar HMAC, NÃO use req.json().
app/api/zfiscoo/webhook/route.ts
import { NextResponse } from 'next/server';
import crypto from 'node:crypto';
import { prisma } from '@/lib/prisma';
export const dynamic = 'force-dynamic';
export const runtime = 'nodejs'; // crypto não funciona em edge
export async function POST(req: Request) {
const raw = await req.text();
const signature = req.headers.get('x-signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.ZFISCOO_WEBHOOK_SECRET!)
.update(raw, 'utf8')
.digest('hex');
const received = signature.replace(/^sha256=/, '');
if (
expected.length !== received.length ||
!crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(received, 'hex'),
)
) {
return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
}
const event = JSON.parse(raw) as {
id: string;
type: string;
data: { id: string; external_ref: string; access_key?: string };
};
// De-dupe por event.id — gateway pode reentregar
const seen = await prisma.webhookEvent.findUnique({ where: { id: event.id } });
if (seen) return NextResponse.json({ ok: true, deduped: true });
await prisma.webhookEvent.create({ data: { id: event.id, type: event.type } });
if (event.type === 'nfce.authorized') {
await prisma.pedido.update({
where: { id: event.data.external_ref },
data: {
nfceStatus: 'authorized',
chaveAcesso: event.data.access_key,
},
});
}
return NextResponse.json({ ok: true });
}4. SSE no client (página de pedido)
app/pedidos/[id]/StatusStream.tsx
'use client';
import { useEffect, useState } from 'react';
export function StatusStream({ nfceId, sandboxKey }: { nfceId: string; sandboxKey: string }) {
const [status, setStatus] = useState<string>('processing');
useEffect(() => {
const es = new EventSource(
`${process.env.NEXT_PUBLIC_ZFISCOO_API_URL}/v1/nfce/${nfceId}/events?key=${sandboxKey}`,
);
es.addEventListener('status', (e) => {
const evt = JSON.parse(e.data);
setStatus(evt.status);
if (['authorized', 'denied', 'cancelled'].includes(evt.status)) es.close();
});
return () => es.close();
}, [nfceId, sandboxKey]);
return <span>Status: {status}</span>;
}🔒
Nunca exponha fk_live_... no client. Use o sandbox key público (fk_sandbox_public) pra SSE no browser
durante testes, ou um endpoint proxy seu (/api/nfce/stream) que faz o fetch server-side.