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 runtime attachments. Default and welcome bindings provide fallback inbound behavior; Get Started, persistent menus, and ice breakers synchronize provider profile surfaces and route their postbacks into a flow.
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 14 kinds
| Kind | IG | FB | WA | TG |
|---|---|---|---|---|
dm_received | ✓ | ✓ | ✓ | ✓ |
comment_created | ✓ | ✓ | — | — |
story_reply | ✓ | ✓ | — | — |
story_mention | ✓ | ✓ | — | — |
live_comment | ✓ | ✓ | — | — |
ad_click | ✓ | ✓ | — | — |
ref_link_click | ✓ | ✓ | ✓ | ✓ |
share_to_dm | ✓ | — | — | — |
schedule | ✓ | ✓ | ✓ | ✓ |
field_changed | ✓ | ✓ | ✓ | ✓ |
tag_applied | ✓ | ✓ | ✓ | ✓ |
tag_removed | ✓ | ✓ | ✓ | ✓ |
conversion_event | ✓ | ✓ | ✓ | ✓ |
webhook_inbound | ✓ | ✓ | ✓ | ✓ |
Keyword matching — there is no dedicated
keywordkind. Usedm_receivedwith aconfig.keywordsarray (and optionalmatch_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 legacykeywordkind.
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,
"daily_cap": 500,
"priority": 100,
"specificity": 30
}Fields:
- kind — one of the 14 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. - daily_cap (default unlimited) — maximum atomic admissions for this entrypoint per UTC day.
- 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", "case_sensitive": false, "first_message_only": false }
// comment_created
{ "post_ids": ["17895..."], "keywords": ["link"], "match_mode": "contains", "case_sensitive": false, "include_replies": false }
// story_reply / story_mention
{ "story_ids": ["17895..."], "keywords": ["promo"], "match_mode": "contains", "case_sensitive": false }
// live_comment
{ "keywords": ["question"], "match_mode": "contains", "case_sensitive": false }
// schedule — supported forms: daily M H, hourly, or every N minutes
{ "cron": "0 9 * * *", "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"] }
// share_to_dm
{}
// webhook_inbound — see dedicated section belowAn active schedule entrypoint requires at least one enumerable filter in all or any: contact.tags or contact.segments with op: "contains". You may save a paused schedule while configuring it, but activation rejects none groups and arbitrary contact fields instead of creating a job that can only fail at runtime.
Conflict resolution
Multiple entrypoints can match the same event. The matcher picks one with this sort:
ORDER BY specificity DESC, priority ASC, created_at ASCSpecificity is derived automatically:
| Config | Specificity |
|---|---|
dm_received with keywords + match_mode: exact | regex | 30 |
webhook_inbound (unique slug) | 30 |
Asset-filtered (e.g. comment_created with post_ids) | 25 |
| Filtered with tag/segment/field predicate | 20 |
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:
- Does this contact have an active or waiting run for this
(automation_id, entrypoint_id)? → skip. - Does this contact have a completed run within
reentry_cooldown_minof now? → skip. - Is
allow_reentry = falseand the contact has ever been enrolled? → skip.
The cooldown clock starts at the prior run's completed_at.
Admission is transactional: active-run, re-entry, cooldown, daily-cap, and run creation checks execute under one contact/automation lock, so concurrent deliveries cannot bypass them.
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
| Method | Path | Purpose |
|---|---|---|
GET | /v1/automations/{id}/entrypoints | List entrypoints on an automation |
POST | /v1/automations/{id}/entrypoints | Create |
GET | /v1/automation-entrypoints/{id} | Retrieve |
PATCH | /v1/automation-entrypoints/{id} | Update |
DELETE | /v1/automation-entrypoints/{id} | Remove |
POST | /v1/automation-entrypoints/{id}/rotate-secret | Rotate the webhook secret (for webhook_inbound only) |
Bindings
Bindings attach a flow to a runtime or provider-owned surface on a specific social account. There are five supported types.
| Type | Channels | Availability |
|---|---|---|
default_reply | IG, FB, WA, TG | Fires when an inbound event matches no entrypoint |
welcome_message | IG, FB, WA, TG | Fires on the contact's first-ever inbound on the channel |
get_started | FB | Synchronizes the Messenger Get Started payload; its postback starts the flow |
main_menu | IG, FB | Synchronizes the provider persistent menu; postback items start the flow |
ice_breaker | IG | Synchronizes up to four Instagram ice-breaker questions; postbacks start the flow |
Shape
{
"id": "abnd_...",
"social_account_id": "acc_...",
"channel": "instagram",
"binding_type": "default_reply",
"automation_id": "auto_...",
"config": {},
"status": "active",
"desired_active": true,
"sync_revision": 3,
"last_synced_revision": 3,
"sync_attempts": 1,
"last_synced_at": "2026-07-18T10:00:00.000Z",
"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
// default_reply / welcome_message
{}
// get_started (Facebook)
{ "payload": "GET_STARTED" }
// main_menu (Instagram/Facebook)
{
"items": [
{ "label": "Help", "action": "postback", "payload": "HELP" },
{ "label": "Website", "action": "url", "url": "https://example.com" }
],
"composer_input_disabled": false
}
// ice_breaker (Instagram; maximum four)
{
"questions": [
{ "question": "What are your hours?", "payload": "HOURS" }
]
}Provider-synchronized bindings move through pending_sync before becoming active or paused. Writes increment sync_revision; queue workers apply only that exact revision, and a reconciler re-enqueues revisions that have not been acknowledged. sync_failed retains the provider error for repair. Deleting a provider binding first removes its provider profile field and only then removes the local row.
Facebook requires an active, synchronized get_started binding before a main_menu can synchronize. Facebook menus support at most three top-level items; Instagram menus support up to 20.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET | /v1/automation-bindings | List. Filters: social_account_id, binding_type, automation_id |
POST | /v1/automation-bindings | Create |
GET | /v1/automation-bindings/{id} | Retrieve |
PATCH | /v1/automation-bindings/{id} | Update |
DELETE | /v1/automation-bindings/{id} | Remove |
Create and update requests accept only status = "active" | "paused".
Runtime match algorithm
On every inbound channel event, the matcher:
- Loads candidate entrypoints where
channel = X,kind = Y,status = active, and(social_account_id IS NULL OR social_account_id = event.account_id). - Applies the kind-specific config matcher (keyword match, post-id match, cron window, etc.).
- Applies filter predicates against the contact.
- Skips any entrypoint blocked by re-entry rules or contact-pause.
- Sorts by
(specificity DESC, priority ASC, created_at ASC). Takes the first. - Enrolls — creates an
automation_runsrow, 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 isactive.
Interactive provider postbacks are matched against the synchronized Get Started, main-menu, or ice-breaker payload on that exact account. A matching active binding enrolls its automation with the same atomic admission protections as an entrypoint.
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 plus top-level webhook_secret_plaintext exactly once. Store the plaintext—subsequent reads return a masked config value. Rotation returns a new webhook_secret_plaintext:
POST /v1/automation-entrypoints/{id}/rotate-secretContact lookup modes
by | Effect |
|---|---|
email | Extract via field_path, match on contacts.email |
phone | Extract via field_path, match on contacts.phone |
platform_id | Match on a platform identifier from the channel |
custom_field | Extract via field_path, match on contacts.custom_fields.<custom_field_key> |
contact_id | Extract 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: set X-Relay-Timestamp to the current Unix time in seconds, then compute HMAC-SHA256 over <timestamp>.<raw-request-body> using the plaintext secret. Send both headers:
X-Relay-Signature: sha256=<hex>
X-Relay-Timestamp: <unix-seconds>Timestamps more than five minutes from the API server's current time are rejected. Sign the exact body bytes sent on the wire; reformatting the JSON after signing invalidates the signature.
Example
BODY='{"order":{"id":"1234","total":42.00},"customer":{"email":"alice@example.com"}}'
TIMESTAMP=$(date +%s)
SIG=$(printf '%s.%s' "$TIMESTAMP" "$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" \
-H "X-Relay-Timestamp: $TIMESTAMP" \
-d "$BODY"Responses
| Status | Meaning |
|---|---|
202 { run_id } | Enrolled successfully |
401 | Missing or invalid signature |
404 | Unknown webhook_slug |
409 | Enrollment was blocked by the entrypoint's active-run, re-entry, cooldown, daily-cap, pause, or filter policy |
422 | Contact lookup failed (auto_create_contact is false or required fields are missing) |
The webhook endpoint is account-agnostic for email, phone, contact-ID, and custom-field lookup — the slug alone identifies the entrypoint, so those modes do not require a social_account_id. A platform_id is only unique within a connected account, so that lookup mode requires the entrypoint to be attached to the matching social account.
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.