Skip to content
MeridFlow AiFlow v8.x • self-hosted

Triggers

Triggers & events covers the conceptual model in prose, condition operators, condition_logic, action_type, context_template rendering, cooldown_seconds, with no endpoint examples. Read that page first if you haven't. This page assumes it and is the full CRUD reference: creating, listing, updating, deleting, and dry-run testing a Trigger against real endpoints.

Endpoint family

POST   /api/v1/agents/{agent_id}/triggers
GET    /api/v1/agents/{agent_id}/triggers
GET    /api/v1/agents/{agent_id}/triggers/{trigger_id}
PATCH  /api/v1/agents/{agent_id}/triggers/{trigger_id}
DELETE /api/v1/agents/{agent_id}/triggers/{trigger_id}
POST   /api/v1/agents/{agent_id}/triggers/{trigger_id}/test

Authentication and roles

Every endpoint here takes an admin session token (see Authentication), not an API key. Role requirements split by whether the call changes anything:

Endpoint Required role
GET (list, detail) Owner, Admin, Editor, or Viewer
POST (create) Owner, Admin, or Editor
PATCH (update) Owner, Admin, or Editor
DELETE Owner, Admin, or Editor
POST .../test Owner, Admin, or Editor

A Viewer can read Trigger configuration but can't create, edit, delete, or even dry-run one. The /test endpoint is held to the same role requirement as the mutating endpoints rather than treated as read-only, because it still renders context_template and resolves a real phone number from whatever payload you give it.

Every create, update, and delete also writes an audit log entry (resource_type: "trigger").

Create a Trigger

Capped by your licence

This deployment's licence sets a triggers limit. Creating one more past that cap returns 402 rather than succeeding; see Licence limits.

POST /api/v1/agents/{agent_id}/triggers

Field reference

Field Type Required Default Description
name string Yes A label, shown in the admin dashboard and in audit log entries.
enabled boolean No true A disabled Trigger is never evaluated against incoming events.
event_type_filter string or null No null (matches every event_type) Only evaluate this Trigger for events posted with this exact event_type.
conditions list of condition objects No [] (always matches) Checked against the posted payload under condition_logic. See the exact shape of each object directly below.
condition_logic "all" or "any" No "all" Whether every condition must match, or just one.
phone_field_path string Only meaningful for call, whatsapp, and both "phone" Dot-path into the posted payload where the recipient's phone number lives. A match with no valid number here doesn't hard-fail, it's dropped and recorded on the event.
from_number string or null No null (falls back to the agent's own twilio_phone_number) Override which of your numbers places the call or sends the WhatsApp message.
context_template string or null Required for agent_task; optional otherwise null Jinja2, rendered against the event payload. For call/both it becomes the agent's dynamic opening context; for agent_task it's the literal task instruction the agent executes headlessly.
cooldown_seconds integer ≥ 0 or null No null (no cooldown) Suppresses a repeat fire to the same resolved phone number within this window. Meaningless for agent_task (no recipient to dedupe on).
action_type "call", "whatsapp", "both", "telegram", or "agent_task" No "call" Determines which fields above are actually required; see below.
whatsapp_template_sid string or null Required for whatsapp/both null A pre-approved Twilio Content Template SID. WhatsApp/Meta policy requires one for any business-initiated message.
whatsapp_template_variables_template string or null No null Jinja2, must render to a JSON object ({"1": "...", "2": "..."}) filling the Content Template's numbered variables.
telegram_chat_id string or null No null (falls back to the deployment's TELEGRAM_CHAT_ID) The chat to message.
telegram_chat_id_field_path string or null No null Dot-path into the posted payload for a chat id. When it resolves, it wins over telegram_chat_id, so one Trigger can message whichever person the event is about.
telegram_message_template string or null Required for telegram null Jinja2, rendered against the payload into the message body.

The shape of a condition object

Each entry in conditions is an object with three fields.

Field Type Required Default Description
field string Yes (minimum 1 character) A dot-path into the posted payload, e.g. customer.phone reaches payload["customer"]["phone"], and items.0.sku reaches the sku key of the first element of an items array.
operator string No "equals" One of the twelve values in the table below.
value any JSON type No null The value to compare field against. Ignored by exists/not_exists, which only check presence.

operator is one of:

Operator Checks
equals Exact value match
not_equals Value differs
contains Substring or list membership
not_contains Substring or list membership is absent
exists The field is present in payload at all
not_exists The field is absent from payload
gt Numeric greater-than
gte Numeric greater-than-or-equal
lt Numeric less-than
lte Numeric less-than-or-equal
in Value is one of a given set
not_in Value is not one of a given set
regex Value matches a regular expression

See Triggers & events for worked prose examples of these operators against a sample payload.

Three validation rules are enforced on create, each returning 422 with a message explaining which field is missing:

  • An action_type of "whatsapp" or "both" without whatsapp_template_sid set is rejected.
  • An action_type of "telegram" without telegram_message_template set is rejected. Telegram has no pre-approved template to fall back on, so without a body there is nothing to send.
  • An action_type of "agent_task" without context_template set is rejected, since context_template doubles as the task instruction and there's nothing to run without it.
curl -X POST https://api.your-domain.com/api/v1/agents/1/triggers \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{
        "name": "Abandoned cart follow-up",
        "event_type_filter": "abandoned_cart",
        "conditions": [
          {"field": "cart_value", "operator": "gt", "value": 50}
        ],
        "condition_logic": "all",
        "phone_field_path": "customer.phone",
        "context_template": "You are calling {{ customer.name }} about their cart worth ${{ cart_value }}, left at {{ items | length }} items. Offer help completing checkout.",
        "cooldown_seconds": 3600
      }'
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/agents/1/triggers",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={
        "name": "Abandoned cart follow-up",
        "event_type_filter": "abandoned_cart",
        "conditions": [
            {"field": "cart_value", "operator": "gt", "value": 50},
        ],
        "condition_logic": "all",
        "phone_field_path": "customer.phone",
        "context_template": (
            "You are calling {{ customer.name }} about their cart worth "
            "${{ cart_value }}, left at {{ items | length }} items. "
            "Offer help completing checkout."
        ),
        "cooldown_seconds": 3600,
    },
)
response.raise_for_status()
trigger = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/triggers",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${adminToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "Abandoned cart follow-up",
      event_type_filter: "abandoned_cart",
      conditions: [{ field: "cart_value", operator: "gt", value: 50 }],
      condition_logic: "all",
      phone_field_path: "customer.phone",
      context_template:
        "You are calling {{ customer.name }} about their cart worth ${{ cart_value }}, left at {{ items | length }} items. Offer help completing checkout.",
      cooldown_seconds: 3600,
    }),
  },
);
const trigger = await response.json();

