Skip to main content

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.

note

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

OperationScopes
Import, upsert, listgroup:update:all or UpdateContent
Deletegroup: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=email and old-page are 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 location header 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,destinationUrl is 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:

LimitValue
File size5 MiB
Rows10,000
Source path length500 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.

import-page-redirects.sh
#!/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.

note

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
}
}
}
caution

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
}
}
}
note

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.slug to be the fallback not-found space, which is what lets a dead path resolve at all.
  • customResponse.httpCode to be 301.
  • customResponse.headers to contain a location key with your destination path.

Troubleshooting

SymptomCause
The redirects CSV is too largeOver 5 MiB. Split the file.
The redirects CSV has too many rowsOver 10,000 rows. Split the file.
Some redirect source URLs are too longA source path exceeds 500 characters; the message names the offenders.
importedCount lower than your line countDeduplication, or rows missing a column. Expected.
401 or 403The token is for a different site, or lacks group:update:all / UpdateContent.
Redirect stored but not taking effectRun the verify query to confirm the read path returns the expected HTTP code and location header.