Import Page Redirects
When you migrate a community onto Bettermode, the old URLs usually need to keep working. This guide covers adding, editing, and removing page redirects — in bulk from a CSV, or one at a time through the API.
A redirect is stored as a custom response on a path, with an HTTP code of 301 and a location header.
The API stores and serves these; the rendering layer turns them into an actual HTTP redirect.
All requests go to https://api.bettermode.com/graphql and must be authenticated with a token for the site
you are working on. Tokens are site-scoped — a token issued for one site will not work against another. See
App Access Token and
Member Access Token.
Required scopes
| Operation | Scopes |
|---|---|
| Import, upsert, list | group:update:all or UpdateContent |
| Delete | group:remove:all or DeleteContent |
Export your token once for the session:
export BM_TOKEN='<your-admin-token>'
How paths are normalized
Both the source and the destination run through the same normalization on write and on read, so
/old-page, old-page/, and https://community.example.com/old-page all store as old-page.
Two consequences are worth knowing before you prepare a file:
- Only the pathname survives. Query strings and fragments are dropped, so
old-page?ref=emailandold-pageare the same record — you cannot redirect on a query string. - Bulk destinations are always same-origin. The importer stores the destination as a leading-slash path,
so a bulk import cannot send traffic to another domain. For an off-site redirect, use the
single-redirect mutation and set the
locationheader to the absolute URL.
Bulk import from a CSV
Prepare the file
Two columns, sourceUrl,destinationUrl:
sourceUrl,destinationUrl
/old-marketing-page,/new-marketing-page
https://community.example.com/legacy/faq,/help/faq
- A header row named
sourceUrl,destinationUrlis skipped. A header is not required. - Rows missing either column are ignored. Values are trimmed and blank lines are skipped.
- Duplicate sources within one file are deduplicated — the last row wins.
These limits are enforced on the server. If a file trips one, the request fails and nothing is written:
| Limit | Value |
|---|---|
| File size | 5 MiB |
| Rows | 10,000 |
| Source path length | 500 characters |
An over-long source path rejects the whole batch and names the first few offenders.
Upload it
The import uses the importPageRedirects mutation, which takes a file upload and follows the
GraphQL multipart request spec:
mutation ImportPageRedirects($file: Upload!) {
importPageRedirects(file: $file) {
importedCount
}
}
The script below wraps that call with curl. It is provided only as an example and will not be supported.
#!/usr/bin/env bash
#
# Imports page redirects from a "sourceUrl,destinationUrl" CSV via the
# importPageRedirects mutation (GraphQL multipart request spec).
#
# Run with --help for usage.
set -euo pipefail
DEFAULT_HOST="https://api.bettermode.com/graphql"
usage() {
cat <<EOF
Imports page redirects from a CSV via the importPageRedirects mutation.
Usage:
$0 [options] <csv-path> [host]
Arguments:
<csv-path> CSV of "sourceUrl,destinationUrl" rows. A header row named
sourceUrl,destinationUrl is skipped; rows missing either
column are ignored.
[host] API base URL. Default: ${DEFAULT_HOST}
Options:
-t, --token <token> Admin token. Overrides \$BM_TOKEN.
--token=<token> Same, in = form.
-h, --help Show this help and exit.
Auth:
Needs an admin token with group:update:all / UpdateContent scope. Supply
it via \$BM_TOKEN (preferred) or --token. Note that a token passed as
an argument is visible to other users via 'ps' and lands in your shell
history — prefer the environment variable outside of throwaway shells.
Limits (rejected server-side, nothing is written if the file trips one):
5 MiB per file, 10000 redirects per file.
Examples:
# Test with a small file first, then run the full one.
export BM_TOKEN=...
head -20 redirects.csv > redirects.test.csv
$0 redirects.test.csv
$0 redirects.csv
# Token as an argument, against a non-default host.
$0 --token "\$(cat ~/.bm-token)" redirects.csv https://api.bettermode.com
EOF
}
TOKEN="${BM_TOKEN:-}"
CSV=""
HOST=""
while [[ $# -gt 0 ]]; do
case "$1" in
-h | --help)
usage
exit 0
;;
-t | --token)
if [[ $# -lt 2 ]]; then
echo "$1 requires a value" >&2
exit 2
fi
TOKEN="$2"
shift 2
;;
--token=*)
TOKEN="${1#*=}"
shift
;;
-*)
echo "unknown option: $1" >&2
usage >&2
exit 2
;;
*)
if [[ -z "$CSV" ]]; then
CSV="$1"
elif [[ -z "$HOST" ]]; then
HOST="$1"
else
echo "unexpected argument: $1" >&2
usage >&2
exit 2
fi
shift
;;
esac
done
HOST="${HOST:-$DEFAULT_HOST}"
if [[ -z "$CSV" ]]; then
echo "no CSV path given" >&2
usage >&2
exit 2
fi
if [[ -z "$TOKEN" ]]; then
echo "no token: set BM_TOKEN or pass --token" >&2
exit 1
fi
if [[ ! -f "$CSV" ]]; then
echo "no such file: $CSV" >&2
exit 1
fi
QUERY='mutation ImportPageRedirects($file: Upload!) { importPageRedirects(file: $file) { importedCount } }'
# Field order matters: the upload middleware requires operations, then map, then the files.
curl -i --fail-with-body \
-H "Authorization: Bearer ${TOKEN}" \
-H 'apollo-require-preflight: true' \
-H 'x-apollo-operation-name: ImportPageRedirects' \
-H 'Expect:' \
-F "operations={\"query\":\"${QUERY}\",\"variables\":{\"file\":null}}" \
-F 'map={"0":["variables.file"]}' \
-F "0=@${CSV};type=text/csv" \
"${HOST}/graphql"
Run it
Always dry-run a small slice before importing the full file:
head -20 redirects.csv > redirects.test.csv
./import-page-redirects.sh redirects.test.csv
# Then the full file.
./import-page-redirects.sh redirects.csv
The token comes from $BM_TOKEN, or pass --token <token> to override. Prefer the environment variable —
a token in argv is visible to other users via ps and lands in your shell history.
A successful run returns the number of records written:
{ "data": { "importPageRedirects": { "importedCount": 20 } } }
importedCount counts rows after deduplication, so it can be lower than your line count. That is
expected, not a partial failure.
Re-running an import
Import is an upsert on the path. Re-running the same file is safe and idempotent, and changing a destination then re-importing overwrites the HTTP code, body, and headers for that path. There is no partial-batch state to clean up after a failure.
Bulk import deliberately emits no per-record event, unlike the single-redirect mutation. Anything downstream that listens for page custom response events will not see bulk-imported records.
Redirect a single page
Use this for one-offs, for non-301 codes, or when you need an absolute off-site destination.
mutation UpsertRedirect {
upsertPageCustomResponse(
path: "old-marketing-page"
input: {
httpCode: 301
body: ""
headers: [{ key: "location", value: "/new-marketing-page" }]
}
) {
httpCode
creatorType
headers {
key
value
}
}
}
Only three header names are persisted: location, content-type, and content-disposition. Keys are
lowercased, and anything else is silently dropped with no error — so check that the mutation's response
reflects what you sent.
body and headers values are rendered as Liquid templates against the requesting member's context when
read back, so {{ member.name }} style interpolation works. A literal {{ in a body will be interpreted;
the renderer falls back to the raw string if the template fails to parse.
Edit an existing redirect
Use the same upsertPageCustomResponse mutation. It overwrites the HTTP code, body, and headers for that
path — there is no partial update, so send the full desired state. Omitting headers clears them rather
than leaving them alone.
To confirm the current state before editing, list what exists:
query ListRedirects {
pageCustomResponses(limit: 50, creatorType: Member) {
totalCount
edges {
cursor
node {
httpCode
body
headers {
key
value
}
createdAt
updatedAt
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
Select edges { node }, not nodes — the nodes field exists on the paginated type but is not populated
for this query and comes back null.
creatorType is System, Member, or App, set from the token used to write the record — a bulk import
run with an admin token lands under Member. Omit the argument to see all.
Remove a redirect
mutation DeleteRedirect {
deletePageCustomResponse(path: "old-marketing-page") {
status
}
}
Deleting a path that has no custom response is a no-op and still returns succeeded. There is no bulk
delete — remove paths one at a time, or loop over the results of the list query.
Verify a redirect
Redirects apply to paths that have no space of their own, which is the normal case for a migration:
query VerifyRedirect {
page(path: "old-marketing-page") {
space {
slug
}
customResponse {
httpCode
headers {
key
value
}
}
}
}
Expect:
space.slugto be the fallback not-found space, which is what lets a dead path resolve at all.customResponse.httpCodeto be301.customResponse.headersto contain alocationkey with your destination path.
Troubleshooting
| Symptom | Cause |
|---|---|
The redirects CSV is too large | Over 5 MiB. Split the file. |
The redirects CSV has too many rows | Over 10,000 rows. Split the file. |
Some redirect source URLs are too long | A source path exceeds 500 characters; the message names the offenders. |
importedCount lower than your line count | Deduplication, or rows missing a column. Expected. |
401 or 403 | The token is for a different site, or lacks group:update:all / UpdateContent. |
| Redirect stored but not taking effect | Run the verify query to confirm the read path returns the expected HTTP code and location header. |