Returns 201 Created with the full Trigger, including its new id:

{
  "id": 4,
  "agent_id": 1,
  "name": "Abandoned cart follow-up",
  "enabled": true,
  "event_type_filter": "abandoned_cart",
  "conditions": [{ "field": "cart_value", "operator": "gt", "value": 50 }],
  "condition_logic": "all",
  "phone_field_path": "customer.phone",
  "from_number": null,
  "context_template": "You are calling {{ customer.name }} about their cart worth ${{ cart_value }}, left at {{ items | length }} items. Offer help completing checkout.",
  "cooldown_seconds": 3600,
  "action_type": "call",
  "whatsapp_template_sid": null,
  "whatsapp_template_variables_template": null
}

List Triggers

GET /api/v1/agents/{agent_id}/triggers

Returns every Trigger on the agent, enabled and disabled, ordered by id, with no pagination.

curl https://api.your-domain.com/api/v1/agents/1/triggers \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/agents/1/triggers",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
triggers = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/triggers",
  { headers: { Authorization: `Bearer ${adminToken}` } },
);
const triggers = await response.json();

Get a single Trigger

GET /api/v1/agents/{agent_id}/triggers/{trigger_id}

Returns 404 if trigger_id doesn't exist, or belongs to a different agent than the one in the URL.

curl https://api.your-domain.com/api/v1/agents/1/triggers/4 \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/agents/1/triggers/4",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
trigger = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/triggers/4",
  { headers: { Authorization: `Bearer ${adminToken}` } },
);
const trigger = await response.json();

Update a Trigger

PATCH /api/v1/agents/{agent_id}/triggers/{trigger_id}

Every field is optional; send only what you want to change. Unlike create, there's no cross-field validation on update. You could, for example, PATCH a whatsapp_template_sid onto a Trigger and only switch its action_type to "whatsapp" in a later call, and neither individual request would be rejected.

