Skip to main content

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 });
  }
}

Inbound webhook adapters

The example above covers outbound webhooks: events that CodeSpar fires to your URL when something happens in a session (a payment confirms, a label is generated, a KYC verdict lands). The signature scheme is always HMAC-SHA256 with X-CodeSpar-Signature, exactly as shown above.

The CodeSpar backend also runs inbound webhook adapters — endpoints under /v1/webhooks/<server_id>/<connection_id> that providers post to directly. There are 14+ adapters live today (10 commerce + 4 KYC: Asaas, Mercado Pago, Stripe, iugu, Stone, Coinbase Commerce, Bitso, Melhor Envio, NFe.io, Z-API plus Persona, Sift, Konduto, Truora). Each adapter verifies the provider's signature using one of four schemes:

SchemeWhere it's used
HMAC-SHA256 (hex digest in X-Signature or similar header)Most providers — Asaas, Stripe, Z-API, etc.
ECDSA P-256Some Mercado Pago configurations
HTTP BasicSift, Konduto
Shared-secret comparisonProviders that pass a raw secret in body or header

You don't write any of this verification yourself — the CodeSpar backend handles it and only forwards verified events on to your registered triggers (which are HMAC-SHA256, as documented above).

If you're self-hosting and need to verify inbound webhooks from a specific provider directly, see the per-provider section in the operations runbook.

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

Edit on GitHub

Last updated on