> ## 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

> Fire on a change in a connected app - what a trigger is, how its events reach you, how retries work, and how to verify they came from Engini.

A **trigger** watches a connected application and captures an event when something happens there — a row is added, an email arrives, a record changes. Engini then delivers that event to an HTTPS endpoint you own.

Triggers are entities in their own right, addressed by a `ti_…` id. They are not workflow steps, and they are not connections.

## The three pieces

| Piece | What it is |
| - | - |
| **Trigger type** | A catalog entry — what a connector *can* fire on, identified by a slug (e.g. `monday_item_created`). Read-only. |
| **Trigger instance** | Your configured copy of a type, against one of your connections. This is the `ti_…` id. |
| **Destination** | A named HTTPS endpoint events are POSTed to. An account has a default; instances can override it. |

```
trigger type  ──create──▶  trigger instance (ti_…)  ──event──▶  destination (your URL)
   catalog                    yours, enable/disable                signed POST
```

## Lifecycle

<Steps>
  <Step title="Pick a type">
    `GET /v1/triggers/types` lists what your connections can fire on. `GET /v1/triggers/types/{slug}` returns its `config_schema`, `payload_schema`, and which parameter blocks apply — read this before creating, because whether a type takes a `schedule`, `listen_columns`, or neither is per type.
  </Step>

  <Step title="Create it">
    `POST /v1/triggers`. You get back a `ti_…` id.
  </Step>

  <Step title="Enable it">
    **A trigger is created disabled.** Nothing is captured until you `POST /v1/triggers/{id}/enable`. This catches people out — create is not start.
  </Step>

  <Step title="Receive events">
    Engini POSTs each captured event to the destination, signed. Verify the signature, then act on it. The event log at `GET /v1/triggers/{id}/events` is the record of what was captured and what happened to each delivery.
  </Step>
</Steps>

<Warning>
  **`enable` also clears a block, and blocked is not the same as disabled.**

  Engini blocks a trigger by itself when something is wrong — too many consecutive failures, a deleted connection, a provider that refused the subscription. A blocked trigger reports `status: "errored"` with a `status_reason`, and stays silent until an `enable` clears the block.

  The one case `enable` will not fix: an account **over its activity limit** is refused with `409` rather than unblocked. The block is the limit doing its job; get usage back under it first.
</Warning>

## How a trigger fires

Every trigger type declares a `delivery`, and it decides what you configure:

| `delivery` | What happens | What you configure |
| - | - | - |
| `realtime` | The provider pushes to Engini as it happens | `listen_columns` on types that support it |
| `polling` | Engini checks the app on a schedule | `schedule` (or the `poll_interval_minutes` shorthand) |
| `manual_webhook` | You POST to a URL Engini gives you | Nothing |

<Note>
  **Polling intervals have a floor, and it is your plan's.** Every polling response echoes a `schedule.effective` block — the schedule *as applied*, with `min_interval_minutes` resolved from your own account's subscription. Compare it with what you sent to see whether you were clamped. It is echoed on every response, not only when it differs, so you never have to infer the floor from a field's absence.
</Note>

<Note>
  **`subscription_count` is usually not 1.** A provider subscription covers a single watched column, so three `listen_columns` create three subscriptions. Providers generally meter them, so this is a number worth reading before you widen a trigger.
</Note>

## Destinations and the signing secret

A destination is a name plus an HTTPS URL. Create one with `POST /v1/triggers/destinations`, or set the account default with `PUT /v1/triggers/destination`.

<Warning>
  **The signing secret is shown exactly once.** It comes back from the three calls that mint one — creating a destination, rotating its secret, and `PUT /v1/triggers/destination` *when that call creates the default*. No read path ever returns it again. Store it when you see it; if you lose it, rotate.

  The prefix is **`esec_`**.
</Warning>

Engini disables a destination automatically (`status: "auto_disabled"`) after too many consecutive **dead-lettered events** - events that used up every retry, or were refused with a non-retryable status - set by `max_consecutive_failures`, default 5. Events keep being captured; they just stop being delivered. Re-enable it (`PATCH /v1/triggers/destinations/{name}` with `status: "active"`) and the failure counter resets.

