> ## Documentation Index
> Fetch the complete documentation index at: https://docs.engini.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Triggers

> Create triggers, receive their events at your own endpoint, and verify the signature - in Python and TypeScript.

`client.triggers` covers the whole surface: the type catalog, instance lifecycle, destinations, the event log, and a live stream for local development. Concepts and the wire contract are in [Triggers](/developers/get-started/triggers).

## Create and enable

<CodeGroup>
  ```python Python theme={null}
  from engini import Engini

  client = Engini(api_key="eng_…")

  # Read the type first - which parameter blocks apply is per type.
  spec = client.triggers.types.get("monday_item_created")

  trigger = client.triggers.create(
      "monday_item_created",
      connection_id=12,
      listen_columns=["status"],
  )

  client.triggers.enable(trigger.id)   # created disabled; nothing fires until this
  ```

  ```typescript TypeScript theme={null}
  import { Engini } from "@engini/sdk";

  const client = new Engini({ apiKey: "eng_…" });

  // Read the type first - which parameter blocks apply is per type.
  const spec = await client.triggers.types.get("monday_item_created");

  const trigger = await client.triggers.create({
    triggerSlug: "monday_item_created",
    connectionId: 12,
    listenColumns: ["status"],
  });

  await client.triggers.enable(trigger.id);  // created disabled; nothing fires until this
  ```
</CodeGroup>

