Brandfine Docs
REST API

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

FieldTypeRequiredNotes
pathstringDot-path into the submission. Pattern: ^[a-zA-Z_][a-zA-Z0-9_.[\]-]*$, 1–200 chars.
opstringOne of exists, not_exists, equals, not_equals, contains, not_contains, starts_with, ends_with, regex, in, not_in.
valuestring | string[]variesRequired for every op except exists / not_exists. Array for in / not_in.
caseSensitivebooleanDefault false. Applies to string comparisons + regex (controls the /i flag).

Action

Discriminated by type:

FieldTypeApplies toNotes
type"email" | "webhook"bothRequired.
tostring[]email1–20 recipient addresses. Required for email; forbidden for webhook.
urlstringwebhookhttp(s)://…, up to 2048 chars. Required for webhook; forbidden for email.
secretstringwebhook16–256 chars. When set, every delivery includes X-Brandfine-Signature: sha256=<hex> (HMAC-SHA256 of the raw body).
headersobjectwebhookUp 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=…

200RoutingRule[].


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"] }
  ]
}
FieldRequiredDefault
name
descriptionnull
enabledtrue
priority100
matchMode"ALL"
stopOnMatchfalse
conditions— ([] allowed)
actions— (1–10 items)
rateLimitWindowSeconds / rateLimitMaxRequestsboth 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.

200RoutingRule. 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"
}
FieldRequiredNotes
name1–120 chars
emailValidated
message1–5,000 chars
phone, subject, sourceAll optional
metadataJSON object
workspaceIdSimulate 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

StatusWhy
400Validation failed. Body's message field is human-readable (string or string[]).
401Missing / expired session.
403Either not a member of the team, or write attempted by a MEMBER.
404Rule id doesn't belong to the team.

On this page