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

# Receive trigger events on a webhook

> Register a destination, create and enable a trigger, prove the wiring with a test event, and run a receiver that verifies every signature.

<Info>**Uses:** Python · TypeScript · CLI · REST API</Info>

A trigger watches a connected app and POSTs each event it captures to an HTTPS endpoint you own - a **destination**. This recipe goes from nothing to a receiver that accepts only genuine Engini deliveries.

The sequence is **destination → trigger → enable → test → receive**.

<Note>
  cURL samples assume `BASE=https://api.engini.io/v1` and `AUTH="x-api-key: $ENGINI_API_KEY"` - the setup from the [REST walkthrough](/examples/rest-walkthrough). The trigger type and connection (`monday_item_created`, connection `12`) are placeholders - list what your connections can fire on with `GET /v1/triggers/types`.
</Note>

## 1. Register a destination

The response carries the **signing secret**, and this is the only time you will see it. Store it in your secret manager before doing anything else.

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

  client = Engini(api_key="eng_…")

  created = client.triggers.destinations.create(
      "prod", "https://acme.example/hooks/engini", is_default=True
  )
  save_secret(created.signing_secret)   # esec_… - shown once
  destination_id = created.destination.id
  ```

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

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

  const created = await client.triggers.destinations.create({
    name: "prod",
    url: "https://acme.example/hooks/engini",
    isDefault: true,
  });
  saveSecret(created.signing_secret);   // esec_… - shown once
  const destinationId = created.destination.id;
  ```

  ```bash CLI theme={null}
  engini triggers destinations add prod https://acme.example/hooks/engini --default --json
  # -> {"id": 7, "name": "prod", ..., "secret": "esec_…", "note": "Shown once ..."}
  ```

  ```bash cURL theme={null}
  curl -s -X POST "$BASE/triggers/destinations" -H "$AUTH" -H "Content-Type: application/json" \
    -d '{"name": "prod", "url": "https://acme.example/hooks/engini", "is_default": true}' \
    | jq '{id: .destination.id, signing_secret}'
  ```
</CodeGroup>

<Warning>
  **Keep the whole `esec_…` string.** The prefix is part of the HMAC key - a receiver that strips it computes a signature that never matches. Lost the secret? Rotate it (`rotate-secret`); nothing reads it back.
</Warning>

Because this destination is the account default, every trigger that names no destination delivers here. Pass `destination_id` when you create a trigger to send it somewhere else.

## 2. Create the trigger and enable it

Read the type first: whether it takes a `schedule`, `listen_columns`, or neither depends on the type. Then create it - and **enable it**, because a trigger is created disabled.

