---
title: Triggers
description: Triggers are CodeSpar's outbound webhook subscriptions. Your app receives signed HTTP callbacks when asynchronous events settle (payment succeeded, invoice issued, notification delivered) or when the platform itself changes state.
---

import { Callout } from "fumadocs-ui/components/callout";

A **trigger** is a CodeSpar-managed webhook subscription. You register an endpoint in your app, CodeSpar watches the provider connections in your project plus its own runtime, and when a matching event fires (`commerce.payment.succeeded`, `commerce.invoice.issued`, `commerce.notify.delivered`) CodeSpar sends a signed HTTP POST to your endpoint.

Triggers replace the traditional pattern of wiring up one webhook per provider: one endpoint, normalized payload shape, single signing secret, single retry and dead-letter queue.

## When to use a trigger vs `session.send` / `session.execute`

| You want to... | Use |
|---|---|
| Agent asks "charge R$150 via Pix", synchronous result | `session.execute` / `session.send` |
| Background job: react to a payment settling N minutes after the charge | Trigger on `commerce.payment.succeeded` |
| Background job: react to a cobrança com vencimento being paid or expiring | Trigger on `commerce.charge.paid` / `commerce.charge.expired` (not `payment_notified` — see below) |
| Cron-like: reconcile payments every 5 min | Poll `/v1/sessions/:id/tool-calls` yourself |
| In-conversation "send the checkout link" | `session.execute("codespar_notify", ...)`, not a trigger |

Triggers are the async half of the Complete Loop. Whenever an action your agent kicks off today produces a settlement event tomorrow, a trigger is the right primitive.

## Lifecycle

1. **Create** via `POST /v1/triggers` (or in the dashboard). CodeSpar returns a signing secret **once**. Store it immediately; it is never revealed again.
2. **An event fires.** A provider webhook lands on one of your connections (a payment settles, an email bounces) or the platform emits an internal event (a tool call fails, a trigger auto-pauses).
3. **CodeSpar signs and delivers.** It computes `HMAC-SHA256(secret, "<timestamp>.<body>")`, puts `t=<unix>,v1=<hex>` in `X-CodeSpar-Signature`, and `POST`s the event envelope to your `webhook_url`.
4. **Your endpoint returns `2xx`** within 10 seconds. Any other outcome (non-2xx, timeout, network error) schedules a retry.
5. **Dead-letter.** After the fifth failed attempt the delivery is marked `dead` and lands in the trigger's DLQ. Inspect or operator-redeliver via the API.

## Trigger object

```json
{
  "id": "trg_a1b2c3d4e5f6g7h8",
  "org_id": "org_xyz789",
  "project_id": "prj_abc123",
  "name": "fulfillment-pipeline",
  "event": "commerce.payment.succeeded",
  "server_id": "stripe",
  "webhook_url": "https://yourapp.com/api/webhooks/codespar",
  "status": "active",
  "total_runs": 1847,
  "last_run_at": "2026-04-22T14:30:00Z",
  "created_at": "2026-04-01T09:00:00Z",
  "signing_enabled": true
}
```

| Field | Description |
|-------|-------------|
| `id` | Trigger ID in the form `trg_<16chars>` |
| `name` | Free-form label shown in the dashboard and logs |
| `event` | Exact event name to subscribe to (see [Event catalog](#event-catalog)). Lowercase, dot-separated. No wildcards: `*` is rejected with `400`. |
| `server_id` | Optional. Associates the trigger with one catalog server (must exist in the catalog); shown in the dashboard and usable as a list filter. Delivery matching itself is by event name. |
| `webhook_url` | Your HTTPS endpoint, reachable from `api.codespar.dev`. Private, loopback, and cloud-metadata addresses are rejected. |
| `status` | `active`, `paused`, or `error` (set by the system when the trigger auto-pauses after sustained delivery failures) |
| `signing_enabled` | `true` when a signing secret exists for this trigger. Always `true` for triggers created through the managed API. |

The `secret` field is returned **only** on `POST /v1/triggers` (creation) and `POST /v1/triggers/:id/rotate-secret` (rotation). Never in list or get responses.

## Event catalog

Matching is **exact string equality** on the full event name. There are no wildcard subscriptions; to receive several events, create one trigger per event. Triggers share no state, which keeps retry semantics clean per subscription. Event names are lowercase and dot-separated; anything else (including `*`) is rejected with `400`.

### Commerce events

These originate from webhooks CodeSpar receives on your provider connections. The envelope `source` is the server id of the connection that produced the event (for example `stripe`), and CodeSpar appends `connection_id` and `user_id` to every payload so you can route the delivery to the right tenant.

| Event | Fires when |
|-------|-----------|
| `commerce.payment.succeeded` | The provider confirmed the payment |
| `commerce.payment.failed` | The provider reported the payment as failed |
| `commerce.payment.refunded` | A refund was processed (on Stripe, `amount_minor` carries the refunded amount) |
| `commerce.payment.pending` | The payment is awaiting confirmation |
| `commerce.payment.updated` | The provider updated the payment's state |
| `commerce.payment.disputed` | The payment entered a dispute |

`commerce.payment.*` payload keys: `provider`, `provider_action`, `provider_native_id`, `payment_id`, `amount_minor`, `currency`, `customer_ref`, `external_reference`, `raw`, `connection_id`, `user_id`.

| Event | Fires when |
|-------|-----------|
| `commerce.charge.created` | A cobrança com vencimento finished registering with the clearing house, so its documents now exist (barcode, linha digitável, Pix copia-e-cola) |
| `commerce.charge.payment_notified` | The clearing house registered a payment of the boleto leg. **The money has not arrived yet** |
| `commerce.charge.paid` | The payer settled the cobrança, by either leg, and the funds are in the account |
| `commerce.charge.cancelled` | The cobrança was withdrawn before it was paid |
| `commerce.charge.expiry_notified` | The issuer reported that the cobrança passed its due date |
| `commerce.charge.expired` | The cobrança reached its due date unpaid, and CodeSpar closed the receivable |

`commerce.charge.*` belongs to a **cobrança com vencimento** issued through [`codespar_charge`](/docs/concepts/meta-tools/charge) (`method: "boleto"` plus `due_date`). Three of them are worth reading twice:

- **`commerce.charge.created` is how you learn the charge became payable.** The create call answers `PROCESSING` with no document, because the instrument registers with the clearing house first. Subscribe to this event rather than polling for a barcode that is not late, just not made yet.
- **`commerce.charge.paid` is the only one that means the money is yours.** When the payer uses the boleto leg, `commerce.charge.payment_notified` can arrive first: that is the clearing house posting its operational baixa, and the issuer says in as many words not to read it as proof of payment. Every convênio has a daily cut-off, so a boleto paid after it is notified today and credited on the next business day. The notification carries `liquidation_date` and `payment_release_date` so you can tell the payer when to expect it. **Ship on `paid`, not on `payment_notified`.**
- **`commerce.charge.expired` is emitted by CodeSpar, and fires exactly once.** Three things can close an unpaid cobrança: the issuer's own expiry notification, an `action: "status"` read, and a scheduled sweep. Whichever gets there first closes the receivable and emits this event, and the other two find it already closed. Its payload carries `charge_id`, `charge_external_id`, `due_date`, `amount_minor`, `currency` and `closed_by`, where `closed_by` names which of the three it was. A cobrança whose payment was already notified is never expired, so the D+1 gap above cannot write off a receivable somebody paid.

| Event | Fires when |
|-------|-----------|
| `commerce.ted_in.succeeded` | A TED arrived from outside CodeSpar and landed on a consumer's sub-account |
| `commerce.ted_in.failed` | The inbound transfer did not confirm. Nothing was credited, so nothing is reversed |
| `commerce.ted_out.succeeded` | The bank confirmed an outbound TED after the fact |
| `commerce.ted_out.failed` | The bank reported an outbound TED as failed |

**TED does not arrive as `commerce.payment.*`.** A TED reconciles in its own bucket, so an endpoint subscribed only to `commerce.payment.succeeded` will never see one. This is deliberate: a wire and a card capture do not belong in the same reconciliation bucket.

- **`commerce.ted_in.succeeded` is the only signal that an inbound TED landed**, and it is what credits the wallet. Unlike a Pix into a charge, nothing was created first, so there is no correlation row to match on. CodeSpar resolves the wallet by matching the receiving account (`credit_account`, `credit_branch`, `credit_tax_id`) against the consumer's registered funding source. If more than one source matches, CodeSpar credits nothing and logs the ambiguity rather than guessing which consumer the money belongs to. Payload keys: `provider`, `provider_action`, `payment_id`, `amount_minor`, `currency`, `external_reference`, `credit_account`, `credit_branch`, `credit_tax_id`, `raw`. `payment_id` is the provider's own transfer id and doubles as the funding idempotency key, so a redelivery credits once.
- **`commerce.ted_out.*` moves no money and confirms nothing you did not already know.** The outbound leg ([`POST /v1/consumer-payments/execute`](/docs/api/reference/consumer-payments) and its two siblings) confirms against the bank synchronously before it answers you, so by the time this event arrives the outcome is already in the response you hold. Subscribe to it for your own reconciliation trail, not as the thing you wait on.

| Event | Fires when |
|-------|-----------|
| `commerce.invoice.issued` | The invoicing provider issued the document |
| `commerce.invoice.canceled` | The document was canceled |
| `commerce.invoice.failed` | The provider reported a delivery failure for the document |

Invoice events are emitted for Facturapi connections today. Note that `commerce.invoice.failed` means the provider could not deliver the document; it is **not** a fiscal rejection, and the payload carries no `reason` key.

| Event | Fires when |
|-------|-----------|
| `commerce.kyc.approved` | Identity verification approved |
| `commerce.kyc.rejected` | Identity verification rejected |
| `commerce.kyc.review` | Verification moved to manual review |
| `commerce.kyc.expired` | Verification expired |
| `commerce.kyc.pending` | Verification is pending |

`commerce.kyc.*` payload keys: `provider`, `provider_action`, `verification_id`, `decision`, `external_reference`, `raw`, `connection_id`, `user_id`.

| Event | Fires when |
|-------|-----------|
| `commerce.notify.delivered` | The channel confirmed delivery (SendGrid) |
| `commerce.notify.bounced` | The message bounced |
| `commerce.notify.opened` | The recipient opened the message |
| `commerce.notify.clicked` | The recipient clicked a link in the message |

`commerce.notify.*` payload keys: `provider`, `provider_action`, `message_id`, `event_type`, `recipient`, `timestamp`, `external_reference`, `raw`, `connection_id`, `user_id`.

### Platform events

Emitted by the CodeSpar runtime itself; the envelope `source` is `internal`.

| Event | Payload keys | Fires when |
|-------|-------------|-----------|
| `tool_call.succeeded` / `tool_call.failed` | `tool_call_id`, `tool`, `server`, `duration_ms`, `error` | A tool call completed or failed |
| `proxy_call.succeeded` / `proxy_call.failed` | `proxy_call_id`, `server`, `method`, `endpoint`, `upstream_status`, `duration_ms`, `error_code` | A proxied provider call completed or failed |
| `session.closed` | `session_id`, `user_id`, `servers`, `closed_at` | A session was closed |
| `system.health.degraded` / `system.health.recovered` | `previous_status`, `current_status`, `checks_diff`, `observed_at` | Platform health transitioned |
| `trigger.paused_automatically` | `trigger_id`, `consecutive_failures`, `last_delivery_id` | A trigger auto-paused after consecutive failed deliveries |
| `trigger.test_fire` | `test: true`, `trigger_id`, `requested_at` | You called the test-fire endpoint |
| `approval.pending` | `approval_id`, `agent_id`, `tool_name`, `matched_rule_id`, `matched_rule_name`, `tool_input_hash`, `expires_at` | A policy rule held a call and queued it for a person to decide |
| `approval.decided` | `approval_id`, `tool_name`, `matched_rule_id`, `decision`, `decided_by`, `decided_by_source`, `decided_at`, `execution` | A person approved or denied a held call |

> End-to-end delivery of `approval.pending` and `approval.decided` to a webhook has not been verified yet. Both are published best-effort: a delivery that fails loses the notification, never the hold or the decision.

**Subscribe to `approval.pending` if anything in your project can be held.** A rule of type approval answers the agent `403` with an `approval_id` and waits for a person. Nothing else tells that person there is something waiting, so without this event the hold is silent until somebody opens the dashboard.

- The payload carries **no `tool_input`**. A trigger delivers to a URL you configure, and the input of a held payment is a payee and an amount. What you get is the approval's identity, the rule that matched, and `tool_input_hash` to tell two holds apart. Read the row itself with `GET /v1/approvals/:id`.
- `approval.decided` fires for a denial too, and for an approval it fires **after the held call has run**, so `execution` carries `{ ok, error, status }` — what the decision produced, not what it intended. A denial has no execution and omits the field.
- `decided_by_source` is `verified_user_token` when this backend verified the approver's identity. Treat anything else as an assertion you have not checked.

## Delivery format

Every delivery is a JSON envelope with a stable shape:

```json
{
  "id": "evt_9f8e7d6c5b4a3210",
  "type": "commerce.payment.succeeded",
  "source": "stripe",
  "occurred_at": "2026-04-22T14:30:00Z",
  "data": {
    "provider": "stripe",
    "payment_id": "pay_123",
    "amount_minor": 14900,
    "currency": "BRL",
    "connection_id": "ca_abc123",
    "user_id": "user_abc"
  }
}
```

And these headers:

```
POST /api/webhooks/codespar HTTP/1.1
Content-Type: application/json
X-CodeSpar-Signature: t=1745332200,v1=5f8a1c...
X-CodeSpar-Signature-Legacy: 9c1b7e...
X-CodeSpar-Event: commerce.payment.succeeded
X-CodeSpar-Event-Id: evt_9f8e7d6c5b4a3210
X-CodeSpar-Trigger-Id: trg_a1b2c3d4e5f6g7h8
X-CodeSpar-Attempt: 1
```

`X-CodeSpar-Attempt` counts from 1 and increments on each retry of the same event.

## Signature verification

`X-CodeSpar-Signature` has the form `t=<unix seconds>,v1=<hex>`. The `v1` value is `HMAC-SHA256(secret, "<t>.<raw_body>")`: the timestamp from the header, a literal dot, then the raw request body. Recompute it, compare in constant time, and reject stale timestamps to defeat replays (5 minutes is a sensible tolerance):

```typescript
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

export async function POST(req: Request) {
  const raw = await req.text();
  const header = req.headers.get("X-CodeSpar-Signature") ?? "";

  // Header shape: t=1745332200,v1=<hex>
  const parts = new Map(
    header.split(",").map((kv) => kv.split("=", 2) as [string, string]),
  );
  const t = Number(parts.get("t"));
  const v1 = parts.get("v1") ?? "";

  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) {
    return new Response("Stale or missing timestamp", { status: 401 });
  }

  const expected = crypto
    .createHmac("sha256", process.env.CODESPAR_TRIGGER_SECRET!)
    .update(`${t}.${raw}`)
    .digest("hex");

  const a = Buffer.from(v1, "hex");
  const b = Buffer.from(expected, "hex");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return new Response("Invalid signature", { status: 401 });
  }

  const event = JSON.parse(raw);
  // handle event.type + event.data ...
  return new Response(null, { status: 200 });
}
```

<Callout type="warn">
Always validate the signature against the **raw request body**, not the parsed JSON. JSON parse / re-serialize reorders keys and breaks the HMAC match.
</Callout>

<Callout type="info">
`X-CodeSpar-Signature-Legacy` carries a bare hex HMAC computed over the body alone (no timestamp). It exists so verifiers written against the original scheme keep working during the migration window. New integrations should verify the timestamped `X-CodeSpar-Signature`; the legacy header will be removed.
</Callout>

## Retries and dead-lettering

- **Retry schedule**: 5 attempts total (the initial delivery plus 4 retries), with waits of 1 minute, 5 minutes, 30 minutes, and 2 hours between attempts. The whole cycle wraps up within roughly nine hours, so a broken subscriber drops off the retry queue instead of retrying for days.
- **Success** = your endpoint returns any `2xx` within 10 seconds of connection. Optionally include the response header `X-CodeSpar-Receipt: ack` to record an explicit processing acknowledgment on the delivery record.
- **Attempt history**: each attempt is recorded as its own delivery row, so `GET /v1/triggers/:id/deliveries` shows the full append-only history per event.
- **Dead-letter**: an attempt that fails with no retries left is marked `dead`. `GET /v1/triggers/:id/dlq` lists every dead delivery with its response status and error so you can replay locally.
- **Auto-pause**: 20 consecutive dead-lettered deliveries flips the trigger's `status` to `error` and emits a `trigger.paused_automatically` event (subscribe to it with a second trigger if you want to be alerted). Retries only run for `active` triggers, so an auto-paused trigger stops delivering entirely. Clear the underlying issue, then `PATCH /v1/triggers/:id` with `status: "active"` to resume.
- **Operator redeliver**: `POST /v1/triggers/deliveries/:did/redeliver` re-enqueues a single delivery. `POST /v1/triggers/retry-pending` re-dispatches every due retry in your project immediately instead of waiting for the next worker tick.

## Idempotency

CodeSpar guarantees **at-least-once** delivery: the same event may arrive more than once under retry or operator redelivery. Use `X-CodeSpar-Event-Id` as your idempotency key. Retries and redeliveries of the same event carry the same event id, so a dedupe check on it collapses them:

```typescript
const eventId = req.headers.get("X-CodeSpar-Event-Id");
if (await alreadyProcessed(eventId)) {
  return new Response(null, { status: 200 });
}
await processEvent(event);
await markProcessed(eventId);
```

## Test-fire

`POST /v1/triggers/:id/test-fire` sends a synthetic `trigger.test_fire` event through the real signing and delivery pipeline, and the attempt shows up in the deliveries list like any other. Pass `event_type` in the body to rehearse a specific handler (for example `commerce.payment.succeeded`); pass `payload` to merge extra keys into the fixture `{ test: true, trigger_id, requested_at }`.

```bash
curl -X POST https://api.codespar.dev/v1/triggers/trg_abc123/test-fire \
  -H "Authorization: Bearer csk_live_..."
```

The trigger must be `active`; test-firing a paused or errored trigger returns `409`. Test fires reach only the trigger you name, so sibling triggers subscribed to the same event stay quiet.

## Next steps

<NextStepsGrid items={[
  { label: "REFERENCE", title: "Triggers API", description: "Full HTTP reference: create, rotate, test-fire, DLQ.", href: "/docs/api/triggers" },
  { label: "COOKBOOK", title: "Webhook Listener", description: "End-to-end cookbook: subscribe, validate, run a deterministic loop.", href: "/docs/cookbooks/webhook-listener" },
  { label: "CONCEPT", title: "Sessions", description: "The synchronous counterpart: session.send / execute.", href: "/docs/concepts/sessions" },
  { label: "DEBUGGING", title: "Debugging", description: "Inspect delivery attempts + DLQ + tool-call logs.", href: "/docs/debugging" },
  { label: "ERRORS", title: "Error Reference", description: "Every error code the API can return, in one place.", href: "/docs/errors" },
]} />
