> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hadoseo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Create Routing Rule

> Add a redirect or proxy rule to a domain

```
POST /functions/v1/domains/{domainId}/routing-rules
```

<Info>
  **Beta.** The routing rules endpoints are new and their response shapes may
  still change.
</Info>

Creates one [routing rule](/dashboard/routing-rules) on a domain. The API checks rules the same way the dashboard's **Add Rule** dialog does, so a rule created here behaves exactly like one created in the dashboard. The new rule takes effect right away.

You need the `owner` or `manager` role on the domain. Viewers get `403 forbidden`.

<Warning>
  Always call this endpoint from a **server-side environment** (backend API, serverless function, build script, etc.). Never include your API key in client-side code — it will be visible to anyone inspecting your frontend.
</Warning>

<Info>
  Programmatic (API key) access requires the **Pro plan and above** — requests
  from Starter accounts return `403 plan_upgrade_required`.
</Info>

## Request

### Headers

| Header          | Required | Description                    |
| --------------- | -------- | ------------------------------ |
| `Authorization` | Yes      | `Bearer hado_sk_your_key_here` |
| `Content-Type`  | Yes      | `application/json`             |

### Path Parameters

| Parameter  | Type   | Required | Description                                                                 |
| ---------- | ------ | -------- | --------------------------------------------------------------------------- |
| `domainId` | string | Yes      | The domain's ID, from [List Domains](/api-reference/endpoint/list-domains). |

### Body Parameters

