> ## 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](/concepts/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("monday_item_created", {
    connectionId: 12,
    listenColumns: ["status"],
  });

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

<Note>
  **The SDKs let you omit the connection; the CLI does not.** With neither `connection_id` nor `connection_name`, the server uses the application's default connection and answers `MISSING_DEFAULT_CONNECTION` when there is none. `engini triggers create` requires `--connection` even for types that need no connection at all — see the [CLI reference](/cli/commands#triggers).
</Note>

## Destinations

<CodeGroup>
  ```python Python theme={null}
  created = client.triggers.destinations.create(
      name="prod",
      url="https://acme.example/hooks/engini",
      max_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",
    maxFailures: 5,
  });
  saveSecret(created.signingSecret);    // 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>

## Verifying a delivery

`verify_webhook` / `verifyWebhook` **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.headers["X-Engini-Signature"],
              request.get_data(),        # RAW bytes - not the parsed JSON
              secret=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.header("X-Engini-Signature"),
        req.body,                       // RAW bytes - not the parsed JSON
        { secret: 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`; the default is the recommendation.

## Reading events

<CodeGroup>
  ```python Python theme={null}
  events = client.triggers.events.list(trigger.id)   # auto-paginates
  detail = client.triggers.events.get(events[0].id)  # full delivery attempt history
  client.triggers.events.replay(events[0].id)
  ```

  ```typescript TypeScript theme={null}
  const events = await client.triggers.events.list(trigger.id, { top: 50 });
  const detail = await client.triggers.events.get(events[0].id);
  await client.triggers.events.replay(events[0].id);
  ```
</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.

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.
