---
title: Webhook Listener
description: 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.
---

<ActionBar githubPath="content/docs/cookbooks/webhook-listener.mdx" markdownPath="/api/docs-md/cookbooks/webhook-listener" />

<MetaStrip items={[
  { label: "TIME", value: "~15 min" },
  { label: "STACK", value: (<><ServerChip name="Next.js API" accent /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>no LLM</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="asaas" /><ServerChip name="nuvem-fiscal" /><ServerChip name="correios" /><ServerChip name="z-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.

<EventPipeline
  title="EVENT PIPELINE"
  subtitle="Webhook → validation → deterministic loop → response"
  nodes={[
    { tag: "1 · EVENT IN", name: "Webhook arrives", meta: "POST /api/webhooks/payment", tone: "event" },
    { tag: "2 · VALIDATE", name: "Signature check", meta: "X-CodeSpar-Signature (HMAC-SHA256)", tone: "validation" },
    { tag: "3 · RUN LOOP", name: "session.loop()", meta: "3 steps · abortOnError: false", tone: "loop" },
  ]}
  loopSteps={[
    { tool: "codespar_invoice", description: "Issue NF-e via nuvem-fiscal" },
    { tool: "codespar_ship", description: "Create label via correios" },
    { tool: "codespar_notify", description: "WhatsApp via z-api" },
  ]}
  note="event payload flows through each step · outputs chain via params: (prev) => ..."
/>

## Prerequisites

```bash
npm install @codespar/sdk next
```

<Callout>
Register the endpoint as a [**trigger**](/docs/concepts/triggers) 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.
</Callout>

## Webhook handler

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

```typescript title="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();
  }
}
```

<Callout type="warn">
**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.
</Callout>

## Handling failures

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

```typescript
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:

| Scheme | Where it's used |
|--------|-----------------|
| HMAC-SHA256 (hex digest in `X-Signature` or similar header) | Most providers — Asaas, Stripe, Z-API, etc. |
| ECDSA P-256 | Some Mercado Pago configurations |
| HTTP Basic | Sift, Konduto |
| Shared-secret comparison | Providers 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).

<Callout>
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.
</Callout>

## Idempotency

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

```typescript
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() },
});
```

<Callout>
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.
</Callout>

## Next steps

<NextStepsGrid items={[
  { label: "COOKBOOK", title: "E-Commerce Checkout", description: "The agent-driven counterpart — interactive checkout via chat.", href: "/docs/cookbooks/ecommerce-checkout" },
  { label: "COOKBOOK", title: "Multi-Tenant Agent", description: "Extend this pattern to multiple customers with isolated sessions.", href: "/docs/cookbooks/multi-tenant" },
  { label: "CONCEPT", title: "Sessions", description: "session.loop() reference — abortOnError, onStepComplete, retries.", href: "/docs/concepts/sessions" },
  { label: "CONCEPT", title: "Tools & Meta-Tools", description: "Meta-tool input schemas for deterministic calls.", href: "/docs/concepts/tools" },
]} />