curl -X PATCH https://api.your-domain.com/api/v1/agents/1/triggers/4 \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"cooldown_seconds": 7200, "enabled": false}'
import httpx

response = httpx.patch(
    "https://api.your-domain.com/api/v1/agents/1/triggers/4",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={"cooldown_seconds": 7200, "enabled": False},
)
response.raise_for_status()
trigger = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/triggers/4",
  {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${adminToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ cooldown_seconds: 7200, enabled: false }),
  },
);
const trigger = await response.json();

Returns the full, updated Trigger.

Delete a Trigger

DELETE /api/v1/agents/{agent_id}/triggers/{trigger_id}
curl -X DELETE https://api.your-domain.com/api/v1/agents/1/triggers/4 \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.delete(
    "https://api.your-domain.com/api/v1/agents/1/triggers/4",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/triggers/4",
  { method: "DELETE", headers: { Authorization: `Bearer ${adminToken}` } },
);

Returns 204 No Content. Deletion is immediate and permanent: a future event that would have matched this Trigger simply won't anymore. There's no soft-delete or recovery.

Dry-run a Trigger: /test

POST /api/v1/agents/{agent_id}/triggers/{trigger_id}/test

Evaluates a sample payload against an already-created Trigger exactly the way real event ingestion would, condition matching, phone resolution, context_template rendering, all of it, but stops short of any live effect: no call is queued, no WhatsApp message is sent, no agent_task runs, and no cooldown window is recorded or checked. Use it to validate a Trigger's configuration before pointing a real integration at Sending events.

Request

Field Type Required Description
payload object Yes A sample event payload, same shape as what you'd post to the events endpoint.

Response

Field Type Description
matched boolean Whether conditions matched this payload under condition_logic.
resolved_phone string or null The phone number resolved from phone_field_path, if matched and one was found. Always null for an agent_task Trigger, there's no phone to resolve.
phone_valid boolean true only if matched and resolved_phone is non-null. Always false for agent_task.
rendered_context string or null context_template rendered against payload, if matched. For agent_task, this is the task instruction the agent would actually execute.
curl -X POST https://api.your-domain.com/api/v1/agents/1/triggers/4/test \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{
        "payload": {
          "customer": { "name": "Prince", "phone": "+15557654321" },
          "cart_value": 89,
          "items": [1, 2, 3]
        }
      }'
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/agents/1/triggers/4/test",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={
        "payload": {
            "customer": {"name": "Prince", "phone": "+15557654321"},
            "cart_value": 89,
            "items": [1, 2, 3],
        },
    },
)
response.raise_for_status()
result = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/agents/1/triggers/4/test",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${adminToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      payload: {
        customer: { name: "Prince", phone: "+15557654321" },
        cart_value: 89,
        items: [1, 2, 3],
      },
    }),
  },
);
const result = await response.json();
{
  "matched": true,
  "resolved_phone": "+15557654321",
  "phone_valid": true,
  "rendered_context": "You are calling Prince about their cart worth $89, left at 3 items. Offer help completing checkout."
}

A payload that doesn't satisfy conditions returns matched: false with every other field null or false. No error is raised: this is the expected shape for "this Trigger wouldn't have fired," not a failure response.

End-to-end: create, test, then fire for real

Putting the pieces together, using the abandoned-cart Trigger created above:

  1. Create the Trigger (already done above; its id is 4).
  2. Test it against a representative payload, confirming matched: true and a sensible rendered_context, without placing a real call (also already done above).
  3. Fire it for real by posting an actual event. This is where Sending events takes over: the same POST /api/v1/agents/{agent_id}/events call, authenticated with an events:write-scoped API key rather than your admin token, with a payload shaped to match:
curl -X POST https://api.your-domain.com/api/v1/agents/1/events \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "event_type": "abandoned_cart",
        "payload": {
          "customer": { "name": "Prince", "phone": "+15557654321" },
          "cart_value": 89,
          "items": [1, 2, 3]
        }
      }'

A successful match here returns {"matched": true, "call_ids": [...]}, and moments later an outbound call actually rings +15557654321 with the agent opening on the rendered context. Track it via Calls or the event log.

Next

  • Sending events: the endpoint that actually evaluates Triggers against live traffic.
  • Event log: read back what happened to an event after the fact, including why it didn't match.
  • Calls: the call a matching call or both Trigger queues.
  • Audit log: every create, update, and delete on this page's endpoints is recorded here.