<Warning>
  **Re-enabling resumes delivery on its own for recent events.** Events captured in the last 24 hours, and retries that were paused while the destination was off, go out within about a minute of re-enabling - no action needed. Only events older than 24 hours that were never attempted, and dead-lettered events, need a replay. See [Debug and replay a failed delivery](/developers/cookbook/debug-replay-failed-delivery).
</Warning>

Rotating a secret (`POST /v1/triggers/destinations/{name}/rotate-secret`) has **no overlap window**: the old secret stops verifying the moment the call returns. Update your receiver straight away and replay whatever failed in between.

Deleting a destination that triggers still point at is a `409`. Name a replacement to move them across in the same call — the API query parameter is **`reassign_to`** (the CLI flag is spelled `--reassign`).

## Verifying a delivery

Every POST carries these headers:

| Header | Contents |
| - | - |
| `X-Engini-Signature` | `t=<unix-seconds>,v1=<lowercase hex>` |
| `X-Engini-Event-Id` | The event id — **this is your deduplication key** |
| `X-Engini-Trigger-Id` | The `ti_…` that fired |
| `X-Engini-Trigger-Slug` | Its trigger type |
| `X-Engini-Timestamp` | Same value as `t` |
| `X-Engini-Attempt` | Which delivery attempt this is |

The signature is:

```
v1 = HMAC-SHA256(key = the whole "esec_…" secret as UTF-8 bytes,
                 message = "<t>" + "." + <the raw request body>)
```

Three things break verification if you get them wrong:

* **Use the whole secret**, `esec_` prefix included. Stripping the prefix produces a MAC that never matches.
* **Use the raw body bytes.** Parsing the JSON and re-serialising it changes whitespace and key order, and the MAC is over the exact bytes.
* **Reject anything outside a 300-second window** on `t`, in both directions.

<Warning>
  **Deduplicate on `X-Engini-Event-Id`, never on the signature.** `t` is regenerated for every attempt, so a retry of the same event arrives with a completely different signature. Treating the signature as an idempotency key means processing every retry as a new event.
</Warning>

Both SDKs ship a `verify_webhook` / `verifyWebhook` that does all of this - see [Triggers in the SDKs](/developers/sdk/triggers). If you verify by hand, compare the MACs in constant time:

```python theme={null}
import hashlib, hmac, time

def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    # Minimal sketch: assumes a well-formed header. Use the SDK helper above in production.
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    t, v1 = parts["t"], parts["v1"]
    if abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)
```

### The delivery body

```json theme={null}
{
  "id": "te_…",
  "trigger_id": "ti_…",
  "trigger_slug": "monday_item_created",
  "occurred_at": "2026-09-24T09:15:02.1234567Z",
  "attempt": 1,
  "payload_truncated": false,
  "payload_hash": "…",
  "payload": { "…": "the record, exactly as captured" }
}
```

A test event (see below) also carries `"test": true`, written only when it is true - a real delivery never has the key at all. When a record exceeds the payload cap, `payload_truncated` is `true` and `payload` is a truncation envelope rather than the record.

### Retries

Engini treats any **2xx** as delivered. Anything else is retried or given up on:

| Your receiver answers | Engini |
| - | - |
| `2xx` | Delivered - done |
| `408`, `429`, `5xx`, a timeout (10 s), or a connection error | Retries |
| Any other `4xx`, or a `3xx` | Gives up at once and dead-letters the event - redirects are not followed |

An event gets **6 attempts**: the first straight away, then retries **1 min, 5 min, 15 min, 1 h and 6 h** after the previous one - a little over 7 hours in all. An event that exhausts them is **dead-lettered**. It stays stored (events are kept 30 days) and you can still replay it.

<Tip>
  **Answer fast, and pick your status code on purpose.** Acknowledge with `200` as soon as you have verified and queued the event, and do the slow work afterwards - an attempt that takes longer than 10 seconds counts as failed. Return a `4xx` only for a request you will never accept (a bad signature, for example): it dead-letters the event on the spot, and five dead-letters in a row auto-disable the destination. If your own backend is briefly down, answer `503` so Engini retries.
