Skip to main content
Cookbooks

Webhook Listener

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

1 min read
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 commerce.payment.succeeded, 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
loop(session, …)
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, loop } 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 !== "commerce.payment.succeeded") {
    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 loop(session, {
      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 as { tracking_code: string }).tracking_code,
            nfe_url: (prev[0].data as { pdf_url: string }).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:

import { CodeSpar } from "@codespar/sdk";
const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });
const session = await cs.create("user_123", { servers: ["asaas"] });
// ---cut---
declare const retryQueue: { add(x: unknown): Promise<void> };
// ---cut---
import { loop } from "@codespar/sdk";

const result = await loop(session, {
  steps: [], // the steps this run should take
  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) {
    // ToolResult carries what ran, not what you passed: re-queue by index
        // and tool, and read the params back from your own step list.
        await retryQueue.add({ step: i, tool: stepResult.tool, error: stepResult.error });
  }
}

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, the fiscal rail, 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 });
}

await loop(session, { steps });   // the three steps from the handler above

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

Webhook Listener | CodeSpar