all dispatches
Sep 22, 202612 min read

Nano Banana 2 Image Editing API: A Working Integration

A working Nano Banana 2 image editing API integration: request shape, accepted inputs and common pitfalls.

Nano Banana 2 Image Editing API: A Working Integration

A request can be valid JSON and still be wrong for Nano Banana 2.

On each::labs, the image-editing model is nano-banana-2-edit. You create a job through POST /v1/prediction, with the edit parameters inside input.

Before making that POST, fetch the model's request_schema and validate the payload against it. That lets your backend catch an invalid edit before creating a prediction.

Quick answer: Nano Banana 2 image editing on each::labs uses nano-banana-2-edit. Create the job with POST /v1/prediction, validate input against the model's current request_schema before submission, then poll GET /v1/prediction/{id} until it reaches a terminal state.

The full path is:

local PNG
  ↓
upload
  ↓
public URL
  ↓
fetch request_schema
  ↓
validate input
  ↓
create prediction
  ↓
poll
  ↓
edited image or actionable error

Already have a public image URL? Skip the upload step.

A model is a contract that happens to have a fruit's name.
A model is a contract that happens to have a fruit's name.

The shortest working Nano Banana 2 Edit request

The model slug is:

nano-banana-2-edit

Create the prediction at:

POST https://api.eachlabs.ai/v1/prediction

Prediction requests use Bearer authentication:

Authorization: Bearer YOUR_API_KEY

The current Nano Banana 2 Edit model page shows the input shape below. This example keeps that shape intact and drops the deprecated version field:

curl -X POST https://api.eachlabs.ai/v1/prediction \
  -H "Authorization: Bearer $EACHLABS_API_KEY" \
  -H "Content-Type: application/json" \
  --data '{
    "model": "nano-banana-2-edit",
    "input": {
      "prompt": "Replace the background with a clean light-gray studio wall while keeping the subject unchanged.",
      "num_images": 1,
      "aspect_ratio": "16:9",
      "output_format": "png",
      "image_urls": [
        "https://cdn-us.eachlabs.ai/defaults/58dc9f15ff189c5e6d4b757636e54f884a060b38ee8c92273545b1a00692d9a1.png"
      ],
      "resolution": "1K",
      "limit_generations": true
    }
  }'

The POST creates the prediction. It does not return the finished image:

{
  "status": "success",
  "message": "Prediction created successfully",
  "predictionID": "abc123-def456-ghi789"
}

Keep the predictionID. You will use it to fetch the result.

Why the request omits version

You may still run into each::labs examples containing:

"version": "0.0.1"

The current machine-readable Create Prediction contract marks version as deprecated and ignored. The required top-level request fields are model and input.

New integration code should omit it.

This is also a good reason not to treat an old request example as a permanent model contract. The live API is a better source of truth.

What Nano Banana 2 Edit puts inside input

The current working model example exposes seven fields:

Field JSON shape in the current example What it represents
prompt string The edit instruction
num_images integer value Number of requested results
aspect_ratio string Requested output aspect ratio
output_format string Output image format
image_urls array Source/reference image URLs
resolution string Requested output resolution
limit_generations boolean Model-specific generation option exposed by the current contract/example

That table is useful when you are reading the request. It is not what I would hard-code as the application's validation layer.

The live model object contains request_schema, which each::labs documents as JSON Schema defining valid model input.

Fetch it with:

curl https://api.eachlabs.ai/v1/models/nano-banana-2-edit

To inspect only the schema:

curl -s https://api.eachlabs.ai/v1/models/nano-banana-2-edit \
  | jq '.request_schema'

To see which fields the current schema marks as required:

curl -s https://api.eachlabs.ai/v1/models/nano-banana-2-edit \
  | jq '.request_schema | {
      required,
      properties
    }'

each::labs also exposes a per-model OpenAPI document:

GET /v1/models/nano-banana-2-edit/schemas/openapi

It is generated from the same model input contract.

The distinction matters. An article can tell you what the model accepted when the article was written. The API can tell your application what it accepts now.

A key that almost fits opens nothing.
A key that almost fits opens nothing.

Validate the request before you create a prediction

For each::api, Invalid input means the model's input does not match its request_schema.

Waiting and submitting the same payload again will not change that.

For Node.js, AJV is a straightforward way to run the check locally:

npm install ajv ajv-formats

Fetch and compile the current schema:

import Ajv from "ajv";
import addFormats from "ajv-formats";

const BASE_URL = "https://api.eachlabs.ai";
const MODEL = "nano-banana-2-edit";

async function getModelSchema() {
  const response = await fetch(`${BASE_URL}/v1/models/${MODEL}`);

  if (!response.ok) {
    throw new Error(
      `Could not load model schema: ${response.status} ${response.statusText}`
    );
  }

  const model = await response.json();

  if (!model.request_schema) {
    throw new Error(`${MODEL} did not return request_schema`);
  }

  return model.request_schema;
}

function createInputValidator(schema) {
  const ajv = new Ajv({
    allErrors: true,
    strict: false
  });

  addFormats(ajv);

  return ajv.compile(schema);
}

Now build the same input object you intend to submit:

const input = {
  prompt:
    "Replace the background with a clean light-gray studio wall. " +
    "Keep the subject unchanged.",
  num_images: 1,
  aspect_ratio: "16:9",
  output_format: "png",
  image_urls: [
    "https://cdn-us.eachlabs.ai/defaults/58dc9f15ff189c5e6d4b757636e54f884a060b38ee8c92273545b1a00692d9a1.png"
  ],
  resolution: "1K",
  limit_generations: true
};

const schema = await getModelSchema();
const validate = createInputValidator(schema);

if (!validate(input)) {
  console.error("Nano Banana 2 input is invalid:");

  for (const error of validate.errors ?? []) {
    console.error(
      `- ${error.instancePath || "/"} ${error.message}`
    );
  }

  process.exit(1);
}

The library is interchangeable. The behavior is not:

construct input
      ↓
validate against current request_schema
      ↓
invalid ──→ reject locally
      ↓
valid
      ↓
create prediction

Canberk's rule for integration work is:

Canberk’s POV

“Expertise isn't demonstrated by making the customer learn your vocabulary.”

That is a useful test for validation errors too.

INVALID_INPUT tells the caller what your system decided. A field path and the failed rule tell them what they need to change.

Return validation errors your caller can use

AJV already exposes structured errors:

function formatValidationErrors(errors = []) {
  return errors.map((error) => ({
    path: error.instancePath || "/",
    message: error.message,
    keyword: error.keyword,
    params: error.params
  }));
}

Your own API can return those before it creates a generation:

if (!validate(input)) {
  return {
    ok: false,
    error: "INVALID_NANO_BANANA_2_INPUT",
    validation_errors: formatValidationErrors(validate.errors)
  };
}

That moves a predictable integration failure into your own validation path, where it is cheaper and easier to explain.

A local file is not an input yet. Give it an address.
A local file is not an input yet. Give it an address.

From a local image to image_urls

The first example starts with a public source URL. A file on disk needs one extra step.

each::labs exposes:

POST /v1/upload/presign

Send the file's MIME type:

{
  "content_type": "image/png",
  "file_type": "image"
}

content_type is required by the current upload contract. file_type is an optional high-level category.

A successful response includes, among other fields:

{
  "presigned_url": "https://...",
  "public_url": "https://cdn-us.eachlabs.ai/uploads/...",
  "required_headers": {
    "x-amz-meta-file-id": "..."
  }
}

PUT the raw bytes to presigned_url, including every header returned in required_headers. Then pass public_url to Nano Banana 2.

./portrait.png
      ↓
POST /v1/upload/presign
      ↓
presigned_url
public_url
required_headers
      ↓
PUT raw PNG bytes
      ↓
input.image_urls = [public_url]

Upload a PNG from Node.js

This example deliberately uses a PNG source. It does not try to define every supported image-media constraint outside the live model contract.

import { readFile } from "node:fs/promises";

async function uploadPng(apiKey, filePath) {
  const contentType = "image/png";

  const createResponse = await fetch(
    "https://api.eachlabs.ai/v1/upload/presign",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        content_type: contentType,
        file_type: "image"
      })
    }
  );

  if (!createResponse.ok) {
    throw new Error(
      `Could not create upload: ${createResponse.status} ${await createResponse.text()}`
    );
  }

  const upload = await createResponse.json();
  const file = await readFile(filePath);

  const putResponse = await fetch(upload.presigned_url, {
    method: "PUT",
    headers: {
      "Content-Type": contentType,
      ...(upload.required_headers ?? {})
    },
    body: file
  });

  if (!putResponse.ok) {
    throw new Error(
      `Image upload failed: ${putResponse.status} ${putResponse.statusText}`
    );
  }

  return upload.public_url;
}

Use the public_url the upload API gives you. Do not construct the CDN path yourself.

Full working Nano Banana 2 image-editing integration

Now put the pieces together:

read PNG
  ↓
upload
  ↓
fetch request_schema
  ↓
build input
  ↓
validate
  ↓
POST prediction
  ↓
predictionID
  ↓
poll
  ↓
output

The example uses Node.js with native fetch and AJV for JSON Schema validation.

Install the dependencies:

npm install ajv ajv-formats

Save this as nano-banana-2-edit.mjs:

import { readFile } from "node:fs/promises";
import Ajv from "ajv";
import addFormats from "ajv-formats";

const BASE_URL = "https://api.eachlabs.ai";
const MODEL = "nano-banana-2-edit";
const API_KEY = process.env.EACHLABS_API_KEY;

if (!API_KEY) {
  throw new Error(
    "Missing EACHLABS_API_KEY environment variable"
  );
}

const [filePath, prompt] = process.argv.slice(2);

if (!filePath || !prompt) {
  console.error(
    'Usage: node nano-banana-2-edit.mjs ./input.png "Your edit instruction"'
  );
  process.exit(1);
}

if (!filePath.toLowerCase().endsWith(".png")) {
  throw new Error(
    "This example expects a PNG input file. " +
    "Use the correct MIME type if you adapt it for another format."
  );
}

async function parseResponse(response) {
  const text = await response.text();

  if (!text) {
    return null;
  }

  try {
    return JSON.parse(text);
  } catch {
    return text;
  }
}

async function eachlabsRequest(path, options = {}) {
  const response = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      ...(options.body
        ? { "Content-Type": "application/json" }
        : {}),
      ...(options.headers ?? {})
    }
  });

  const body = await parseResponse(response);

  if (!response.ok) {
    const message =
      body && typeof body === "object"
        ? [body.error, body.details]
            .filter(Boolean)
            .join(": ")
        : String(body ?? response.statusText);

    const error = new Error(
      `each::labs ${response.status}: ${message}`
    );

    error.status = response.status;
    error.body = body;
    error.retryAfter =
      response.headers.get("retry-after");

    throw error;
  }

  return body;
}

async function getModelSchema() {
  const model = await eachlabsRequest(
    `/v1/models/${MODEL}`
  );

  if (!model?.request_schema) {
    throw new Error(
      `${MODEL} did not return a request_schema`
    );
  }

  return model.request_schema;
}

async function uploadLocalImage(filePath) {
  const contentType = "image/png";

  const upload = await eachlabsRequest(
    "/v1/upload/presign",
    {
      method: "POST",
      body: JSON.stringify({
        content_type: contentType,
        file_type: "image"
      })
    }
  );

  const file = await readFile(filePath);

  const uploadResponse = await fetch(
    upload.presigned_url,
    {
      method: "PUT",
      headers: {
        "Content-Type": contentType,
        ...(upload.required_headers ?? {})
      },
      body: file
    }
  );

  if (!uploadResponse.ok) {
    throw new Error(
      `Upload failed: ${uploadResponse.status} ${uploadResponse.statusText}`
    );
  }

  if (!upload.public_url) {
    throw new Error(
      "Upload completed but public_url was missing"
    );
  }

  return upload.public_url;
}

function validateInput(schema, input) {
  const ajv = new Ajv({
    allErrors: true,
    strict: false
  });

  addFormats(ajv);

  const validate = ajv.compile(schema);

  if (!validate(input)) {
    const details = (validate.errors ?? [])
      .map((error) => {
        const path = error.instancePath || "/";
        return `${path} ${error.message}`;
      })
      .join("\n");

    throw new Error(
      `Nano Banana 2 input failed local validation:\n${details}`
    );
  }
}

async function createPrediction(input) {
  const result = await eachlabsRequest(
    "/v1/prediction",
    {
      method: "POST",
      body: JSON.stringify({
        model: MODEL,
        input
      })
    }
  );

  if (!result?.predictionID) {
    throw new Error(
      "Prediction was accepted without a predictionID"
    );
  }

  return result.predictionID;
}

function predictionErrorMessage(prediction) {
  const output = prediction?.output;

  if (
    output &&
    typeof output === "object" &&
    !Array.isArray(output)
  ) {
    const code = output.error_code;
    const message = output.error_message;

    if (code || message) {
      return [code, message]
        .filter(Boolean)
        .join(": ");
    }
  }

  if (typeof output === "string" && output) {
    return output;
  }

  if (prediction?.logs) {
    return prediction.logs;
  }

  return "Prediction failed without a detailed error message";
}

async function waitForPrediction(
  predictionID,
  {
    intervalMs = 2000,
    timeoutMs = 180000
  } = {}
) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const prediction = await eachlabsRequest(
      `/v1/prediction/${predictionID}`
    );

    if (prediction.status === "success") {
      return prediction;
    }

    if (prediction.status === "error") {
      throw new Error(
        `Prediction failed: ${predictionErrorMessage(prediction)}`
      );
    }

    if (prediction.status === "cancelled") {
      throw new Error(
        `Prediction ${predictionID} was cancelled`
      );
    }

    if (
      !["created", "starting", "processing"].includes(
        prediction.status
      )
    ) {
      throw new Error(
        `Unknown prediction status: ${prediction.status}`
      );
    }

    console.log(
      `Prediction status: ${prediction.status}`
    );

    await new Promise((resolve) =>
      setTimeout(resolve, intervalMs)
    );
  }

  throw new Error(
    `Prediction ${predictionID} did not finish within ${timeoutMs}ms`
  );
}

async function main() {
  console.log("Uploading source image...");
  const imageUrl =
    await uploadLocalImage(filePath);

  console.log("Loading current model schema...");
  const schema = await getModelSchema();

  const input = {
    prompt,
    num_images: 1,
    aspect_ratio: "16:9",
    output_format: "png",
    image_urls: [imageUrl],
    resolution: "1K",
    limit_generations: true
  };

  console.log(
    "Validating Nano Banana 2 input..."
  );
  validateInput(schema, input);

  console.log("Creating prediction...");
  const predictionID =
    await createPrediction(input);

  console.log(
    `Prediction ID: ${predictionID}`
  );

  const prediction =
    await waitForPrediction(predictionID);

  console.log("Prediction succeeded:");
  console.log(
    JSON.stringify(prediction.output, null, 2)
  );
}

main().catch((error) => {
  console.error(error.message);

  if (error.status === 429) {
    if (error.retryAfter) {
      console.error(
        `Server Retry-After: ${error.retryAfter}`
      );
    }

    if (error.body?.details) {
      console.error(error.body.details);
    }
  }

  process.exit(1);
});

Run it with:

export EACHLABS_API_KEY="YOUR_API_KEY"

node nano-banana-2-edit.mjs \
  ./portrait.png \
  "Replace the background with a modern concrete studio. Keep the subject unchanged."

You provide three things: the API key, the PNG, and the edit instruction.

The API paths and model slug are not placeholders.

The script also deliberately does not auto-retry createPrediction().

Poll on a rhythm. Not on a hope.
Poll on a rhythm. Not on a hope.

Poll the prediction correctly

A successful POST /v1/prediction means the job was created. It does not mean the edit is finished.

The documented prediction states are:

created
starting
processing
success
error
cancelled

created, starting, and processing are non-terminal. success, error, and cancelled end the prediction.

That leaves two different failure paths to handle.

The API rejects the submission

You may get:

400
401
402
404
429
500

In that case, you do not have a usable prediction result.

The prediction is accepted, then fails

The create call returns predictionID, but polling later can produce:

{
  "status": "error",
  "output": {
    "error_code": "...",
    "error_message": "..."
  }
}

An HTTP error handler cannot catch a failure that occurs after the HTTP request has already succeeded. The polling path has to deal with it separately.

Put a deadline around polling

Do not leave the poller running forever.

The full example uses:

timeoutMs = 180000

Three minutes is an application choice in this example, not a documented Nano Banana 2 limit.

If that deadline expires, the only thing you know is that your application stopped waiting. It does not prove the upstream generation failed.

The Nano Banana 2 errors worth handling explicitly

The useful question is not just which status code came back. It is whether anything would be different if you tried again.

Symptom What it means Retry unchanged? Action
400 Invalid input Model input does not match request_schema No Fix and revalidate the payload
Other 400 Missing/invalid request values or malformed JSON No Correct the request
401 Missing or invalid API key No Fix Bearer authentication
402 Balance cannot cover the request / in-flight reservations No Top up or wait for active work to settle
404 Invalid model/resource/prediction identifier No Correct the identifier
429 Prediction concurrency cap reached Later Wait for a slot; surface details
Explicit 500 Unexpected server-side failure Potentially Follow a bounded transient-error policy
Prediction status: "error" Execution failed after the job was accepted Depends Read the prediction error before deciding

400 Invalid input: fix it before you retry it

This is the failure the schema check should remove from your normal prediction path.

Do not do this:

tryAgain(samePayload);

Do this:

const schema = await getModelSchema();

validateInput(schema, input);

await createPrediction(input);

The contents are wrong. Time will not repair them.

401: fix authentication

Use:

Authorization: Bearer YOUR_API_KEY

Retrying with the same invalid credential only repeats the failure.

Keep the key on your backend rather than embedding it in browser code.

402: something about the balance has to change

A prediction can return 402 when the requested work exceeds the available balance, including estimated costs reserved by predictions already in flight.

The documented remedies are to add balance or wait for active work to settle.

Backoff alone does neither.

404: check the slug or prediction ID

The model slug for this integration is:

nano-banana-2-edit

The same status can also mean the prediction ID does not exist.

Fix the identifier rather than retrying it.

429: this is a concurrency condition

For predictions, the current error reference defines 429 as an account concurrency-cap failure, not a generic requests-per-second quota.

A rejected request creates no prediction and is not billed.

Read the returned details field because it describes the cap that applied. If Retry-After is present, honor it. In the normal concurrency case, the meaningful event is an existing prediction reaching a terminal state and releasing its slot.

500: retry policy needs context

The current error guide recommends exponential backoff for transient 500 responses. Prediction creation adds one complication: the public POST /v1/prediction contract does not expose an idempotency key.

Canberk puts the underlying problem this way:

Canberk’s POV

“The semantics have to come before the pattern. A retry here isn't a retry — it's a second purchase of a different product.”

For a validation error, the answer is easy: fix the request.

For an ambiguous creation failure, do not hide the decision inside a generic catch → retry three times wrapper unless your application knows whether another POST could create duplicate work.

A later model-execution failure is different again. Another generation may be appropriate, but that is a product decision as much as an infrastructure one.

A production preflight checklist

Before sending a Nano Banana 2 Edit request from your backend:

  • authenticate with Bearer auth and keep the API key server-side;
  • load the current request_schema and validate the exact input object you intend to submit;
  • for a local PNG, upload it first, include every returned required_headers value, and use the returned public_url;
  • leave the deprecated version field out of new prediction code;
  • keep submission failures separate from terminal prediction failures;
  • put a deadline around polling and only retry when the underlying failure can plausibly change.

One rule covers most of the avoidable failures:

Do not use the prediction endpoint as your input validator.

The contract is available before execution.

FAQ

What is the Nano Banana 2 image-editing model slug?

Use:

nano-banana-2-edit

The broader Nano Banana 2 family also contains a separate text-to-image model.

Can I edit a local image?

Yes. Upload it first through:

POST /v1/upload/presign

PUT the raw file bytes to the returned presigned_url, include the returned required_headers, then pass public_url in image_urls.

What does Invalid input mean?

For each::api predictions, it means the model input does not match its request_schema.

Fetch the current schema, validate the request locally, fix the failing fields, then submit.

Does Nano Banana 2 require version?

The current machine-readable prediction contract does not. version is deprecated and ignored, even though some lagging examples may still display it.

Why can the POST succeed and the image generation still fail?

Prediction creation and model execution happen at different stages.

The POST creates the job and returns predictionID. Model execution continues asynchronously, so polling can later return success, error, or cancelled.

That is why the integration needs both HTTP error handling and prediction-status handling.

About the author

Canberk Sinangil

Co-founder & CTO, each::labs

I’m the Co-Founder and CTO of each::labs, where I focus on building the infrastructure and tooling that help developers bring AI models into production. My background spans computer vision, augmented reality, machine learning, and software engineering, including building AR and visual AI products before moving deeper into generative AI. I’m particularly interested in the engineering challenges behind making powerful AI models fast, scalable, and practical for real-world products.

LinkedIn · X

Ready to build the workflow around the model call?

Build your Workflow on each::labs