Repoint a redirect at a new target
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.
Path Parameters
Body
THE REPAIR ROUTE FOR AN IMPORT THAT LANDED WRONG. A migration writes thousands of rows in one pass and some fraction of them point somewhere the merchant did not mean, because the target came out of another platform's export. Two fields are mutable and the body must name at least one of them. FROM_PATH IS REFUSED BY NAME rather than ignored, and it is the field an integrator reaches for first. It is the redirect's identity and half of UNIQUE (store_id, from_path), and the update statement does not touch it, so a patch that accepted the key and dropped it would answer 200 having done nothing and the client would conclude this API is eventually consistent. Changing a source path is DELETE /api/v1/redirects/{redirectId} plus a fresh create, which mints a NEW id, so anything you keyed on the old one has to be updated too. source is refused for the create's reason: it separates an imported row from a hand-made one and a caller that could label its own rows would destroy the question the column answers. The provenance keys (id, store_id, created_at, updated_at) are refused because the server owns them and the path already names the row, and so are the wrapper shapes redirect and redirects, because the body IS the redirect and there is no bulk update on this api. AN EMPTY BODY IS A 400, not a no-op. The update sets updated_at unconditionally, so an empty patch would move the one column an integrator syncs on while changing nothing observable, and answer 200 for it. IT CONVERGES, unlike the create beside it. Both values are absolute rather than deltas, so a retry with a FRESH key past the idempotency window sets the same row to the same values and answers 200 again. There is no second row to mint here, because the row is named by the path. NO MONEY AND NO STOCK MOVE. What moves is where a retired URL sends shoppers and where search engines pass ranking signals, and it moves IMMEDIATELY: the storefront looks a path up when the request arrives and nothing in this module caches the row, so the next visitor follows the new target. Nothing is emitted, because this family registers no webhook topics, so a client mirroring redirects must poll. A null is refused on both keys, since no column on a redirect is nullable and null could not mean "clear it". The whole body is capped at 8192 bytes, and an id naming no redirect in this store is a generic 404.
Where this URL now answers, validated EXACTLY as on the create, which is the security half of this route. Accepted: a store-relative path, normalized so "new-page" stores as "/new-page"; and an absolute http:// or https:// URL, 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. Admitting it here would reopen through the update path the hole the create refuses, and on the MORE valuable row: an existing redirect is already indexed, already linked, and nothing about it looks new. The loop check compares the normalized value against this redirect's STORED from_path, which cannot be changed, so a target equal to it is 400 invalid_redirect and nothing is written. Omit the key to leave the target alone.
2048Omitting it KEEPS THE STORED VALUE, which is the one place this route differs from the create, where an absent status_code means 301. Defaulting here would silently demote to 301 every 302 whose target a client updated, and it would do it on the one field an SEO audit reads. 307 and 308 are a 400 naming the two allowed values rather than a 200 describing a 301 the caller never asked for.
301, 302 Response
Success
