RelayAPI
GuidesAutomations

Triggers

Entrypoints that start a run, bindings that attach a flow to a channel surface, and the webhook entrypoint for external systems.

An automation reacts to two kinds of inputs:

  • Entrypoints — per-flow triggers. Each flow can have many. An event on the channel is matched against all active entrypoints for that channel and the most specific one enrolls the contact.
  • Bindings — per-account attachments. One flow gets bound to a channel surface (default reply, welcome message, main menu, etc.). Bindings fire when no entrypoint matches, filling in the "otherwise" slot.

Entrypoints live under an automation and are managed in the flow builder. Bindings live under a social account and are managed in the account detail page.

Entrypoints

The 15 kinds

KindIGFBWATG
dm_received
comment_created
story_reply
story_mention
live_comment
ad_click
ref_link_click
share_to_dm
follow
schedule
field_changed
tag_applied
tag_removed
conversion_event
webhook_inbound

Keyword matching — there is no dedicated keyword kind. Use dm_received with a config.keywords array (and optional match_mode / case_sensitive) to react to specific words in an inbound DM. The matcher treats an exact- or regex-mode keyword list as specificity 30, identical to the legacy keyword kind.

Shape

{
  "id": "aep_...",
  "automation_id": "auto_...",
  "channel": "instagram",
  "kind": "dm_received",
  "status": "active",
  "social_account_id": "acc_...",          // null = all accounts for the channel
  "config": { "keywords": ["pizza"], "match_mode": "exact", "case_sensitive": false },
  "filters": { "all": [{ "field": "contact.tags", "op": "contains", "value": "vip" }] },
  "allow_reentry": true,
  "reentry_cooldown_min": 60,
  "priority": 100,
  "specificity": 30
}

Fields:

  • kind — one of the 15 above.
  • channel — must match the parent automation's channel.
  • social_account_id — scopes the entrypoint to a specific account; leave null for all accounts on the channel.
  • config — kind-specific (see below).
  • filters — segment / tag / field predicates evaluated against the contact before enrolling.
  • allow_reentry (default true) — whether the same contact can re-enter.
  • reentry_cooldown_min (default 60) — cooldown after run completion before a re-entry is allowed.
  • priority (default 100, lower wins in ties).
  • specificity — auto-derived from the kind + config; higher wins.

Config shapes per kind

// dm_received (optional keyword filtering)
// Omit `keywords` to match any inbound DM on the channel/account. Specify
// `keywords` with `match_mode: "exact"` or `"regex"` for a specificity=30
// match — the canonical replacement for the retired `keyword` kind.
{ "keywords": ["pizza", "order"], "match_mode": "exact | contains | regex", "case_sensitive": false }

// comment_created
{ "post_ids": ["17895..."], "keywords": ["link"], "include_replies": false }

// story_reply
{ "story_ids": ["17895..."], "keywords": ["promo"] }

// schedule
{ "cron": "0 9 * * 1", "timezone": "Europe/Rome" }

// field_changed
{ "field_keys": ["stage"], "from": "new", "to": "qualified" }

// tag_applied / tag_removed
{ "tag_ids": ["trial_expired"] }

// ref_link_click
{ "ref_url_ids": ["ref_..."] }

// conversion_event
{ "event_names": ["purchase"] }

// webhook_inbound — see dedicated section below

Conflict resolution

Multiple entrypoints can match the same event. The matcher picks one with this sort:

ORDER BY specificity DESC, priority ASC, created_at ASC

Specificity is derived automatically:

ConfigSpecificity
dm_received with keywords + match_mode: exact | regex30
webhook_inbound (unique slug)30
Asset-filtered (e.g. comment_created with post_ids)25
Filtered with tag/segment/field predicate20
Account-scoped broad (e.g. dm_received on account X, no filter)10
Catch-all (no account, no filter)0

Use priority to break ties or force a specific order when two entrypoints have identical specificity.

Re-entry

For every incoming match, the matcher checks:

  1. Does this contact have an active or waiting run for this (automation_id, entrypoint_id)? → skip.
  2. Does this contact have a completed run within reentry_cooldown_min of now? → skip.
  3. Is allow_reentry = false and the contact has ever been enrolled? → skip.

The cooldown clock starts at the prior run's completed_at.

Contact pause

A contact can be paused per-automation or globally via automation_contact_controls. If any matching pause row exists (with paused_until IS NULL or still in the future), enrollment is skipped. See Runs for the contact-controls API.

Endpoints

MethodPathPurpose
GET/v1/automations/{id}/entrypointsList entrypoints on an automation
POST/v1/automations/{id}/entrypointsCreate
GET/v1/automation-entrypoints/{id}Retrieve
PATCH/v1/automation-entrypoints/{id}Update
DELETE/v1/automation-entrypoints/{id}Remove
POST/v1/automation-entrypoints/{id}/rotate-secretRotate the webhook secret (for webhook_inbound only)

Bindings

Bindings attach a flow to a "channel surface" on a specific social account. There are 5 types.

TypeChannelsv1 status
default_replyIG, FB, WA, TGWired live — fires when an inbound event matches no entrypoint
welcome_messageIG, FB, WA, TGWired live — fires on the contact's first-ever inbound on the channel
conversation_starterFB onlyStubbed — storage + UI only; platform sync via messenger_profile.ice_breakers ships in v1.1
main_menuFB, IGStubbed — storage + UI only; platform sync via messenger_profile.persistent_menu ships in v1.1
ice_breakerWA onlyStubbed — storage + UI only; WhatsApp platform sync ships in v1.1

