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

# Triggers and webhooks

> Signed notices when something happens in the memory. Niadra notifies; your system acts.

Niadra tells your systems when something happens in the memory, through signed webhooks. There are two kinds. A **webhook subscription** delivers every occurrence of an event type, such as `action.recorded`. A **trigger** is a rule of yours over what the memory knows, with parameters, a window and deduplication per subject, such as "a company promise one day past due". In both cases Niadra notifies by webhook, and your agent or your system acts. A trigger never calls a system of record, never opens a ticket, never messages the end customer and never branches.

## Outgoing event types

| Type                                    | When it is sent                                                                            |
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
| `memory.updated`                        | The derived memory of a profile changed                                                    |
| `identity.merged` / `identity.unmerged` | Two profiles were joined, or split apart                                                   |
| `identity.suggestion`                   | A possible link between handles is waiting for review                                      |
| `open_item.due` / `open_item.closed`    | An open item reached its due date, or was closed by an action                              |
| `action.recorded`                       | An agent recorded what it did in a system of record                                        |
| `trait.added` / `trait.expired`         | A pattern appeared on a profile, or stopped holding                                        |
| `trigger.fired` / `trigger.retracted`   | One of your rules fired, or a firing was withdrawn after an unmerge separated its evidence |
| `export.completed` / `export.failed`    | A continuous export run finished                                                           |
| `erasure.completed`                     | An erasure finished, with the erased ids and the export runs that already contained them   |
| `usage.threshold`                       | Usage crossed a threshold you set                                                          |

Endpoints, subscriptions and trigger rules are versioned configuration in the control API, changed through diffs a person approves. Every endpoint belongs to a source with an audience class, and a rule is only accepted, and only fires, if its condition and payload are readable by that source. `trait.present(overdue_invoices)` never reaches a marketing system.

## The trigger catalog

| Condition                                 | Fires when                                                                     |
| ----------------------------------------- | ------------------------------------------------------------------------------ |
| `promise.overdue(days)`                   | A company promise is past due by the given days, with no action that closes it |
| `open_item.due(lead_time)`                | An open item is about to reach its due date                                    |
| `trait.present(name, filters)`            | A profile has a given pattern                                                  |
| `sentiment.drop`                          | Sentiment across recent sessions dropped below the threshold                   |
| `identity.ambiguous`                      | A handle started connecting too many profiles, like a shared store tablet      |
| `fact.contradicted_by_system_event`       | A declared action was not confirmed by the system of record within its window  |
| `recontact(72h)`                          | The customer came back within 72 hours on the same category                    |
| `context_use.repetition_rate(source) > X` | A source's repetition rate crossed X within the window                         |
| `source.silent(minutes)`                  | A source stopped sending events                                                |

New conditions come through the product catalog, never through free text. A rule fires at most once per subject per window (24 hours by default), with a ceiling of 100 firings per minute per space and 50 rules per space. Event conditions are evaluated right after the memory is updated; time conditions every 5 minutes. A late firing is marked `late` or suppressed, as the rule says.

<Tip>
  Before turning a rule on, run it dry with [`POST /v1/triggers/dry-run`](/en/api/triggers-dry-run): the draft names `rule_id`, `condition`, `params`, the target `endpoint_id`, `window_hours` (24 by default, up to 90 days) and `late_policy` (`deliver_marked` or `suppress`). The answer, over the last 30 days, says how many firings there would have been (`would_fire`), with a sample, and `target_problem` when the endpoint's source may not read the payload. Adjust the parameters before anyone gets paged. It takes the `integration` role or an `admin` key.
</Tip>

Example: "notify the CRM one day after a missed technician visit". When Marina's rescheduled visit passes its due date by one day with no action that closes it, Niadra sends `trigger.fired` to the CRM endpoint, and your CRM agent reschedules the visit.

## What a delivery looks like

Deliveries follow the open **Standard Webhooks** format.

