Create a redirect from a retired URL
Authorizations
A secret API key. Publishable keys cannot reach this API. A key may carry an expiry, and an expired key is refused exactly like an unknown one, with a 401 that names no reason; check the key's expires_at in the dashboard rather than inferring it from a response. When a merchant rolls a key's secret they choose a grace window of up to 3 days, and for its duration BOTH the new secret and the one it replaced authenticate, so an integration moves over on its own deploy schedule instead of at the instant the button is pressed. Move before the window closes: after it, the old secret is refused. Nothing else about this contract moves with a roll. The key keeps its id and its scopes, so the only thing an integration updates is the credential itself.
Headers
A unique key per logical write. Replaying a request with the same key returns the first response byte for byte instead of applying the write twice.
Body
Creates ONE redirect, always recorded with source 'manual'. source is refused BY NAME rather than accepted: it is the only signal separating a bulk-imported row from a hand-made one, and a caller able to label its own rows 'shopify_import' would destroy the question the column exists to answer. The provenance keys (id, store_id, created_at, updated_at) are refused by name too, because the server mints them and your api key already names the store, and so are the wrapper shapes redirect and redirects, because the body IS the redirect and there is no bulk create on this api. FROM_PATH IS IMMUTABLE ONCE STORED, and that shapes how a mistake is repaired. PATCH /api/v1/redirects/{redirectId} moves to_path and status_code and refuses from_path by name, so a source path typed wrongly is fixed by DELETE /api/v1/redirects/{redirectId} followed by a fresh create, which mints a NEW id. store_redirect carries no deleted_at, so that delete removes the row for good. IT DOES NOT CONVERGE OUTSIDE THE IDEMPOTENCY WINDOW. Replaying the Idempotency-Key returns the recorded 201 without re-running anything, but a retry with a FRESH key meets UNIQUE (store_id, from_path) and answers 409 redirect_exists rather than upserting. That is what makes a migration safe to re-run: the second run reports its collisions instead of silently rewriting a target the merchant corrected by hand in between. A null value is refused on every key, since no column on a redirect is nullable and null could not mean "clear it". The whole body is capped at 8192 bytes.
Required. The old URL this store must keep answering. NORMALIZED BEFORE IT IS STORED and before the uniqueness check: any scheme, host, QUERY STRING and fragment are dropped, a leading slash is forced and a trailing slash is trimmed except at the root, so "https://old.example/legacy/" and "legacy" both store as "/legacy". Matching is on the path alone, so two sources differing only in their query string are the SAME row and the second one is a 409 rather than a second redirect. The 2048 cap is measured in bytes, not characters, against the value you send.
2048Required. Where that URL now answers. TWO TARGET FORMS ARE ACCEPTED and one is refused, and the difference is an open-redirect one this table has a history with. Accepted: a store-relative path, which is normalized exactly like from_path, so "new-page" stores as "/new-page"; and an absolute http:// or https:// URL, which is stored VERBATIM on ANY host, deliberately, because retiring a page to another domain is a real merchant case. Refused with 400 invalid_redirect: a value beginning "//" or "/", the protocol-relative form, which targets another host while looking like a path, so a consumer checking for an "http" prefix cannot tell it apart from a store-relative target. That form is the one thing this route refuses that the dashboard still accepts, so a row carrying it can exist from another writer even though no call here can create one. A value carrying any other scheme (javascript:, data:, mailto:) is NOT treated as an external target: it fails the http(s) test, takes a leading slash and is stored as an ordinary path. from_path and to_path must differ AFTER normalization, since a redirect pointing at its own source is a loop.
2048Optional, defaults to 301, which is the right default for the migration this family exists to serve: 301 consolidates ranking signals onto the target and 302 deliberately does not. STRICT HERE, unlike the dashboard, which collapses anything that is not an explicit 302 into 301. 307 and 308, both real redirect statuses another platform may have exported, are a 400 naming the two allowed values rather than a 201 describing a 301 the caller never asked for and could not notice.
301, 302 Response
Created