</Tip>

## Reading events

Two read paths, and they answer different questions.

| | `GET /v1/triggers/{id}/events` | `GET /v1/triggers/subscribe` |
| - | - | - |
| What it is | The captured event **log** | A live `text/event-stream` |
| Delivery state | Real - `pending`, `delivered`, `failed`, `dead_lettered` | Always the literal `"pending"` |
| Use it for | Auditing, debugging a failed delivery, replay | Watching events arrive while you develop |

<Warning>
  **The stream's `delivery` field is always `"pending"`.** The stream reports dispatch, not delivery — an event is put on the stream before anyone has tried to POST it. To know whether a delivery actually succeeded, read `GET /v1/triggers/events/{eventId}`, which carries the full attempt history.
</Warning>

<Note>
  **A stream is not a delivery mechanism, and every stream ends.** The gateway caps one connection at **one hour** of wall-clock time. It is an absolute cap, not an idle timeout — the 20-second heartbeat does not defer it — and the cut arrives as an abrupt truncation with no goodbye frame.

  Resume by reconnecting with `?since=<last event id you saw>`, which replays strictly *after* that event, so the seam neither duplicates nor skips. Both SDKs do this for you. If the cursor has been pruned by retention, the resume answers `410 SINCE_PRUNED` — a real gap, surfaced rather than papered over with a silent restart from "now".
</Note>

`POST /v1/triggers/events/{eventId}/replay` re-delivers a captured event. It answers `202`: the attempt is **queued**, not sent inline, so read the event again a few seconds later to see the result. The attempt number carries on from the last one, so a replay gets whatever retries the event had left - none, if it had already used all six. If the destination has auto-disabled, replay answers `409` until you enable it again. Walkthrough: [Debug and replay a failed delivery](/developers/cookbook/debug-replay-failed-delivery).

## Testing a destination

`POST /v1/triggers/{triggerId}/test` sends one synthetic event through the real delivery path - signed with the destination's secret, same headers - and returns what your endpoint answered, synchronously:

```json theme={null}
{ "delivered": false, "event_id": "te_test_…", "trigger_id": "ti_…", "status_code": 401, "duration_ms": 63, "error": "HTTP 401: invalid signature", "destination": { "name": "prod", "…": "…" } }
```

* **A broken receiver is a successful test.** The call returns `200` whatever your endpoint did; `delivered` is `true` only on a `2xx`.
* **Nothing is stored.** The test event never appears in the log or on the stream, and never counts against deduplication. Its id is prefixed `te_test_` and its body carries `"test": true`, both signed.
* Optional body `{"payload": {...}}`; omit it for a default. Limited to 10 calls a minute per account; `409` if the destination has auto-disabled.

## Response shapes

<Note>
  **The list envelope is camelCase; the items inside it are snake\_case.** A paginated `/v1` response looks like `{ "items": [...], "totalCount": 42, "offset": 0, "top": 100 }`, and each entry inside `items` uses `snake_case` members (`trigger_slug`, `status_reason`, `last_event_at`). The Python SDK hides the seam; TypeScript and raw HTTP see both conventions in one response.
</Note>

## Known limits

* **`GET /v1/triggers?include=workflow` now paginates correctly at the wire level.** `offset`/`top` are split across the API and designer-built halves, so a sequential walk over the raw endpoint visits every row exactly once, with no duplicates or gaps. Both SDKs' `list(include=...)` still fetch a single page rather than walking the full set - pass `top` explicitly if you need more than the default page. The CLI's `--include workflow` also requests a single page; see [the CLI reference](/developers/cli/command-reference#triggers).

## Recipes

* [Receive trigger events on a webhook](/developers/cookbook/receive-trigger-events-webhook) - end to end, with a verifying receiver and a test event.
* [Debug and replay a failed delivery](/developers/cookbook/debug-replay-failed-delivery) - attempts, re-enabling, replay, and safe secret rotation.
* [Develop a trigger locally](/developers/cookbook/develop-trigger-locally) - the live stream and signed forwarding to `localhost`.


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