Capture storefront 404s, triage them, and map them to redirect rules
/admin/api/404-log
List captured 404 entries for the given tab, paginated. Each entry has a path, rolling hit count, first/last-seen timestamps, last referer + user agent, and (if mapped) the redirect rule id and timestamp it was mapped at.
/admin/api/404-log/{id}
Permanently delete a single 404 log entry. Subsequent hits to the same path will create a fresh entry.
/admin/api/404-log/{id}/map
Create a redirect rule from this 404 entry and link the entry to the new rule. The rule's pattern defaults to the entry's captured path (override via `pattern`); the destination is resolved from `target_type` + (`target_id` or `destination`). Soft links to products / categories / pages survive slug changes — the storefront re-resolves them at request time. Responds with the new rule, identical in shape to POST /admin/api/redirect-rules.
/admin/api/404-log/{id}/suppress
Suppress a log entry. The entry stops appearing on the unmapped tab (move to `tab=suppressed` to find it) but the shop keeps incrementing its hit count when fresh 404s arrive. Use this for noisy bot probes you don't want cluttering triage.
/admin/api/404-log/{id}/unsuppress
Reverse a previous suppress. The entry returns to the unmapped tab so it can be triaged again.
/admin/api/404-log/import/preview
Upload a CSV of known-broken URLs as a multipart form (single `file` field) and receive a preview summary — number of rows, how many are new vs. already-tracked, validation errors per row — with no database writes. Maximum upload size is 20 MB. Use this to let the merchant eyeball the import before committing.
/admin/api/404-log/import/confirm
Apply a previously-previewed import. The pipeline re-runs against the freshly uploaded `file` (drift-safe — the merchant uploads the same file again) and inserts new entries in a single transaction. If, after re-running, there is nothing left to import (e.g. another admin imported the same file in the meantime), the server returns 422 with the fresh preview body so the UI can fall back to the preview phase.
/admin/api/404-log/export.csv
Download every row in a tab as a CSV file (no paging). Unlike the JSON list endpoint, the response Content-Type is `text/csv; charset=utf-8` and the body is sent as an attachment (`Content-Disposition: attachment; filename="404-log-<tab>.csv"`). Columns are: `path, hit_count, first_seen_at, last_seen_at, last_referer, last_user_agent, suppressed, mapped_rule_id, mapped_destination`. The `mapped_destination` column holds the literal destination of the linked redirect rule and is populated only for mapped entries; empty optional fields render as empty cells. The resulting file can be fed back into the import endpoint.
/admin/api/404-log
List captured 404 entries for the given tab, paginated. Each entry has a path, rolling hit count, first/last-seen timestamps, last referer + user agent, and (if mapped) the redirect rule id and timestamp it was mapped at.
| Name | Type | Required | Description |
|---|---|---|---|
| tab | string | optional | Which bucket to list: `unmapped` (default), `mapped`, or `suppressed`. |
| limit | integer | optional | Page size, clamped to 1..200. Defaults to 50. |
| offset | integer | optional | Zero-based offset for pagination. Defaults to 0. |
{
"entries": [
{
"id": 12,
"path": "/old-product-page",
"hit_count": 37,
"first_seen_at": "2026-04-21T09:14:00Z",
"last_seen_at": "2026-05-12T22:08:00Z",
"last_referer": "https://www.google.com/",
"last_user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"mapped_rule_id": null,
"mapped_at": null,
"suppressed": false
},
{
"id": 14,
"path": "/promo/spring-2024",
"hit_count": 4,
"first_seen_at": "2026-05-01T11:02:00Z",
"last_seen_at": "2026-05-10T16:44:00Z",
"last_referer": null,
"last_user_agent": "curl/8.4.0",
"mapped_rule_id": null,
"mapped_at": null,
"suppressed": false
}
],
"total": 23
}
curl "https://yourshop.zeroshop.io/admin/api/404-log?tab=unmapped&limit=50" \
-H "Authorization: Bearer zspat_..."
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/404-log",
headers={"Authorization": "Bearer zspat_..."},
params={"tab": "unmapped", "limit": 50}
)
page = resp.json()
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/404-log?tab=unmapped&limit=50",
{ headers: { "Authorization": "Bearer zspat_..." } }
);
const page = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/404-log")
uri.query = URI.encode_www_form(tab: "unmapped", limit: 50)
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
page = JSON.parse(resp.body)
/admin/api/404-log/{id}
Permanently delete a single 404 log entry. Subsequent hits to the same path will create a fresh entry.
curl -X DELETE "https://yourshop.zeroshop.io/admin/api/404-log/12" \
-H "Authorization: Bearer zspat_..."
import requests
requests.delete(
"https://yourshop.zeroshop.io/admin/api/404-log/12",
headers={"Authorization": "Bearer zspat_..."}
)
await fetch("https://yourshop.zeroshop.io/admin/api/404-log/12", {
method: "DELETE",
headers: { "Authorization": "Bearer zspat_..." }
});
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/404-log/12")
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer zspat_..."
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/404-log/{id}/map
Create a redirect rule from this 404 entry and link the entry to the new rule. The rule's pattern defaults to the entry's captured path (override via `pattern`); the destination is resolved from `target_type` + (`target_id` or `destination`). Soft links to products / categories / pages survive slug changes — the storefront re-resolves them at request time. Responds with the new rule, identical in shape to POST /admin/api/redirect-rules.
{
"target_type": "product",
"target_id": 42,
"status_code": 301
}
{
"id": 7,
"pattern": "/old-product-page",
"destination": "/p/new-product-slug",
"status_code": 301,
"priority": 100,
"enabled": true,
"hit_count": 0,
"last_hit_at": null,
"created_at": "2026-05-13T10:00:00Z",
"updated_at": "2026-05-13T10:00:00Z"
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/404-log/12/map" \
-H "Authorization: Bearer zspat_..." \
-H "Content-Type: application/json" \
-d '{"target_type": "product", "target_id": 42, "status_code": 301}'
import requests
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/404-log/12/map",
headers={"Authorization": "Bearer zspat_..."},
json={"target_type": "product", "target_id": 42, "status_code": 301}
)
rule = resp.json()
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/404-log/12/map", {
method: "POST",
headers: {
"Authorization": "Bearer zspat_...",
"Content-Type": "application/json"
},
body: JSON.stringify({ target_type: "product", target_id: 42, status_code: 301 })
});
const rule = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/404-log/12/map")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
req["Content-Type"] = "application/json"
req.body = { target_type: "product", target_id: 42, status_code: 301 }.to_json
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
rule = JSON.parse(resp.body)
/admin/api/404-log/{id}/suppress
Suppress a log entry. The entry stops appearing on the unmapped tab (move to `tab=suppressed` to find it) but the shop keeps incrementing its hit count when fresh 404s arrive. Use this for noisy bot probes you don't want cluttering triage.
curl -X POST "https://yourshop.zeroshop.io/admin/api/404-log/12/suppress" \
-H "Authorization: Bearer zspat_..."
import requests
requests.post(
"https://yourshop.zeroshop.io/admin/api/404-log/12/suppress",
headers={"Authorization": "Bearer zspat_..."}
)
await fetch("https://yourshop.zeroshop.io/admin/api/404-log/12/suppress", {
method: "POST",
headers: { "Authorization": "Bearer zspat_..." }
});
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/404-log/12/suppress")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/404-log/{id}/unsuppress
Reverse a previous suppress. The entry returns to the unmapped tab so it can be triaged again.
curl -X POST "https://yourshop.zeroshop.io/admin/api/404-log/12/unsuppress" \
-H "Authorization: Bearer zspat_..."
import requests
requests.post(
"https://yourshop.zeroshop.io/admin/api/404-log/12/unsuppress",
headers={"Authorization": "Bearer zspat_..."}
)
await fetch("https://yourshop.zeroshop.io/admin/api/404-log/12/unsuppress", {
method: "POST",
headers: { "Authorization": "Bearer zspat_..." }
});
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/404-log/12/unsuppress")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
/admin/api/404-log/import/preview
Upload a CSV of known-broken URLs as a multipart form (single `file` field) and receive a preview summary — number of rows, how many are new vs. already-tracked, validation errors per row — with no database writes. Maximum upload size is 20 MB. Use this to let the merchant eyeball the import before committing.
{
"total_rows": 120,
"new_entries": 98,
"existing_entries": 18,
"invalid_rows": [
{
"row": 7,
"error": "path must start with /"
},
{
"row": 41,
"error": "duplicate path within file"
}
],
"sample": [
{
"path": "/legacy/widgets/red"
},
{
"path": "/legacy/widgets/blue"
},
{
"path": "/blog/2019/launch"
}
]
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/404-log/import/preview" \
-H "Authorization: Bearer zspat_..." \
-F "file=@broken-urls.csv"
import requests
with open("broken-urls.csv", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/404-log/import/preview",
headers={"Authorization": "Bearer zspat_..."},
files={"file": f}
)
preview = resp.json()
const form = new FormData();
form.append("file", fileInput.files[0]);
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/404-log/import/preview", {
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: form
});
const preview = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/404-log/import/preview")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
form = [["file", File.open("broken-urls.csv")]]
req.set_form(form, "multipart/form-data")
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
preview = JSON.parse(resp.body)
/admin/api/404-log/import/confirm
Apply a previously-previewed import. The pipeline re-runs against the freshly uploaded `file` (drift-safe — the merchant uploads the same file again) and inserts new entries in a single transaction. If, after re-running, there is nothing left to import (e.g. another admin imported the same file in the meantime), the server returns 422 with the fresh preview body so the UI can fall back to the preview phase.
{
"inserted": 98,
"skipped_existing": 18,
"skipped_invalid": 4
}
curl -X POST "https://yourshop.zeroshop.io/admin/api/404-log/import/confirm" \
-H "Authorization: Bearer zspat_..." \
-F "file=@broken-urls.csv"
import requests
with open("broken-urls.csv", "rb") as f:
resp = requests.post(
"https://yourshop.zeroshop.io/admin/api/404-log/import/confirm",
headers={"Authorization": "Bearer zspat_..."},
files={"file": f}
)
result = resp.json()
const form = new FormData();
form.append("file", fileInput.files[0]);
const resp = await fetch("https://yourshop.zeroshop.io/admin/api/404-log/import/confirm", {
method: "POST",
headers: { "Authorization": "Bearer zspat_..." },
body: form
});
const result = await resp.json();
require "net/http"
require "json"
uri = URI("https://yourshop.zeroshop.io/admin/api/404-log/import/confirm")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer zspat_..."
form = [["file", File.open("broken-urls.csv")]]
req.set_form(form, "multipart/form-data")
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
result = JSON.parse(resp.body)
/admin/api/404-log/export.csv
Download every row in a tab as a CSV file (no paging). Unlike the JSON list endpoint, the response Content-Type is `text/csv; charset=utf-8` and the body is sent as an attachment (`Content-Disposition: attachment; filename="404-log-<tab>.csv"`). Columns are: `path, hit_count, first_seen_at, last_seen_at, last_referer, last_user_agent, suppressed, mapped_rule_id, mapped_destination`. The `mapped_destination` column holds the literal destination of the linked redirect rule and is populated only for mapped entries; empty optional fields render as empty cells. The resulting file can be fed back into the import endpoint.
| Name | Type | Required | Description |
|---|---|---|---|
| tab | string | optional | Which bucket to export: `unmapped` (default), `mapped`, or `suppressed`. Returns every row in the tab without pagination. |
"path,hit_count,first_seen_at,last_seen_at,last_referer,last_user_agent,suppressed,mapped_rule_id,mapped_destination\n/old-product-page,37,2026-04-21T09:14:00Z,2026-05-12T22:08:00Z,https://www.google.com/,Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36,false,7,/p/new-product-slug\n/promo/spring-2024,4,2026-05-01T11:02:00Z,2026-05-10T16:44:00Z,,curl/8.4.0,false,,\n"
curl "https://yourshop.zeroshop.io/admin/api/404-log/export.csv?tab=unmapped" \
-H "Authorization: Bearer zspat_..." \
-o 404-log-unmapped.csv
import requests
resp = requests.get(
"https://yourshop.zeroshop.io/admin/api/404-log/export.csv",
headers={"Authorization": "Bearer zspat_..."},
params={"tab": "unmapped"}
)
with open("404-log-unmapped.csv", "wb") as f:
f.write(resp.content)
const resp = await fetch(
"https://yourshop.zeroshop.io/admin/api/404-log/export.csv?tab=unmapped",
{ headers: { "Authorization": "Bearer zspat_..." } }
);
const csv = await resp.text();
require "net/http"
uri = URI("https://yourshop.zeroshop.io/admin/api/404-log/export.csv")
uri.query = URI.encode_www_form(tab: "unmapped")
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer zspat_..."
resp = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.write("404-log-unmapped.csv", resp.body)
We value your privacy
We use cookies for essential site functionality and, with your consent, analytics to understand how our platform is used. No personal data is shared with third parties. See our Privacy Policy for details.