```json theme={null}
{
  "id": "msg_01J8ZV",
  "type": "trigger.fired",
  "created_at": "2026-09-23T15:05:00Z",
  "tenant": "acme",
  "subject": { "pseudonym": "psn_7f3a", "kind": "person", "system_id": "48213" },
  "data": {
    "rule_id": "rule_overdue_visit",
    "rule_version": 3,
    "condition": "promise.overdue",
    "evidence": ["oi_01J8ZK"],
    "as_of": "2026-09-23T15:04:58Z"
  },
  "links": { "open": "/v1/history/items/oi_01J8ZK" }
}
```

The body carries ids, the rule and its version, state and pointers to evidence. It **never** carries conversation content. The subject may carry your own `system_id` for the customer, never a phone, an e-mail or a document number. `links.open` is the API route that opens the item, and it only works with a credential of your space that has the scope for it.

Three headers come with every delivery:

* `webhook-id`: unique per message; the same on every retry. Use it to deduplicate.
* `webhook-timestamp`: Unix seconds. Reject anything more than 5 minutes away from your clock.
* `webhook-signature`: `v1,` followed by the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`. During a secret rotation, two signatures separated by a space are valid at once.

## Verify the signature

The secret is written once, through the Console or [`PUT /v1/secrets/webhook/{id}`](/en/api/secrets), and starts with `whsec_`; the key is the base64 part after the prefix. Verify on the raw body, before parsing it.

<CodeGroup>
  ```python Python theme={null}
  import base64
  import hashlib
  import hmac
  import time


  def verify(secret: str, headers: dict[str, str], body: bytes) -> bool:
      msg_id = headers["webhook-id"]
      timestamp = headers["webhook-timestamp"]
      if abs(time.time() - int(timestamp)) > 300:
          return False
      key = base64.b64decode(secret.removeprefix("whsec_"))
      signed = f"{msg_id}.{timestamp}.".encode() + body
      expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
      for candidate in headers["webhook-signature"].split(" "):
          version, _, signature = candidate.partition(",")
          if version == "v1" and hmac.compare_digest(signature, expected):
              return True
      return False
  ```

  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  export function verify(secret: string, headers: Record<string, string>, body: string): boolean {
    const id = headers["webhook-id"];
    const timestamp = headers["webhook-timestamp"];
    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
    const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
    const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64");
    return headers["webhook-signature"].split(" ").some((candidate) => {
      const [version, signature] = candidate.split(",");
      if (version !== "v1" || !signature || signature.length !== expected.length) return false;
      return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    });
  }
  ```
</CodeGroup>

## Delivery guarantees

* **At least once.** A message may arrive more than once; deduplicate by `webhook-id`.
* **No ordering.** Messages may arrive out of order; every event carries `as_of` and a version so you can order them.
* **Retries** with exponential backoff for up to 24 hours. After that the delivery goes to the dead-letter queue, visible in the Console and kept for 7 days.
* **An endpoint that fails continuously for 24 hours is disabled**, and your space is notified.
* **Controlled egress.** Every outgoing call goes through an egress proxy with an allow list per space. Private addresses, cloud metadata and loopback are blocked, DNS is resolved and pinned on each attempt, and only HTTPS goes out.

Answer with any 2xx quickly and do the work asynchronously. A delivery is `pending`, `delivered`, `failing` while it retries, or `dead`. These routes take a Console person with the `integration` role (firings also `analysis`) or a key with the `admin` scope. To inspect deliveries use [Webhook deliveries](/en/api/webhook-deliveries); to send a dead delivery again, with the same `webhook-id`, use [Redeliver](/en/api/webhook-redeliver). Firings, with the rule, the version and the evidence, are listed at [Trigger firings](/en/api/trigger-firings).

## Next steps

<CardGroup cols={2}>
  <Card title="Patterns" href="/en/concepts/patterns">
    the signals `trait.present` can watch.
  </Card>

  <Card title="Webhooks from your systems" href="/en/guides/system-webhooks">
    the other direction, events coming in.
  </Card>

  <Card title="Context use" href="/en/concepts/context-use">
    the repetition rate behind `context_use.repetition_rate`.
  </Card>

  <Card title="Receipts and audit" href="/en/concepts/receipts">
    receipts delivered to your SIEM through the same mechanism.
  </Card>
</CardGroup>
