---
title: Event triggers
description: Fire a template automatically when a reported event matches — with rate limits, quiet hours, dedup, and dynamic per-recipient attachments.
---

An event trigger is the **only** "event happened, send an email" mechanism in Retidal. There is no
general-purpose "send this one email to this one address" API — for a one-off send to a specific person, use
a [campaign](/docs/email/campaigns) whose audience filter matches exactly one user.

Authentication for every endpoint on this page is the console session cookie — see
[Providers § Authentication](/docs/email/providers#authentication). Writes need EDITOR+.

<Danger title="The event pipeline needs identify, not just track">
  A trigger matches on the **identified user** behind an event. If your application only calls the
  [event-ingestion endpoint](/docs/sending-data/events) and never calls
  [`POST /api/v1/identify`](/docs/sending-data/identify) with that user's `email` trait, the trigger log will
  show every match skipped as `no_identified_user` (never identified) or `no_email` (identified, but no email
  trait) — and you will get no error anywhere else. Identify the user (with `traits.email` set) **before**
  the event you want to trigger on, not after.
</Danger>

## Create a trigger

<CodeGroup>
```text title="Endpoint"
POST https://retidal.com/api/projects/{projectId}/email/triggers
```
```json title="Request body"
{
  "name": "Welcome on sign-up",
  "eventName": "sign_up",
  "match": {
    "properties": { "plan": "pro" },
    "userFilter": { "hasEmail": true, "locale": "zh-CN" }
  },
  "templateId": "tpl_x",
  "providerId": "prov_x",
  "category": "transactional",
  "enabled": true,
  "maxSendsPerWindow": 1,
  "windowSeconds": 86400,
  "quietHoursStartHour": 22,
  "quietHoursEndHour": 8,
  "quietHoursMode": "defer",
  "dedupeWindowSeconds": 3600
}
```
</CodeGroup>

<ParamField name="name" type="string" required>
  1–120 characters.
</ParamField>
<ParamField name="eventName" type="string" required>
  1–200 characters. Must match the `eventName` your application reports to
  [`POST /api/v1/t`](/docs/sending-data/events) exactly.
</ParamField>
<ParamField name="templateId" type="string" required>
  Must belong to the same project.
</ParamField>
<ParamField name="providerId" type="string">
  Channel to send from. Nullable — omit to let [routing](/docs/email/providers#routing) pick one by category.
</ParamField>
<ParamField name="identityId" type="string">
  A specific [sending identity](/docs/email/providers) to send as, nullable.
</ParamField>
<ParamField name="category" type="string">
  `transactional` | `marketing`. Default `transactional`. Marketing triggers are blocked until
  [compliance settings](/docs/email/campaigns#compliance-settings) are configured.
</ParamField>
<ParamField name="enabled" type="boolean">
  Default `true`.
</ParamField>
<ParamField name="match" type="object">
  See below. `additionalProperties` is disallowed — an unrecognized key is a `400`, not a silent no-op.
</ParamField>

### match

`match` has exactly three possible keys:

| Key | Purpose |
| --- | --- |
| `properties` | Exact-match against the triggering event's own `properties` object. |
| `conditionTree` | A nested (2-level) `any`/`all` fact-rule tree for conditions `properties` alone can't express. Nullable. |
| `userFilter` | Audience/segment filter — same shape as a campaign's `audienceFilter` (`hasEmail`, `locale`, `paidStatus`, `valueTier`, `queueKey`, and more — see [Campaigns § audienceFilter](/docs/email/campaigns#audiencefilter) for the full field list). |

### Guardrails

| Field | Range | Default | Purpose |
| --- | --- | --- | --- |
| `maxSendsPerWindow` | 1–100,000, nullable | unset | Cap sends to the same user within `windowSeconds`. |
| `windowSeconds` | 1–31,536,000 | 86400 | Window the send cap above applies to. |
| `quietHoursStartHour` / `quietHoursEndHour` | 0–23, nullable | unset | Hour range (project-local) to hold sends. Set both together or neither. |
| `quietHoursMode` | `defer` \| `drop` | `defer` | `defer` sends once quiet hours end; `drop` discards the send entirely. |
| `dedupeWindowSeconds` | 0–31,536,000 | 3600 | Suppresses a second send for the same (user, trigger) pair inside this window. |

Guardrails are evaluated in this order — suppression list → rate cap → quiet hours → dedup — and the
trigger log's `skipReason` names whichever one stopped the send.

## Dynamic attachments

```json
"dynamicAttachments": [
  { "fromProperty": "invoice_url", "filename": "invoice.pdf" }
]
```

<ParamField name="fromProperty" type="string" required>
  1–120 characters. The property name on the **triggering event** that holds a URL — read at send time, not
  stored ahead of time.
</ParamField>
<ParamField name="filename" type="string">
  1–200 characters. Falls back to a name inferred from the URL if omitted.
</ParamField>

Up to **3** `dynamicAttachments` entries per trigger. This is how "send an invoice after payment" works end
to end: your application includes an `invoice_url` property on the `payment_succeeded` event it reports, the
trigger is configured with `dynamicAttachments: [{ fromProperty: "invoice_url" }]`, and — critically — that
URL's domain has to already be on the
[dynamic-attachment allowlist](/docs/email/templates#dynamic-attachment-domain-allowlist), or the fetch fails
closed and the attachment silently doesn't go out (the email itself still sends).

If `fromProperty` is missing from the event, or isn't a string, that one attachment is marked
`attachment_fetch_failed` on the sent message — the rest of the email still sends.

## Debugging why a trigger didn't fire

```
GET /api/projects/{projectId}/email/triggers/{id}/log
```

Returns the latest 50 evaluation records with `status`/`skipReason`. Work through these in order:

1. **No log entries at all** — the `eventName` doesn't match what's actually being reported, or the trigger
   is `enabled: false`, or the event was never reported (check with the console analytics/events query).
2. **`no_identified_user`** — the event's `userId` was never passed to `POST /api/v1/identify`.
3. **`no_email`** — identified, but the `traits.email` field was never set.
4. **`no_match`** — `properties`/`conditionTree`/`userFilter` didn't match this event or this user.
5. **`suppressed`** — the recipient is on the [suppression list](/docs/email/campaigns#suppressions) for a
   reason that blocks this category.
6. **`capped`** — hit `maxSendsPerWindow`.
7. **`quiet`** — inside the quiet-hours window (deferred or dropped per `quietHoursMode`).
8. **`deduped`** — inside `dedupeWindowSeconds` of a previous send to the same user.

If none of those explain it, check the channel itself: `GET /api/projects/{projectId}/email/providers` to confirm the `providerId`
isn't a `log`-driver channel (which "sends" but delivers nothing), and `GET /api/projects/{projectId}/email/messages?limit=20` for the
actual send status of any message that did get created.

## Next steps

<CardGroup cols={2}>
  <Card title="Templates" href="/docs/email/templates">
    Build the content and static attachments a trigger sends.
  </Card>
  <Card title="Campaigns" href="/docs/email/campaigns">
    Bulk sends, compliance settings, and the suppression list.
  </Card>
</CardGroup>