<Note>
  **The connection is optional for types that need none.** Omit both `connection_id` and `connection_name` for a connection-less type (e.g. `engini_schedule`, `http_webhook`) and the server supplies its own. For a type whose application does need one, pass it - the server does not guess. The CLI matches this from `0.23.0` - see the [CLI reference](/developers/cli/command-reference#triggers).
</Note>

## Destinations

<CodeGroup>
  ```python Python theme={null}
  created = client.triggers.destinations.create(
      name="prod",
      url="https://acme.example/hooks/engini",
      max_consecutive_failures=5,
  )
  save_secret(created.signing_secret)   # esec_… - shown once, never readable again

  client.triggers.destinations.delete("old", reassign_to="prod")
  ```

  ```typescript TypeScript theme={null}
  const created = await client.triggers.destinations.create({
    name: "prod",
    url: "https://acme.example/hooks/engini",
    maxConsecutiveFailures: 5,
  });
  saveSecret(created.signing_secret);   // esec_… - shown once, never readable again

  await client.triggers.destinations.delete("old", { reassignTo: "prod" });
  ```
</CodeGroup>

<Warning>
  **`destination.put()` returns one of two shapes.** Setting the account default *creates* a destination the first time — and that response carries the signing secret. Re-pointing an existing default returns the bare destination with no secret. Test for the secret's presence; do not assume either shape.

  ```python theme={null}
  result = client.triggers.destination.put(url="https://acme.example/hooks/engini")
  secret = getattr(result, "signing_secret", None)
  if secret:
      save_secret(secret)   # it was created - this is your only chance
  ```
</Warning>

Rotating a secret and bringing back an auto-disabled destination are both one call:

<CodeGroup>
  ```python Python theme={null}
  rotated = client.triggers.destinations.rotate_secret("prod")
  save_secret(rotated.signing_secret)   # the old secret stopped working just now

  client.triggers.destinations.update("prod", status="active")   # re-enable + reset the failure counter
  ```

  ```typescript TypeScript theme={null}
  const rotated = await client.triggers.destinations.rotateSecret("prod");
  saveSecret(rotated.signing_secret);   // the old secret stopped working just now

  await client.triggers.destinations.update("prod", { status: "active" });  // re-enable + reset the failure counter
  ```
</CodeGroup>

<Warning>
  **Rotation has no overlap window.** Deliveries signed with the old secret stop verifying the moment `rotate_secret` returns. Deploy the new secret to your receiver straight away, then replay anything that failed in between - see [Debug and replay a failed delivery](/developers/cookbook/debug-replay-failed-delivery#rotate-a-secret-without-losing-events).
</Warning>

## Sending a test event

`POST /v1/triggers/{triggerId}/test` pushes one synthetic, correctly-signed event through the real delivery path to the trigger's destination and returns what the destination answered. It is **API-only for now**: neither SDK (Python `0.24.0`, TypeScript `0.24.0`) nor the CLI (`0.24.0`) wraps it yet, so call it over HTTP.

```bash theme={null}
curl -s -X POST "https://api.engini.io/v1/triggers/$TRIGGER_ID/test" \
  -H "x-api-key: $ENGINI_API_KEY" -H "Content-Type: application/json" \
  -d '{"payload": {"name": "Test item"}}'
# -> {"delivered": true, "event_id": "te_test_…", "status_code": 200, "duration_ms": 84, ...}
```

* **A broken receiver is a successful test.** A `5xx` or unreachable destination still returns `200` from this endpoint, with `delivered: false` and the reason in `error`.
* **Nothing is stored.** A test event never appears in the event log or the stream, and never counts against deduplication. Its id starts `te_test_` and its body carries `"test": true` - both inside the signature, so a receiver can tell a test from real traffic.
* Omit the body to get a default payload. The call is limited to **10 per minute** per account, and answers `409` if the destination has auto-disabled.

## Verifying a delivery

`verify_webhook(payload, signature_header, secret)` / `verifyWebhook(payload, signatureHeader, secret)` **raises on any rejection** - it does not return a boolean. That is deliberate: an ignored `False` is a webhook endpoint that accepts forgeries, and a raise cannot be ignored by accident. On success it returns the parsed event body.

<CodeGroup>
  ```python Python theme={null}
  from engini import verify_webhook
  from engini.errors import EnginiWebhookSignatureError

  @app.post("/hooks/engini")
  def receive(request):
      try:
          event = verify_webhook(
              request.get_data(),                    # RAW bytes - not the parsed JSON
              request.headers.get("X-Engini-Signature"),
              SIGNING_SECRET,                        # the whole esec_… string
          )
      except EnginiWebhookSignatureError:
          return "", 400

      if already_seen(request.headers["X-Engini-Event-Id"]):
          return "", 200                 # dedupe on the event id, never the signature
      handle(event)
      return "", 200
  ```

  ```typescript TypeScript theme={null}
  import { verifyWebhook, EnginiWebhookSignatureError } from "@engini/sdk";

  app.post("/hooks/engini", express.raw({ type: "*/*" }), (req, res) => {
    let event;
    try {
      event = verifyWebhook(
        req.body,                       // RAW bytes - not the parsed JSON
        req.header("X-Engini-Signature"),
        SIGNING_SECRET,                 // the whole esec_… string
      );
    } catch (err) {
      if (err instanceof EnginiWebhookSignatureError) return res.sendStatus(400);
      throw err;
    }

    if (alreadySeen(req.header("X-Engini-Event-Id"))) return res.sendStatus(200);
    handle(event);                      // dedupe on the event id, never the signature
    res.sendStatus(200);
  });
  ```
</CodeGroup>

Three ways to get this wrong, all of which produce a MAC that never matches:

* Passing the secret **without** its `esec_` prefix. The key is the whole string.
* Passing a re-serialised body. Frameworks that parse JSON for you must be configured to hand over the raw bytes.
* Widening the replay window. It defaults to **300 seconds** either side of `t` (`tolerance=` / `{ tolerance }` to change it); the default is the recommendation.

A full receiver - raw body, verification, deduplication, fast acknowledgement - is in the recipe [Receive trigger events on a webhook](/developers/cookbook/receive-trigger-events-webhook).

## Reading events

<CodeGroup>
  ```python Python theme={null}
  events = client.triggers.events.list(trigger.id)   # auto-paginates, newest first
  failed = [e for e in events if e.delivery == "dead_lettered"]  # out of retries
  detail = client.triggers.events.get(failed[0].id)  # full delivery attempt history
  client.triggers.events.replay(failed[0].id)        # queued, not delivered inline
  ```

  ```typescript TypeScript theme={null}
  const events = await client.triggers.events.list(trigger.id, { top: 50 });
  const failed = events.filter((e) => e.delivery === "dead_lettered");  // out of retries
  const detail = await client.triggers.events.get(failed[0].id!);
  await client.triggers.events.replay(failed[0].id!);  // queued, not delivered inline
  ```
</CodeGroup>

<Note>
  **`events.list` is not identical in the two languages.** Python takes no `top` and always auto-paginates to the full set. TypeScript takes `top` and pages. Code that assumes one shape will not port directly.
</Note>

## Streaming, for local development

`subscribe` is a **second read path over the same events**, not a delivery mechanism. Production delivery is a destination plus signature verification. Use the stream while you are building, to watch events arrive without exposing a public URL.

<CodeGroup>
  ```python Python theme={null}
  for event in client.triggers.subscribe(trigger_id=trigger.id):
      print(event["id"], event["trigger_slug"])
      # event["delivery"] is always "pending" here - see the note below
  ```

  ```typescript TypeScript theme={null}
  for await (const event of client.triggers.subscribe({ triggerId: trigger.id })) {
    console.log(event.id, event.trigger_slug);
    // event.delivery is always "pending" here - see the note below
  }
  ```
</CodeGroup>

<Warning>
  **`delivery` on the stream is always the literal `"pending"`.** The stream reports dispatch, before anything has tried to POST. Real delivery state comes only from `events.get(id)`.
</Warning>

Every stream ends — the gateway caps one connection at **one hour** of wall-clock time, an absolute cap the 20-second heartbeat does not defer, and the cut arrives with no goodbye frame. Both SDKs resume automatically with `?since=<last event id>`, so the seam neither duplicates nor skips. Pass `auto_resume=False` / `autoResume: false` if you would rather handle it yourself.

**TypeScript only, since `@engini/sdk` 0.23.0: `subscribe({ ..., signal })` takes an `AbortSignal`.**

```typescript TypeScript theme={null}
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);

for await (const event of client.triggers.subscribe({ triggerId: trigger.id, signal: controller.signal })) {
  console.log(event.id, event.trigger_slug);
}
```

Aborting stops the subscription immediately, including mid-read on an idle connection - the generator returns normally rather than throwing, and the in-flight connection is aborted (not merely abandoned), so no lingering socket keeps the process alive after you stop iterating. The CLI's `engini triggers listen` wires its own `Ctrl-C` handling to this same signal (see [the machine contract](/developers/cli/machine-contract#trigger-commands)). There is no Python equivalent - stop a Python stream by breaking out of the `for` loop.

Two endings stop hard rather than resuming:

| Ending | Raises |
| - | - |
| The stream's credentials were revoked mid-stream | `EnginiAuthError` |
| The resume cursor has been pruned by retention (`410 SINCE_PRUNED`) | `EnginiTriggerGapError` |

A gap is surfaced, never papered over with a silent restart from "now" — the whole point of the cursor is that you get to decide what to do about missed events.

## Typed errors

| Error | When |
| - | - |
| `EnginiWebhookSignatureError` | Any signature rejection: missing, malformed, mismatched, or outside the replay window |
| `EnginiTriggerGapError` | A resume cursor that retention has pruned (`410 SINCE_PRUNED`) |
| `EnginiTriggerSubscribeFailedError` | The provider refused the subscription on create or enable (`502`) |
| `EnginiTriggerStreamLimitError` | The account already holds the maximum concurrent streams (`429`) |

Each refines the status-mapped error it sits under, so existing `catch` blocks keep working.

## Runnable examples

The Python SDK ships a worked set under [`python/examples/triggers/`](https://github.com/engini/engini-sdk/tree/main/python/examples/triggers): the catalog and create flow, destinations, a webhook receiver that verifies signatures, subscribing to the stream, and reading events and recovering from a failure. There is no TypeScript equivalent yet; the flows translate directly using the method names above. For task-shaped walkthroughs in every language, see the Cookbook's **React to events** recipes, starting with [Receive trigger events on a webhook](/developers/cookbook/receive-trigger-events-webhook).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.