<CodeGroup>
  ```python Python theme={null}
  spec = client.triggers.types.get("monday_item_created")   # config_schema, delivery, payload_schema

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

  ```typescript TypeScript theme={null}
  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);
  ```

  ```bash CLI theme={null}
  engini triggers types get monday_item_created
  engini triggers create monday_item_created --connection 12 --listen status --json   # -> {"id": "ti_…", ...}
  engini triggers enable ti_DG9q7IlQO0MeghC4IEzP19
  ```

  ```bash cURL theme={null}
  TRIGGER_ID=$(curl -s -X POST "$BASE/triggers" -H "$AUTH" -H "Content-Type: application/json" \
    -d '{"trigger_slug": "monday_item_created", "connection_id": 12, "listen_columns": ["status"]}' \
    | jq -r '.id')
  curl -s -X POST "$BASE/triggers/$TRIGGER_ID/enable" -H "$AUTH" | jq '{id, status, status_reason}'
  ```
</CodeGroup>

`status` should now read `enabled`. If enable fails with `502 TRIGGER_SUBSCRIBE_FAILED`, the provider refused the subscription - `GET` the trigger and it reads `errored` / `subscribe_failed`.

## 3. Write the receiver

Three rules, and each one is a bug if you skip it:

1. **Verify against the raw body bytes.** Parsing the JSON and re-serialising it changes the bytes the signature covers.
2. **Deduplicate on `X-Engini-Event-Id`.** Delivery is at-least-once, and every retry is signed afresh, so the signature is different each time while the event id stays the same.
3. **Answer within 10 seconds.** A slower attempt counts as failed and is retried. Acknowledge first, then do the work.

<CodeGroup>
  ```python Python (Flask) theme={null}
  import os
  from flask import Flask, request
  from engini import verify_webhook
  from engini.errors import EnginiWebhookSignatureError

  app = Flask(__name__)
  SECRET = os.environ["ENGINI_SIGNING_SECRET"]   # the whole esec_… string

  @app.post("/hooks/engini")
  def receive():
      try:
          event = verify_webhook(
              request.get_data(),                        # raw bytes
              request.headers.get("X-Engini-Signature"),
              SECRET,
          )
      except EnginiWebhookSignatureError:
          return "", 400      # a 4xx stops retries - right for a forgery

      event_id = request.headers["X-Engini-Event-Id"]
      if not mark_seen(event_id):                     # e.g. INSERT ... ON CONFLICT DO NOTHING
          return "", 200      # a retry of something you already have

      if event.get("test"):
          return "", 200      # a test event from step 4 - nothing to process

      enqueue(event)          # hand off; do the slow work elsewhere
      return "", 200
  ```

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

  const app = express();
  const SECRET = process.env.ENGINI_SIGNING_SECRET!;   // the whole esec_… string

  // express.raw, not express.json: verification needs the exact bytes.
  app.post("/hooks/engini", express.raw({ type: "*/*" }), async (req, res) => {
    let event;
    try {
      event = verifyWebhook(req.body, req.header("X-Engini-Signature"), SECRET);
    } catch (err) {
      if (err instanceof EnginiWebhookSignatureError) return res.sendStatus(400);
      throw err;
    }

    const eventId = req.header("X-Engini-Event-Id")!;
    if (!(await markSeen(eventId))) return res.sendStatus(200);   // already processed

    if ((event as { test?: boolean }).test) return res.sendStatus(200);

    await enqueue(event);   // hand off; do the slow work elsewhere
    res.sendStatus(200);
  });

  app.listen(8080);
  ```
</CodeGroup>

`verify_webhook` / `verifyWebhook` raises on a missing, malformed or wrong signature, or a timestamp more than 300 seconds from now, and returns the parsed event on success. Writing it by hand in another language? The scheme is in [Verifying a delivery](/concepts/triggers#verifying-a-delivery).

<Tip>
  **Pick the status code on purpose.** `408`, `429` and `5xx` are retried (1 min, 5 min, 15 min, 1 h, 6 h). Any other `4xx` - and any redirect - dead-letters that event at once, and five dead-letters in a row auto-disable the destination. So a receiver deployed with the wrong secret switches its own deliveries off within five events. Return `503` if your own backend is down and you want Engini to try again later.
</Tip>

## 4. Prove the wiring with a test event

You don't need to wait for someone to change a monday item. `POST /v1/triggers/{id}/test` signs a synthetic event with the destination's real secret, sends it straight away, and tells you what your receiver answered.

```bash theme={null}
curl -s -X POST "$BASE/triggers/$TRIGGER_ID/test" -H "$AUTH" \
  | jq '{delivered, status_code, error, event_id}'
# -> {"delivered": true, "status_code": 200, "error": null, "event_id": "te_test_…"}
```

<Note>
  The test endpoint is **API-only** for now - neither SDK nor the CLI wraps it yet. It is limited to 10 calls a minute per account.
</Note>

| You see | It means |
| - | - |
| `delivered: true` | Your receiver verified the signature and answered `2xx`. Done. |
| `delivered: false`, `status_code: 400` | Your receiver rejected the signature. Check you are verifying against the raw body with the full `esec_…` secret. |
| `delivered: false`, `status_code: null` | Engini could not reach the URL, or it took more than 10 seconds - `error` says which. |
| HTTP `409` | The destination has auto-disabled. Re-enable it first ([Debug and replay a failed delivery](/examples/trigger-replay)). |

A test event is never stored: it won't show up in the event log or count against deduplication. Its id starts `te_test_` and its body has `"test": true`, which is why the receivers above can skip it.

## Next

* Something failed and you want it back: [Debug and replay a failed delivery](/examples/trigger-replay).
* No public URL yet: [Develop a trigger locally](/examples/trigger-local-dev) streams events to your laptop and can forward them, signed, to `localhost`.
* The full wire contract: [Triggers](/concepts/triggers).


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