| Parameter      | Type    | Required | Description                                                                                                       |
| -------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `sourcePath`   | string  | Yes      | The path to match. Must start with `/`. See [Source path patterns](#source-path-patterns).                        |
| `targetUrl`    | string  | Yes      | Where matching requests go. See [Targets](#targets).                                                              |
| `ruleType`     | string  | No       | `redirect` (default) or `proxy`.                                                                                  |
| `redirectCode` | number  | No       | `301` (default, permanent) or `302` (temporary). Redirects only — sending a code with a proxy rule returns `400`. |
| `priority`     | integer | No       | Higher numbers are checked first. Default `0`.                                                                    |
| `isActive`     | boolean | No       | Set to `false` to create the rule paused. Default `true`.                                                         |
| `notes`        | string  | No       | Internal notes, up to 2,000 characters.                                                                           |

Leading and trailing whitespace is trimmed from `sourcePath`, `targetUrl`, and `notes`. Paths and URLs can be up to 2,048 characters.

### Source path patterns

Matching ignores letter case and a trailing slash, so `/About` and `/about/` both match a `/about` rule.

| Pattern        | Matches                                                            | Example                                   |
| -------------- | ------------------------------------------------------------------ | ----------------------------------------- |
| `/page`        | That exact path                                                    | `/page` or `/page/`                       |
| `/blog/*`      | The path and everything under it                                   | `/blog`, `/blog/post-1`, `/blog/2024/jan` |
| `/docs/:slug`  | Exactly one path segment, captured as `slug`                       | `/docs/intro`, but not `/docs/a/b`        |
| `/docs/:path*` | Zero or more segments, captured as `path`                          | `/docs`, `/docs/a/b`                      |
| `/docs/:path+` | One or more segments, captured as `path`                           | `/docs/a/b`, but not `/docs`              |
| `/shop/*/sale` | `*` in the middle matches exactly one segment                      | `/shop/shoes/sale`                        |
| `/gallery-*`   | `*` inside a segment matches within that segment only              | `/gallery-kitchen`                        |
| `/:path*/`     | Only URLs that **end in a slash** (a trailing `/` after a pattern) | `/about/`, but not `/about`               |
| `/page?id=42`  | That exact path **and** query string                               | `/page?id=42` only                        |

`:name*`, `:name+`, and a capturing `*` only work as the **last** segment of the pattern.

Each domain can have only one rule per `sourcePath`. Creating a second rule with the same path returns `409`.

### Targets

**Redirects** accept either a path on your domain (`/new-page`) or a full `http://` / `https://` URL (`https://other-site.com/page`).

You can reuse what the source matched:

* **Captures:** put a `:name` or `*` from the source into the target to insert what it matched. For example, `/old-blog/:slug` → `/blog/:slug` sends `/old-blog/hello` to `/blog/hello`, and `/:path*/` → `/:path*` strips the trailing slash from any URL.
* **Wildcards without captures:** if the source ends in `/*` and the target has no `*`, the rest of the path is appended to the target. For example, `/old-blog/*` → `/blog` sends `/old-blog/2024/hello` to `/blog/2024/hello`.

The visitor's query string is passed along to the target, except on rules whose source already includes a `?`.

**Proxies** need a full `http://` or `https://` URL. For security, a proxy target can't be:

* `localhost` or a private, internal, or link-local IP address (such as `10.x.x.x`, `192.168.x.x`, or `169.254.169.254`)
* a `.internal` or `.local` hostname
* your own domain, or its `www.` variant, since that would loop forever

Wildcard paths are appended to proxy targets the same way as for redirects: `/functions/v1/*` → `https://xyz.supabase.co/functions/v1` forwards `/functions/v1/sitemap` to `https://xyz.supabase.co/functions/v1/sitemap`.

### How rules are matched

For each request, active rules are checked in this order, and the **first match wins**:

1. Higher `priority` first.
2. For the same priority, exact paths before patterns.
3. Then longer source paths before shorter ones.

So with equal priorities, `/blog/featured` is checked before `/blog/*` automatically. Raise `priority` when you need a rule to win outright, like a broad catch-all that should override more specific paths.

A redirect whose target would be the same URL the visitor asked for is skipped, so a pattern like `/:path*/` can't cause a redirect loop.

### Example Request

```bash theme={null}
curl -X POST https://api.hadoseo.com/functions/v1/domains/11111111-1111-1111-1111-111111111111/routing-rules \
  -H "Authorization: Bearer hado_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "sourcePath": "/old-blog/*",
    "targetUrl": "/blog",
    "redirectCode": 301,
    "notes": "Blog moved in the 2026 redesign"
  }'
```

## Response

### Success (201)

The `Location` response header holds the new rule's URL, `/functions/v1/domains/{domainId}/routing-rules/{ruleId}`.

```json theme={null}
{
  "rule": {
    "ruleId": "cccccccc-cccc-cccc-cccc-cccccccccccc",
    "sourcePath": "/old-blog/*",
    "targetUrl": "/blog",
    "ruleType": "redirect",
    "redirectCode": 301,
    "priority": 0,
    "isActive": true,
    "notes": "Blog moved in the 2026 redesign",
    "createdAt": "2026-09-25T10:00:00.000Z",
    "updatedAt": "2026-09-25T10:00:00.000Z"
  },
  "cacheInvalidated": true
}
```

| Field              | Type    | Description                                                                                      |
| ------------------ | ------- | ------------------------------------------------------------------------------------------------ |
| `rule`             | object  | The new rule. See [The rule object](/api-reference/endpoint/list-routing-rules#the-rule-object). |
| `cacheInvalidated` | boolean | `true` when the rule is live right away. See the note below.                                     |

<Note>
  The rule is always saved when you get a `201`. If `cacheInvalidated` is
  `false`, Hado SEO couldn't refresh your domain's cached settings right away,
  so the rule may take a few minutes to start applying. You don't need to
  retry.
</Note>

### Error Responses

| Status | Body                                                                     | Description                                                                                                                                                          |
| ------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `{ "error": "invalid_request", "message": "..." }`                       | A field is missing or invalid, such as a `sourcePath` without a leading `/`, an unsupported `redirectCode`, or a blocked proxy target. The `message` explains which. |
| 401    | `{ "error": "invalid_api_key" }`                                         | API key is missing, invalid, or revoked.                                                                                                                             |
| 403    | `{ "error": "plan_upgrade_required" }`                                   | Programmatic access requires the Pro plan or above.                                                                                                                  |
| 403    | `{ "error": "forbidden" }`                                               | You have `viewer` access to this domain. Creating rules needs `owner` or `manager`.                                                                                  |
| 403    | `{ "error": "rule_limit_reached", "limit": 25 }`                         | The domain already has as many rules as its owner's plan allows.                                                                                                     |
| 404    | `{ "error": "domain_not_found" }`                                        | The domain in the path doesn't exist or your account can't access it.                                                                                                |
| 409    | `{ "error": "duplicate_source_path" }`                                   | The domain already has a rule with this `sourcePath`. Update that rule instead.                                                                                      |
| 429    | `{ "error": "rate_limit_exceeded_monthly", "usage": 300, "limit": 300 }` | Rate limit exceeded. The code is `rate_limit_exceeded_monthly` or `rate_limit_exceeded_per_minute` — match on the `rate_limit_exceeded` prefix to catch both.        |

<Info>
  The rule limit follows the plan of the person who **owns** the domain. If a
  Starter account shared the domain with you, its Starter limit applies even if
  your own plan is unlimited.
</Info>

## Examples

### Proxy a sitemap to a Supabase Edge Function

```bash theme={null}
curl -X POST https://api.hadoseo.com/functions/v1/domains/11111111-1111-1111-1111-111111111111/routing-rules \
  -H "Authorization: Bearer hado_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "sourcePath": "/sitemap.xml",
    "targetUrl": "https://xyz.supabase.co/functions/v1/sitemap",
    "ruleType": "proxy"
  }'
```

### Import redirects from a list (JavaScript / Node.js)

```javascript theme={null}
const headers = {
  "Authorization": `Bearer ${process.env.HADOSEO_API_KEY}`,
  "Content-Type": "application/json",
};

const redirects = [
  { from: "/old-pricing", to: "/pricing" },
  { from: "/team", to: "/about#team", code: 302 },
];

for (const { from, to, code } of redirects) {
  const res = await fetch(
    `https://api.hadoseo.com/functions/v1/domains/${process.env.HADOSEO_DOMAIN_ID}/routing-rules`,
    {
      method: "POST",
      headers,
      body: JSON.stringify({
        sourcePath: from,
        targetUrl: to,
        redirectCode: code ?? 301,
      }),
    },
  );
  if (res.status === 409) {
    console.log(`skipped ${from}: a rule already exists`);
    continue;
  }
  const body = await res.json();
  if (!res.ok) throw new Error(`${from}: ${body.message ?? body.error}`);
}
```
