Routing Rules
Team-scoped CRUD for contact-form routing rules — internal endpoints, session-authenticated.
Routing rules are team-scoped: one ruleset applies across every workspace under the team. See Routing Rules concept for what they do and how to compose them. These endpoints live on the internal surface; the CMS app is their primary consumer.
All endpoints require:
- Session auth (
Cookie: better-auth.session_token=…). - Membership in the team identified by
<slug>(a team slug, not a workspace slug).
Writes (POST, PATCH, DELETE, POST /reorder) additionally
require OWNER or ADMIN role on the team. GET and
POST /test are open to any team member.
Resource shape
{
"id": "cmpv5nwjh0001d3si8ubro9l1",
"teamId": "cmo39ct0c0000bvsiabrh3xxx",
"name": "Enterprise leads",
"description": "Routes large-deal leads to the sales channel",
"enabled": true,
"priority": 100,
"matchMode": "ALL",
"stopOnMatch": false,
"conditions": [
{
"path": "message",
"op": "contains",
"value": "enterprise",
"caseSensitive": false
}
],
"actions": [
{ "type": "email", "to": ["sales@yourco.com"] }
],
"rateLimitWindowSeconds": null,
"rateLimitMaxRequests": null,
"windowStartedAt": null,
"windowCount": 0,
"createdAt": "2026-06-01T12:00:00.000Z",
"updatedAt": "2026-06-01T12:00:00.000Z"
}Condition
| Field | Type | Required | Notes |
|---|---|---|---|
path | string | ✓ | Dot-path into the submission. Pattern: ^[a-zA-Z_][a-zA-Z0-9_.[\]-]*$, 1–200 chars. |
op | string | ✓ | One of exists, not_exists, equals, not_equals, contains, not_contains, starts_with, ends_with, regex, in, not_in. |
value | string | string[] | varies | Required for every op except exists / not_exists. Array for in / not_in. |
caseSensitive | boolean | — | Default false. Applies to string comparisons + regex (controls the /i flag). |
Action
Discriminated by type:
| Field | Type | Applies to | Notes |
|---|---|---|---|
type | "email" | "webhook" | both | Required. |
to | string[] | 1–20 recipient addresses. Required for email; forbidden for webhook. | |
url | string | webhook | http(s)://…, up to 2048 chars. Required for webhook; forbidden for email. |
secret | string | webhook | 16–256 chars. When set, every delivery includes X-Brandfine-Signature: sha256=<hex> (HMAC-SHA256 of the raw body). |
headers | object | webhook | Up to 10 entries; each value is a string ≤ 1024 chars. Merged into the request headers. |
A rule must have at least 1 action and at most 10.
Rate limit
rateLimitWindowSeconds + rateLimitMaxRequests must be set
together or both be null. The server tracks windowStartedAt +
windowCount automatically — clients don't touch those.
GET /ws/:slug/routing-rules
List all rules for the team, in evaluation order (priority asc, then createdAt asc).
GET /ws/acme/routing-rules
Cookie: better-auth.session_token=…200 — RoutingRule[].
POST /ws/:slug/routing-rules
Create a rule. Requires OWNER or ADMIN.
POST /ws/acme/routing-rules
Content-Type: application/json
Cookie: better-auth.session_token=…
{
"name": "Enterprise leads",
"description": "Routes large-deal leads to the sales channel",
"matchMode": "ALL",
"stopOnMatch": false,
"priority": 100,
"conditions": [
{ "path": "message", "op": "contains", "value": "enterprise" }
],
"actions": [
{ "type": "email", "to": ["sales@yourco.com"] }
]
}| Field | Required | Default |
|---|---|---|
name | ✓ | — |
description | — | null |
enabled | — | true |
priority | — | 100 |
matchMode | — | "ALL" |
stopOnMatch | — | false |
conditions | ✓ | — ([] allowed) |
actions | ✓ | — (1–10 items) |
rateLimitWindowSeconds / rateLimitMaxRequests | — | both null |
201 — the created RoutingRule.
400 — validation failure. Body includes a message field
describing the problem (operator value missing, regex invalid,
email action without recipients, both rate-limit fields not set
together, etc.).
403 — not OWNER / ADMIN.
GET /ws/:slug/routing-rules/:id
Fetch a single rule.
200 — RoutingRule. 404 when the rule doesn't belong to the team.
PATCH /ws/:slug/routing-rules/:id
Partial update. Every field is optional; omitted fields are left as-is. Requires OWNER or ADMIN.
PATCH /ws/acme/routing-rules/cmpv5nwjh0001d3si8ubro9l1
Content-Type: application/json
{ "enabled": false }200 — the updated RoutingRule.
Sending conditions or actions replaces that field entirely
(no partial-array merging). Cross-field rules are re-validated on
every PATCH that touches them.
DELETE /ws/:slug/routing-rules/:id
Delete a rule. Requires OWNER or ADMIN.
204 on success. Already-routed submissions are unaffected.
POST /ws/:slug/routing-rules/reorder
Bulk re-order rules. Body lists every rule id in the team in the
desired evaluation order (lowest priority first). The server
re-assigns priorities to 100, 110, 120, … so manual insertions
between have room. Requires OWNER or ADMIN.
POST /ws/acme/routing-rules/reorder
Content-Type: application/json
{ "orderedIds": ["rule_a", "rule_b", "rule_c"] }200 — the full RoutingRule[] in the new order.
400 when orderedIds doesn't contain exactly the team's current
rule ids (no duplicates, no extras, no missing).
POST /ws/:slug/routing-rules/test
Dry-run a sample submission. Open to any team member. No actions fire.
POST /ws/acme/routing-rules/test
Content-Type: application/json
{
"name": "Test Lead",
"email": "test@example.com",
"message": "Interested in an enterprise plan.",
"subject": "Demo request",
"source": "/contact",
"metadata": { "locale": "en" },
"workspaceId": "ws_2N4r"
}| Field | Required | Notes |
|---|---|---|
name | ✓ | 1–120 chars |
email | ✓ | Validated |
message | ✓ | 1–5,000 chars |
phone, subject, source | — | All optional |
metadata | — | JSON object |
workspaceId | — | Simulate the submission coming from this workspace, so conditions on workspace.* paths resolve. Must belong to this team. |
200:
{
"matched": [
{
"id": "cmpv5nwjh0001d3si8ubro9l1",
"name": "Enterprise leads",
"priority": 100,
"stopped": false
}
]
}The stopped flag echoes the rule's stopOnMatch setting. When a
stopOnMatch rule matches, no lower-priority rules are evaluated —
the response's matched array is truncated accordingly.
Errors
| Status | Why |
|---|---|
400 | Validation failed. Body's message field is human-readable (string or string[]). |
401 | Missing / expired session. |
403 | Either not a member of the team, or write attempted by a MEMBER. |
404 | Rule id doesn't belong to the team. |