# 비동기 작업

이미지·영상 작업의 제출, 조회 또는 푸시 수신, 결과 다운로드 방법과 모든 상태·필드의 의미, 그리고 환불 방식.

> https://nezhagate.com/ko/docs/guide/async-jobs

## 비동기로 처리되는 엔드포인트

이미지와 영상 생성은 비동기 작업으로 실행됩니다. 호출하면 작업 ID가 바로 돌아오고, 생성은 백그라운드에서 진행됩니다. 채팅은 동기 방식이라 작업을 쓰지 않습니다.

| 메서드 | 경로 | 설명 |
| --- | --- | --- |
| POST | `/v1/images/generations` | 텍스트-투-이미지. 작업 ID를 바로 돌려줍니다(HTTP 202). |
| POST | `/v1/images/edits` | 이미지-투-이미지. 위 엔드포인트에 `image`를 넣어도 같습니다. |
| GET | `/v1/images/jobs/{id}` | 이미지 작업의 상태와 결과를 조회합니다. |
| POST | `/v1/videos/generations` | 영상 생성. 작업 ID를 바로 돌려줍니다(HTTP 202). |
| GET | `/v1/videos/jobs/{id}` | 영상 작업의 상태와 결과를 조회합니다. |

## 처리 흐름

1. 제출: 생성 엔드포인트를 호출하면 HTTP 202와 작업 `id`가 바로 돌아옵니다(`img_...` 형태이며, 영상 작업도 같은 접두사를 씁니다).

2. 대기: 그 ID로 작업 상태를 주기적으로 조회하거나, 제출할 때 `callback_url`을 넣어 결과를 푸시로 받습니다.

3. 완료: `status`가 `succeeded`가 되면 결과 링크는 `data[0].url`에 있습니다. `failed`가 되면 사유는 `error`에 있고, 이 작업에 예약된 크레딧은 잔액으로 돌아옵니다.

4. 다운로드: 결과 링크는 60일 동안 유지됩니다. 계속 보관해야 하는 파일은 자체 저장소로 옮겨 두세요.

```
curl https://nezhagate.com/v1/videos/generations \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "seedance-2.0", "prompt": "a paper boat drifting down a rainy street", "duration": 5, "size": "16:9"}'

# HTTP 202
{"id": "img_9c1e...7a", "object": "video.generation.job", "status": "queued", "created": 1791199400, "model": "seedance-2.0"}
```

```
curl https://nezhagate.com/v1/videos/jobs/img_9c1e...7a -H "Authorization: Bearer YOUR_API_KEY"
```

## 작업 상태

| 상태 | 의미 |
| --- | --- |
| `queued` | 대기 중. `queue_position`(앞에 있는 작업 수)과 `eta_seconds`(예상 대기 시간)가 함께 옵니다. |
| `processing` | 생성 중. |
| `succeeded` | 완료. 결과는 `data[0].url`, 과금 내역은 `usage`에 있습니다. |
| `failed` | 실패. 사유는 `error`에 있고, 예약된 크레딧은 전액 환불됩니다. |

`succeeded`와 `failed`는 최종 상태이며, 그 뒤로는 바뀌지 않습니다.

## 응답 필드

| 필드 | 의미 |
| --- | --- |
| `id` | 작업 ID. 조회와 웹훅에 쓰입니다. |
| `object` | 이미지 작업은 `image.generation.job`, 영상 작업은 `video.generation.job`입니다. |
| `status` | `queued` / `processing` / `succeeded` / `failed`. |
| `created` | 작업을 제출한 시각(Unix 초). |
| `model` | 제출할 때 쓴 모델 ID. |
| `queue_position` | `queued`일 때만 있음: 앞에 있는 작업 수. |
| `eta_seconds` | `queued`일 때만 있음: 시작까지 남은 예상 시간(초). |
| `data[].url` | `succeeded`일 때만 있음: 결과 파일(이미지 또는 mp4)의 링크. 60일 동안 보관됩니다. |
| `usage` | `succeeded`일 때만 있음: 과금 내역. 이미지는 `images`(실제 전달 장수)와 `resolution`, 영상은 `clips`를 담습니다. |
| `error` | `failed`일 때만 있음: `message`, `type`, `code`, `param`. 원인은 `code`로 판단하세요. |

