---
title: Redirects
description: Read and replace a site's redirect table, with the field-name asymmetry, target allow-list, and status code handling.
order: 6
---

# Redirects

Redirects belong to the site and are stored as one table. There is a read endpoint and a whole-map replace endpoint; there is no way to add or remove a single entry.

For matching rules and precedence, see [Redirects](/writing/redirects).

All endpoints require an organization API key. `:site` accepts a slug or an id.

## Read the table

```
GET /v1/sites/:site/redirects
```

```bash
curl -s -H "authorization: Bearer $DOCSARY_KEY" \
  "$DOCSARY_API/v1/sites/acme/redirects"
```

```json
{
  "redirects": [
    { "source_path": "/beta", "target": "/", "code": 302 },
    { "source_path": "/old-guide/setup", "target": "/guide/install", "code": 301 },
    { "source_path": "/welcome", "target": "/", "code": 301 }
  ]
}
```

Ordered by source path.

## Replace the table

```
PUT /v1/sites/:site/redirects
```

```bash
curl -s -X PUT "$DOCSARY_API/v1/sites/acme/redirects" \
  -H "authorization: Bearer $DOCSARY_KEY" \
  -H "content-type: application/json" \
  -d '{
    "redirects": [
      { "source": "/welcome", "target": "/" },
      { "source": "/old-guide/setup", "target": "/guide/install" },
      { "source": "/beta", "target": "/", "code": 302 },
      { "source": "/status", "target": "https://status.acme.com" }
    ]
  }'
```

```json
{ "count": 4 }
```

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `redirects` | array | yes | The complete table. An empty array clears it. |
| `redirects[].source` | string | yes | Request path. Leading slashes are normalized to exactly one. |
| `redirects[].target` | string | yes | Site-relative path or absolute `http`/`https` URL. |
| `redirects[].code` | number | no | `302` for temporary; anything else, including omission, stores as `301`. |

:::warning
This is a replace, not a merge. Every entry not present in the request is deleted. Read the table first and send it back complete.
:::

:::info
The write field is `source`; the read field is `source_path`. A read-modify-write loop must rename the key between the two calls.
:::

The whole table is written in a single transaction — it applies completely or not at all — and takes effect immediately.

A table longer than your [redirect ceiling](/access#the-ceilings) is refused with `403` and `quota: "redirects_per_site"`. Nothing is written, and the table you already had stays in place.

## Target validation

Every target is checked before anything is written. One bad target fails the entire request; nothing is partially applied.

Accepted:

- A site-relative path beginning with a single `/`
- An absolute `http:` or `https:` URL

Rejected with `400`:

```
{"error":"unsafe redirect target 'javascript:alert(1)' (allowed: http:, https:, or a /site-relative path)"}
```

That covers `javascript:`, `data:`, `vbscript:`, `file:`, protocol-relative `//host/path`, bare `#fragment` values, schemeless relative paths, and any value containing control characters. `mailto:` is accepted in site settings but not here — a redirect target becomes a `Location` header, which is a stricter position than an `href`.

The same check runs again when a redirect is served. A stored target that fails it is ignored entirely, and the request falls through to the normal page lookup or a 404, rather than emitting an unsafe `Location` header.

## Other errors

| Response | Cause |
| --- | --- |
| `400 {"error":"redirects array required"}` | `redirects` missing or not an array |
| `400 {"error":"each redirect needs a string source and target"}` | An entry is null, or `source`/`target` is not a string |
| `404 {"error":"site not found"}` | The site slug or id does not resolve |
