Routing Rules
Fire actions — email, webhook, Zapier hook — when a contact-form submission matches conditions you define.
Routing rules turn the Submissions inbox into a workflow. When a submission lands, every enabled rule for your team is evaluated in priority order: matched rules fire actions (email a teammate, POST to a webhook, ping Zapier / Make / your CRM). No matches? The submission still goes to the inbox — rules add behavior, they don't gatekeep it.
Rules apply team-wide. One ruleset covers every workspace under
the team. To constrain a rule to a single workspace, add a condition
on workspace.slug (or workspace.id, workspace.domain) — see
Conditions below.
Where to find them
CMS → top of the Submissions page → Routing rules button. The pane opens as a side-sheet over the submissions list. The button badge shows enabled / total at a glance.
You need to be a team Owner or Admin to create, edit, enable, disable, delete, or reorder rules. Members can view the list and run dry-run tests.
The rule structure
Every rule has four parts:
- Name + description — for your team's benefit. Shown in the rule list.
- Conditions (optional) — when should this fire? Empty = every submission.
- Actions (at least one) — what should happen? Email, webhook, or both.
- Advanced — priority, stop-on-match, rate limit.
Conditions
A condition checks a single field on the submission. Build complex matches by stacking conditions and picking all of (AND) or any of (OR) at the top.
Field paths
Conditions reference submission fields via dot-paths:
| Path | What it is |
|---|---|
name | Submitter's name |
email | Submitter's email |
message | Free-text body |
subject | Optional subject line |
phone | Optional phone number |
source | Where the form was posted from (often a page path) |
metadata.<key> | Any field your form chose to send — metadata.locale, metadata.utm_source, metadata.formId, etc. |
workspace.slug | Workspace the submission came from |
workspace.id | Workspace ID |
workspace.domain | Workspace custom domain (if set) |
workspace.name | Workspace display name |
Any other dot-path that resolves against the submission JSON also works — the editor's dropdown shows the common ones but accepts arbitrary paths.
Operators
| Operator | Behavior | Value type |
|---|---|---|
exists | Path resolves to a non-null value | — |
not_exists | Path is missing or null | — |
equals / not_equals | Exact string match | string |
contains / not_contains | Substring match | string |
starts_with / ends_with | Prefix / suffix match | string |
regex | JavaScript regex match | string (the pattern) |
in / not_in | Equals any value in a list | array of strings |
String operators are case-insensitive by default. Toggle Case-sensitive match on a condition row to flip that.
not_* operators are designed to do the intuitive thing when the
path is missing: email not_contains "spam" matches a submission
with no email at all.
Examples
Route enterprise leads to sales:
When
messagecontains"enterprise"→ email sales@yourco.
Only fire for one workspace in a team:
When
workspace.slugequalsmarketing-siteAND@bigcorp.com→ POST to your CRM.
Catch GDPR-region forms for a specific locale:
When
metadata.localein["fr", "de", "es"]→ email gdpr-team@yourco.
Actions
Each rule has one or more actions. Multiple actions on one rule fire in the order they're listed.
Email action
Drops a notification email to one or more recipients with the submission details (name, email, subject, message, source, metadata, plus the names of the rule(s) that matched). Up to 20 recipients per email action — stack multiple email actions if you need more.
The email subject line includes the workspace name + matched rule name so it's scannable in an inbox; the body shows the matched rule names as violet pills so multi-rule fires are obvious.
Webhook action
POSTs a JSON envelope to a URL you provide. Drop-in compatible with:
- Zapier "Catch Hook" — paste the trigger URL, done.
- Make.com webhooks
- Slack incoming webhooks (raw JSON; you'll want to use Slack's Block Kit format on your side if you want pretty messages)
- n8n, Pipedream, any custom HTTP endpoint
Payload
POST <your-webhook-url>
Content-Type: application/json
User-Agent: BrandfineRoutingWebhook/1.0 (+https://brandfine.co)
X-Brandfine-Signature: sha256=<hex> # only when a signing secret is set
{
"event": "submission.matched",
"workspace": {
"id": "ws_2N4r",
"slug": "marketing-site",
"name": "Marketing Site",
"domain": "yourco.com"
},
"matchedRules": ["Enterprise leads", "All submissions audit"],
"submission": {
"id": "sub_2N5dRkQp",
"name": "Alex Liu",
"email": "alex@bigcorp.com",
"phone": "+1 555 0100",
"subject": "Demo request",
"message": "I'd like a demo.",
"source": "/contact",
"metadata": { "utm_source": "twitter" },
"createdAt": "2026-06-01T12:34:56.789Z"
}
}HMAC signing
Set a Signing secret (16–256 chars) on the action and Brandfine
adds an X-Brandfine-Signature: sha256=<hex> header — HMAC-SHA256
of the raw JSON body bytes, hex-encoded. Verify with constant-time
comparison on your side. Same convention as GitHub, Stripe, Slack
webhooks.
Node example:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody: string, header: string | undefined, secret: string) {
if (!header?.startsWith('sha256=')) return false
const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
const received = header.slice('sha256='.length)
return (
expected.length === received.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(received))
)
}Verify against the raw bytes you received — re-serializing the parsed JSON before HMAC will compute a different digest.
Reliability
Single attempt with a 10-second timeout, fire-and-forget. We log delivery failures but don't retry — the submission is already saved in your inbox, so a failed webhook doesn't lose data.
If you need at-least-once delivery, point the URL at Zapier (their infra retries) or your own queue.
Priority + stacking
Rules evaluate in priority order, lowest number first (default 100). By default, every matched rule fires — submissions can match many rules.
Stop on match changes that: if a stopOnMatch rule matches,
no lower-priority rules are evaluated for that submission. Useful
for "VIP routes": catch enterprise leads with a high-priority rule
that has stopOnMatch on, so they don't also trigger the
generic "all submissions" rule below.
Rate limit
Optionally cap how often a rule's actions fire: at most N fires per W seconds. The window is per-rule (not per-workspace, not per-recipient). When the cap is hit inside the window, the rule is treated as "did not match" for further submissions until the window rolls over — the submission still saves to the inbox.
Useful for:
- Webhooks with strict daily quotas
- Noisy forms during a spam wave
- Capping email notifications during a campaign launch
Both fields (window + max) must be set together or neither.
Testing rules
Use Test rules at the bottom of the pane to dry-run a sample
submission. Fill in the fields, optionally pick a workspace to
simulate from (so workspace.* conditions resolve), hit Run.
The result shows which rules would have matched in priority
order — no emails fire, no webhooks fire.
What rules don't do
- They don't replace your inbox. Submissions are persisted whether they match any rule or not.
- They don't filter. There's no "drop this submission" action. Use spam protection on the consumer-side form (reCAPTCHA, hCaptcha) if you need that.
- They don't run on historical submissions. Rules fire at ingest time. Saving a new rule won't backfill notifications for yesterday's submissions.
- They don't retry failed actions. A failed email or webhook is logged once and abandoned. The submission row is unaffected.
REST API
Full reference: /docs/api/routing-rules.
Google Analytics
Pull Google Analytics 4 sessions, engagement, channels, and key events into Brandfine — synced daily, surfaced per workspace and per post, side-by-side with built-in analytics.
Brandfine staff sessions
How — and how briefly — Brandfine staff can access your workspace, and how you stay in control.