Shape

{
  "id": "abnd_...",
  "social_account_id": "acc_...",
  "channel": "instagram",
  "binding_type": "default_reply",
  "automation_id": "auto_...",
  "config": {},
  "status": "active",
  "last_synced_at": null,
  "sync_error": null
}

Constraint: one binding per (social_account_id, binding_type). Binding the same flow to multiple surfaces means creating multiple binding rows.

Config per type

// default_reply / welcome_message
{}
// automation_id alone is sufficient

// conversation_starter
{ "starters": [{ "label": "Get started", "payload": "start" }] }  // max 4

// main_menu
{
  "items": [
    { "label": "Shop", "action": "url", "payload": "https://…" },
    { "label": "Contact", "action": "postback", "payload": "contact" }
  ]
}
// max 3 levels deep, supports sub_items

// ice_breaker
{ "questions": [{ "question": "How do I order?", "payload": "faq_order" }] }

Endpoints

MethodPathPurpose
GET/v1/automation-bindingsList. Filters: social_account_id, binding_type, automation_id
POST/v1/automation-bindingsCreate
GET/v1/automation-bindings/{id}Retrieve
PATCH/v1/automation-bindings/{id}Update
DELETE/v1/automation-bindings/{id}Remove

Stubbed binding types default to status = "pending_sync" at create time — they're stored but not yet pushed to the platform.

Runtime match algorithm

On every inbound channel event, the matcher:

  1. Loads candidate entrypoints where channel = X, kind = Y, status = active, and (social_account_id IS NULL OR social_account_id = event.account_id).
  2. Applies the kind-specific config matcher (keyword match, post-id match, cron window, etc.).
  3. Applies filter predicates against the contact.
  4. Skips any entrypoint blocked by re-entry rules or contact-pause.
  5. Sorts by (specificity DESC, priority ASC, created_at ASC). Takes the first.
  6. Enrolls — creates an automation_runs row, dispatches to the runner.

If step 5 yielded nothing, the matcher falls through to bindings:

  • welcome_message — fires only on the contact's first-ever inbound on this channel (checked against inbox_messages).
  • default_reply — fires otherwise, if a binding exists on (social_account_id, "default_reply") and is active.

Webhook entrypoints

webhook_inbound entrypoints let external systems (CRMs, e-commerce stores, etc.) enroll contacts into a flow by POSTing to a unique signed URL.

Creating a webhook entrypoint

POST /v1/automations/{id}/entrypoints
{
  "kind": "webhook_inbound",
  "channel": "instagram",
  "config": {
    "contact_lookup": {
      "by": "email",
      "field_path": "$.customer.email",
      "auto_create_contact": true
    },
    "payload_mapping": {
      "order_id": "$.order.id",
      "order_total": "$.order.total"
    }
  }
}

The create response includes config.webhook_slug and the plaintext config.webhook_secret exactly once. Store it — subsequent reads return only the slug. Rotate with:

POST /v1/automation-entrypoints/{id}/rotate-secret

Contact lookup modes

byEffect
emailExtract via field_path, match on contacts.email
phoneExtract via field_path, match on contacts.phone
platform_idMatch on a platform identifier from the channel
custom_fieldExtract via field_path, match on contacts.custom_fields.<custom_field_key>
contact_idExtract via field_path, match on contacts.id

Set auto_create_contact: true to create a new contact when the lookup misses. Otherwise the request returns 422.

payload_mapping extracts fields from the body into run.context so downstream nodes can reference {{context.order_id}} etc.

Receiving webhooks

Public endpoint: POST /v1/webhooks/automation-trigger/{webhook_slug}

Signature: the caller must compute HMAC-SHA256 over the raw request body using the plaintext secret and send it as:

X-Relay-Signature: sha256=<hex>

Example

BODY='{"order":{"id":"1234","total":42.00},"customer":{"email":"alice@example.com"}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $2}')

curl -X POST https://api.relayapi.dev/v1/webhooks/automation-trigger/auto-xyz123 \
  -H "Content-Type: application/json" \
  -H "X-Relay-Signature: sha256=$SIG" \
  -d "$BODY"

Responses

StatusMeaning
202 { run_id }Enrolled successfully
401Missing or invalid signature
404Unknown webhook_slug
422Contact lookup failed (auto_create_contact is false or required fields are missing)

The webhook endpoint is account-agnostic — the slug alone identifies the entrypoint, so you can use webhook_inbound without attaching a social_account_id.

Filters

All entrypoints support the same filter shape, evaluated before enrollment:

{
  "all": [
    { "field": "contact.tags", "op": "contains", "value": "vip" },
    { "field": "contact.custom_fields.country", "op": "eq", "value": "IT" }
  ],
  "any": [ /* OR group */ ],
  "none": [ /* NOT group */ ]
}

Field paths: contact.<column>, contact.custom_fields.<slug>, contact.tags, contact.segments. Operators: eq, neq, gt, gte, lt, lte, contains, not_contains, in, not_in, exists, not_exists.

Found something wrong? Help us improve this page.

On this page

Submit an Issue
Requires a GitHub account.View repo