```
{
  "id": "img_9c1e...7a",
  "object": "video.generation.job",
  "status": "succeeded",
  "created": 1791199400,
  "model": "seedance-2.0",
  "data": [{"url": "https://img.nezhagate.com/i/5d41402abc....mp4"}],
  "usage": {"clips": 1, "model": "seedance-2.0"}
}
```

```
{
  "id": "img_9c1e...7a",
  "object": "video.generation.job",
  "status": "failed",
  "created": 1791199400,
  "model": "seedance-2.0",
  "error": {"message": "...", "type": "invalid_request_error", "code": "content_policy", "param": null}
}
```

## 조회 요령

- 이미지는 2~3초, 영상은 5~10초 간격으로 조회하세요. 조회는 무료입니다.

- `succeeded`나 `failed`가 나오면 바로 조회를 멈추세요.

- 프로그램에 전체 대기 시간 한도를 두세요. 이미지는 보통 수십 초에서 몇 분, 영상은 1~2분에서 1시간 넘게까지 걸립니다(영상이 길거나 대기열이 있으면 더 오래 걸립니다). 일반적인 소요 시간은 모델별 문서에 있습니다. 한도를 넘겨도 다시 제출하지 마세요. 작업은 그대로 남아 있으니 나중에 이어서 조회하면 됩니다.

- 작업은 제출한 계정에 속합니다. 그 계정의 어느 키로든 조회할 수 있고, 다른 계정에서 조회하면 404 `job_not_found`가 돌아옵니다. 작업 기록은 3일 동안 보관되며, 그 뒤에는 작업 ID로 조회해도 404가 돌아옵니다. 다만 결과 링크는 60일 동안 계속 쓸 수 있습니다.

- 제출 요청이 내 쪽에서 시간 초과되어 작업 ID를 받지 못했다면, 다시 제출하기 전에 콘솔의 「호출 로그」에서 작업이 만들어졌는지 확인하세요. 그래야 두 번 과금되지 않습니다.

## 조회 대신 웹훅으로 받기

제출할 때 `callback_url`을 넣으면 작업이 끝나는 즉시(성공이든 실패든) 게이트웨이가 조회 엔드포인트와 똑같은 결과를 그 URL로 보냅니다. 서명이 포함되며, 전송에 실패하면 자동으로 재시도합니다. 콘솔의 「설정 → 웹훅」에서 계정 전체에 쓸 주소를 지정할 수도 있습니다.

```
{"model": "seedance-2.0", "prompt": "...", "duration": 5, "callback_url": "https://example.com/webhooks/nezhagate"}
```

[웹훅 문서 →](https://nezhagate.com/ko/docs/webhooks)

## 과금

- 제출하면 작업 가격만큼 잔액에서 크레딧이 예약됩니다. 잔액이 부족하면 402가 돌아오고 작업은 만들어지지 않습니다.

- 성공하면 실제로 전달된 만큼 정산합니다. 이미지는 전달된 장수로, 영상은 주문한 길이 또는 클립 수(예약한 금액과 같음)로 정산하며, 그보다 많이 예약된 크레딧은 자동으로 돌아옵니다. 실패하면 예약된 크레딧이 전액 돌아옵니다.

- 작업 조회와 웹훅 전송은 무료입니다.

## 결과 보관 기간

`data[0].url`은 당사 저장소의 링크이며 60일 뒤 자동으로 삭제됩니다. 결과를 계속 쓰려면 자체 저장소나 CDN에 내려받아 두세요.

## 작업이 실패하는 이유

실패한 작업은 항상 전액 환불됩니다. 실패한 작업에서 가장 자주 보게 되는 코드는 다음과 같습니다:

| code | 언제 나오나요 | 조치 |
| --- | --- | --- |
| `content_policy` | 영상: 프롬프트, 참조 소재, 또는 생성된 화면이나 소리가 콘텐츠 심사를 통과하지 못했습니다(소리가 걸렸으면 `param`이 `audio`입니다). | 프롬프트를 고쳐 쓰거나 참조 소재를 바꾼 뒤 다시 제출하세요. 같은 입력은 다시 거부됩니다. |
| `moderation_blocked` | 이미지: 모델 제공사의 안전 시스템이 프롬프트나 참조 이미지를 거부했습니다(코드가 `content_policy_violation`일 수도 있습니다). | 프롬프트를 고쳐 쓰거나 참조 이미지를 바꾸세요. 그대로 다시 제출하면 대개 다시 거부됩니다. |
| `render_failed` | 영상: 이번에는 영상이 나오지 않았습니다. 대개 일시적인 현상입니다. | 같은 요청을 그대로 다시 제출하면 대개 성공합니다. |
| `invalid_material` | 영상: 업스트림이 입력 소재에 문제가 있다고 판단했습니다. 포괄적인 오류이며 일시적인 경우가 많습니다. | 먼저 그대로 다시 제출해 보세요. 계속 나오면 참조 소재와 프롬프트 길이를 확인하세요. |
| `render_timeout` | 영상: 대기나 렌더링이 너무 오래 걸려 업스트림이 작업을 포기했습니다. | 다시 제출하세요. |
| `wait_timeout` | 영상: 회선이 허용하는 최대 대기 시간 안에 영상이 나오지 않았습니다. | 다시 제출하세요. |
| `upstream_unstable` | 영상: 렌더링 중에 연결이 끊겼습니다. | 다시 제출하세요. |
| `result_fetch_failed` | 영상은 생성되었지만, 당사 측에서 영상을 내려받거나 저장하는 데 계속 실패했습니다. | 다시 제출하세요. |
| `invalid_request` | 영상: 회선이 이 파라미터 조합(예: 길이, 비율, 해상도)을 받지 않습니다. | `message`에 적힌 대로 파라미터를 조정한 뒤 다시 제출하세요. |
| `upstream_error` | 그 밖의 실패: 모든 회선을 시도했지만 실패했거나, 15분 동안 기다려도 시작되지 않았거나, 업스트림이 이유 없이 거부한 경우 등입니다. 구체적인 내용은 `message`에 있습니다. | 다시 제출하면 대개 성공합니다. |

[전체 오류 코드 목록 →](https://nezhagate.com/ko/docs/guide/errors)

## 전체 예제

제출, 조회, 다운로드를 한 번에 처리하며, 전체 대기 시간 한도와 실패 처리도 들어 있습니다. YOUR_API_KEY만 바꾸면 바로 실행할 수 있습니다.

```
import time
import requests

BASE = "https://nezhagate.com/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}

def run_job(kind, payload, every=5, give_up=5400):
    """kind is "images" or "videos". Returns the finished job (succeeded or failed)."""
    job = requests.post(f"{BASE}/{kind}/generations", headers=HEADERS, json=payload, timeout=120)
    job.raise_for_status()                       # 4xx here: fix the request; nothing was billed
    job_id = job.json()["id"]
    deadline = time.time() + give_up
    while time.time() < deadline:
        time.sleep(every)
        r = requests.get(f"{BASE}/{kind}/jobs/{job_id}", headers=HEADERS, timeout=30).json()
        if r["status"] in ("succeeded", "failed"):
            return r
    raise TimeoutError(f"{job_id} still running; poll it again later, it is not lost")

job = run_job("videos", {"model": "seedance-2.0", "prompt": "a paper boat drifting down a rainy street",
                         "duration": 5, "size": "16:9"})
if job["status"] == "succeeded":
    url = job["data"][0]["url"]
    with open("clip.mp4", "wb") as f:            # keep your own copy: links expire after 60 days
        f.write(requests.get(url, timeout=300).content)
else:
    print(job["error"]["code"], job["error"]["message"])
```
