Skip to main content
Cookbooks

Webhook Listener

React to payment webhooks with a deterministic loop. On payment.confirmed, automatically issue an NF-e, create shipping, and send WhatsApp — no agent, no LLM, no surprise.

1 min read · updated
View MarkdownEdit on GitHub
TIME
~15 min
STACK
Next.js APIno LLM
SERVERS
asaasnuvem-fiscalcorreiosz-api

React to payment webhooks with a deterministic loop. On payment.confirmed, automatically issue an NF-e, create shipping, and send WhatsApp — no agent, no LLM, no surprise.

EVENT PIPELINE
Webhook → validation → deterministic loop → response
1 · EVENT IN
Webhook arrives
POST /api/webhooks/payment
2 · VALIDATE
Signature check
X-CodeSpar-Signature (HMAC-SHA256)
3 · RUN LOOP
session.loop()
3 steps · abortOnError: false
STEP 1
codespar_invoice
Issue NF-e via nuvem-fiscal
STEP 2
codespar_ship
Create label via correios
STEP 3
codespar_notify
WhatsApp via z-api
event payload flows through each step · outputs chain via params: (prev) => ...

Prerequisites

npm install @codespar/sdk next

Register the endpoint as a trigger via POST /v1/triggers with { name, event, webhook_url } pointing to /api/webhooks/payment. CodeSpar subscribes to the underlying provider and forwards normalized events to you, signed with X-CodeSpar-Signature. Copy the signing secret returned on creation to CODESPAR_TRIGGER_SECRET — it is only returned once.

Webhook handler

The complete handler: validate signature, create session, run the deterministic loop, return response.

app/api/webhooks/payment/route.ts
import { CodeSpar } from "@codespar/sdk";
import { NextResponse } from "next/server";
import crypto from "node:crypto";

const codespar = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });

export async function POST(req: Request) {
  // 1. Validate webhook signature — HMAC-SHA256 of the raw body
  const raw = await req.text();
  const signature = req.headers.get("X-CodeSpar-Signature");
  const expected = crypto
    .createHmac("sha256", process.env.CODESPAR_TRIGGER_SECRET!)
    .update(raw)
    .digest("hex");
  if (!signature || signature !== expected) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }
  const body = JSON.parse(raw);

  // 2. Only process confirmed payments
  if (body.event !== "payment.confirmed") {
    return NextResponse.json({ received: true });
  }

  // 3. Create session + run deterministic loop
  const session = await codespar.create("user_123", {
    servers: ["nuvem-fiscal", "correios", "z-api"],
  });

  try {
    const result = await session.loop({
      steps: [
        { tool: "codespar_invoice", params: {
          type: "nfe",
          customer_cpf: body.customer.cpf,
          items: body.items,
          total: body.amount,
        }},
        { tool: "codespar_ship", params: {
          to_cep: body.shipping.cep,
          weight_kg: body.shipping.weight,
        }},
        { tool: "codespar_notify", params: (prev) => ({
          channel: "whatsapp",
          to: body.customer.phone,
          template: "order_confirmed",
          variables: {
            tracking: prev[1].data.tracking_code,
            nfe_url: prev[0].data.pdf_url,
          },
        })},
      ],
      abortOnError: false,
    });

    return NextResponse.json({
      processed: true,
      steps: result.completedSteps,
    });
  } finally {
    await session.close();
  }
}

abortOnError: false keeps the loop running even when one step fails. The NF-e might fail, but the customer still gets their tracking code. Use onStepError to log failures without breaking the flow.

Handling failures

Capture failures with onStepError and inspect which steps succeeded via result.results:

const result = await session.loop({
  steps: [...],
  abortOnError: false,
  onStepError: (step, error, index) => {
    console.error(`Step ${index}: ${step.tool} failed`, error);
    // send to Sentry, Datadog, etc.
  },
});

// Queue failed steps for retry
for (const [i, stepResult] of result.results.entries()) {
  if (!stepResult.success) {
    await retryQueue.add({ step: i, params: stepResult.params });
  }
}

Idempotency

Webhooks can be delivered more than once. Use the payment ID as an idempotency key before running the loop:

const processed = await db.webhooks.findUnique({
  where: { payment_id: body.payment_id },
});

if (processed) {
  return NextResponse.json({ already_processed: true });
}

// ... run the loop ...

await db.webhooks.create({
  data: { payment_id: body.payment_id, processed_at: new Date() },
});

Many providers (Asaas, Stripe) include an idempotency_key header. When available, prefer it over the payment ID — it's scoped to the webhook delivery, not the payment event.

Next steps

Last updated on