'first_frame_url must match image_url': two names, one value

image_url and first_frame_url are aliases on Video Router. Send both only if the strings are identical, same for end_image_url and last_frame_url, or get a 400.

4 min readSume
All posts

On Video Router, image_url and first_frame_url name the same first frame, and end_image_url and last_frame_url name the same last frame. If both names of a pair are present and the two strings differ, the request is refused with "first_frame_url must match image_url when both are provided." or its last-frame twin. Send one name per pair.

The rule, exactly

The check compares the two strings for equality. Two URLs that point at the same picture but differ in a query string, a trailing character or a host alias are different strings and fail. The code does not fetch either URL for this comparison. Where the check fails, the error path is the second name, first_frame_url or last_frame_url, so a client that highlights the offending field will point at the alias rather than at image_url.

Frame alias pairs on POST /v1/video-router/generate on main (read 2026-10-05)
PairBoth present and equalBoth present and different
image_url, first_frame_urlAccepted400: first_frame_url must match image_url when both are provided.
end_image_url, last_frame_urlAccepted400: last_frame_url must match end_image_url when both are provided.
Only one name of a pairAcceptedNot applicable

Why two names exist

The first-frame and last-frame names mirror the frame_type values of the OpenRouter-shaped route, first_frame and last_frame, so a client written against that vocabulary can keep its spelling. The older image_url and end_image_url names were the original flat fields. The normalized request takes whichever is present, preferring image_url and end_image_url.

Pick one name in your client

The simplest fix is to choose a spelling and delete the other from your request builder. The snippet refuses a conflicting pair locally and keeps one key, so you see the problem before a round trip. It prints the final body and sends nothing.

def one_name(body: dict) -> dict:
    out = dict(body)
    for keep, alias in (("image_url", "first_frame_url"),
                        ("end_image_url", "last_frame_url")):
        a, b = out.get(keep), out.pop(alias, None)
        if a is not None and b is not None and a != b:
            raise ValueError(f"{alias} differs from {keep}")
        if a is None and b is not None:
            out[keep] = b
    return out

print(one_name({
    "model": "kling-3",
    "prompt": "A door opens onto a garden",
    "first_frame_url": "https://example.com/door-closed.png",
    "end_image_url": "https://example.com/garden.png",
    "last_frame_url": "https://example.com/garden.png",
}))

Limits

The pair rule says nothing about whether the model takes an end frame at all. Catalog capabilities decide that: grok-imagine-video-1.5 and h3-max-recast have no end frame, while kling-3 and the Seedance and Wan models do. Read capabilities from the model endpoint first. Also keep the URLs public HTTPS, since the frame fields use the public remote URL schema.

A last practical note: normalize URLs before you compare them. If one layer signs a URL and another strips the signature, the strings drift apart without anyone changing the picture, and the 400 looks mysterious. Build the request from one variable per frame and the problem cannot occur. If you must accept both names from outside callers, run the check in your own code so the error message reaches the person who typed the URLs, not a log nobody reads.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume