An async Firefly call is three HTTP requests, not one. You submit to a -async endpoint, you poll the statusUrl that comes back until the job reaches a terminal state, then you read image URLs from result.outputs.

Three mistakes come straight from the official pages. Polling with the wrong headers, hard-coding the status host, and reading outputs at the top level of the status response.

Why the Firefly API went asynchronous

Adobe explains the change in one sentence: Our original Firefly APIs operated in a synchronous fashion. The old call held the connection open until assets existed, which is fine for one image and hostile to a batch. The async version answers immediately with a job handle, so your worker can submit several jobs and poll them together.

Credentials match the synchronous calls; our integration guide covers them. That is the concrete win Adobe names in its expand example: Instead of doing one resize after another, we can kick off multiple jobs at once so we can resize an image much more efficiently.

The five async operations and the submit call

Adobe lists five: Generate Image Async, Expand Image Async, Fill Image Async, Generate Object Composite Async, Generate Similar Images Async. The workflow is identical for all five.

Which endpoint to POST to

The image endpoint is POST https://firefly-api.adobe.io/v3/images/generate-async. Expand is POST https://firefly-api.adobe.io/v3/images/expand-async. Uploads for image-to-image work go to POST https://firefly-api.adobe.io/v2/storage/image, and those uploaded assets are valid for 7 days. Image5 generation uses a separate path, /v4/images/generate-async, and the Image 5 walkthrough covers that model. A custom model changes one field on this same endpoint; the Custom Models walkthrough covers it.

# Submit a generation job and keep the statusUrl.
export FIREFLY_SERVICES_CLIENT_ID=<your-client-id>
export FIREFLY_SERVICES_CLIENT_SECRET=<your-client-secret>

TOKEN=$(curl -s -X POST 'https://ims-na1.adobelogin.com/ims/token/v3' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -d 'grant_type=client_credentials' \
  -d "client_id=${FIREFLY_SERVICES_CLIENT_ID}" \
  -d "client_secret=${FIREFLY_SERVICES_CLIENT_SECRET}" \
  -d 'scope=openid,AdobeID,read_organizations,firefly_enterprise,firefly_api,ff_apis' \
  | jq -r .access_token)

curl -s -X POST 'https://firefly-api.adobe.io/v3/images/generate-async' \
  -H 'Content-Type: application/json' \
  -H "x-api-key: ${FIREFLY_SERVICES_CLIENT_ID}" \
  -H "Authorization: Bearer ${TOKEN}" \
  -d '{
    "prompt": "a cat living their best life, sleeping in a sunbeam",
    "contentClass": "art"
  }' | jq -r .statusUrl > firefly-status-url.txt

The submit response is three fields

The documented answer to a successful submit is short.

{
  "jobId": "urn:ff:jobs:eso851211:86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac",
  "statusUrl": "https://firefly-api.adobe.io/v3/status/urn:ff:jobs:eso851211:86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac",
  "cancelUrl": "https://firefly-api.adobe.io/v3/cancel/urn:ff:jobs:eso851211:86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac"
}

Adobe’s guidance is explicit about which to use: you can use statusUrl and cancelUrl to get the latest status of your request or to cancel the request. The jobId is also use for logging.

Why the status host is not fixed

The two official pages disagree about the host, and both are correct. The async guide shows firefly-api.adobe.io/v3/status/...; the Custom Models tutorial shows firefly-epo852211.adobe.io/v3/status/.... Jobs are served from the tenant that owns them. Send the statusUrl back exactly as received; do not rebuild it from the submit host.

Polling the status URL

The documented loop is short. Adobe’s own version polls every second: if (status !== 'succeeded' && status !== 'failed') await delay(1000);. Note which headers it sends — only Authorization and x-api-key, with no Content-Type, because the poll is a bare GET.

Which status values are terminal

Three states appear in the official samples. succeeded and failed end the loop; running does not.

{ "status": "running", "jobId": "86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac" }
{
  "status": "succeeded",
  "jobId": "urn:ff:jobs:eso851211:86ffe2ea-d765-4bd3-b2fd-568ca8fc36ac",
  "result": {
    "size": { "width": 2048, "height": 2048 },
    "outputs": [
      {
        "seed": 2142812600,
        "image": {
          "url": "https://pre-signed-firefly-prod.s3-accelerate.amazonaws.com/images/example"
        }
      }
    ],
    "contentClass": "art",
    "altText": "A futuristic city glowing at night with neon lights and flying cars streaking across a dark sky."
  }
}
State machine diagram for an asynchronous Firefly job: submit returns a statusUrl, polling repeats while running, and the job ends in succeeded or failed

The failure branch carries no documented shape beyond status, which is why the polling functions in Adobe’s samples either raise or return the raw string. Treat an unrecognised state as running and keep polling, but bound the loop so a stuck job cannot spin forever.

Reading the result payload

This is the field-nesting trap. jobId, statusUrl and cancelUrl sit at the top level of the submit response, but on success the assets sit under result: result.outputs[].image.url, with result.size and result.altText alongside.

The Quickstart page reads job_response['outputs'][0]['image']['url'] directly off the response. For an asynchronous call that key is not there; the async guide and the polling samples all go through result. Read the nesting from the async guide.

import os
import time
from pathlib import Path

import requests

CLIENT_ID = os.environ['FIREFLY_SERVICES_CLIENT_ID']
TOKEN = os.environ['FIREFLY_SERVICES_ACCESS_TOKEN']
HEADERS = {'x-api-key': CLIENT_ID, 'Authorization': f'Bearer {TOKEN}'}


def submit(prompt, content_class='photo'):
    response = requests.post(
        'https://firefly-api.adobe.io/v3/images/generate-async',
        headers={**HEADERS, 'Content-Type': 'application/json'},
        json={'prompt': prompt, 'contentClass': content_class},
        timeout=60,
    )
    response.raise_for_status()
    return response.json()['statusUrl']


def poll(status_url, interval=5, max_attempts=60):
    """Poll until the job reaches a terminal state, then return the payload."""
    for _ in range(max_attempts):
        response = requests.get(status_url, headers=HEADERS, timeout=30)
        response.raise_for_status()
        payload = response.json()
        status = payload.get('status')
        if status == 'succeeded':
            return payload
        if status == 'failed':
            raise RuntimeError(f'job failed: {payload}')
        time.sleep(interval)
    raise TimeoutError(f'job still running after {max_attempts} polls')


def download(payload, directory='./out'):
    Path(directory).mkdir(exist_ok=True)
    for output in payload['result']['outputs']:
        image = requests.get(output['image']['url'], timeout=60)
        image.raise_for_status()
        path = Path(directory) / f"{output['seed']}.jpg"
        path.write_bytes(image.content)
    return directory


print(download(poll(submit('a cat sleeping in a sunbeam'))))

The seed in each output is worth storing. It is part of what makes a result reproducible, and it is the only per-image identifier Adobe returns.

429, retry-after and fan-out

Rate limits are per organization: 4 requests per minute and 9,000 requests per day. Our API pricing breakdown covers how those are counted. Exceeding either produces HTTP 429 Too Many Requests, and Adobe’s first remedy is to Review your usage and reduce unnecessary requests.

Backing off without guessing

Adobe names the mechanism without publishing numbers: Implement retry logic via a retry-after HTTP header or an exponential backoff strategy. So read the header when it is present and fall back to your own backoff when it is not. Every generation endpoint in the reference lists 429 among its status codes; the Image5 endpoint lists a wider set that also includes 401, 409 and 503.

import time

import requests


def post_with_backoff(url, headers, payload, attempts=5, cap=60.0):
    """Honour retry-after when the header is present, then back off."""
    delay = 1.0
    for _ in range(attempts):
        response = requests.post(url, headers=headers, json=payload, timeout=60)
        if response.status_code == 429:
            wait = float(response.headers.get('retry-after', delay))
            time.sleep(min(wait, cap))
            delay = min(delay * 2, cap)
            continue
        response.raise_for_status()
        return response.json()
    raise RuntimeError('still rate limited after retries')

Fan-out is the other half of the strategy: the expand sample fires three sizes with Promise.all, then polls each statusUrl. Four requests per minute is the budget, so thirty jobs are a scheduling problem, not a concurrency one. Adobe does not publish a concurrency cap, a result-URL lifetime or a queueing policy, so treat all three as not published by the vendor.

Frequently asked questions

Which endpoint starts an async job? POST https://firefly-api.adobe.io/v3/images/generate-async for images, /v4/images/generate-async for Image5, and POST /v3/images/expand-async for expansion.

Which statuses end the loop? succeeded and failed. running means keep polling.

Where are the image URLs? Under result.outputs[].image.url in the success payload — not at the top level of the response.

Can I poll faster than once per second? Adobe publishes no interval at all, only the example of once per second. Pick an interval that fits inside the 4 RPM budget across all your workers.

What should I do on a 429? Read retry-after when the response carries it, otherwise back off exponentially, and reduce the number of in-flight submits.