> ## Documentation Index
> Fetch the complete documentation index at: https://docs.widerouter.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 콜백을 통한 결과 전달

> 모든 작업에 callback_url을 지정하면 WideRouter가 완료된 결과를 사용자에게 게시합니다. 모든 모델, 이미지 및 동영상에서 작동하며, 폴링 루프와 수신 측의 API 키가 필요하지 않습니다.

비동기 API의 모든 작업은 두 가지 방식으로 전달할 수 있습니다. 작업이 완료될 때까지
`GET /v1/task/{task_id}`을 폴링하거나, WideRouter에
`https` URL을 전달하여 결과가 사용자에게 오도록 할 수 있습니다. 두 번째 방식이 콜백이며, 이는 특정 모델이 아니라 작업 API 자체의 기능입니다. 따라서 Nano Banana 이미지와 Grok Imagine
동영상 모두 동일한 필드, 동일한 페이로드 및 동일한 규칙이 적용됩니다.

<Info>
  **폴링을 사용하려면 모든 요청에 API 키가 필요합니다. 콜백에는 API 키가 필요하지 않습니다.** 수신기는 사용자가 호스팅하는 URL이며, WideRouter가 해당 URL을 호출하므로 로드하거나 전달하거나 잘못 입력할 인증 정보가 없습니다. 폴러가 `401`에 응답하거나 처리 중인 작업이 수천 개라면 이 페이지가 적합합니다.
</Info>

## 제출 시 필드 하나

`model` 및 `input` 옆에 `callback_url`을 추가합니다. 요청의 다른 내용은
변경되지 않으며, 제출 응답은 다른 경우에 받게 되는 것과 동일한 작업 ID입니다.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.widerouter.com/v1/task/submit \
    -H "Authorization: Bearer $WIDEROUTER_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "model": "gemini-3-pro-image",
      "input": { "prompt": "a paper crane on a walnut desk" },
      "callback_url": "https://your-app.example/hooks/widerouter/9f2c1e7b0a4d",
      "callback_secret": "your-callback-secret-at-least-16-bytes"
    }'
  ```

  ```python Python theme={null}
  import os, requests

  resp = requests.post(
      "https://api.widerouter.com/v1/task/submit",
      headers={"Authorization": f"Bearer {os.environ['WIDEROUTER_API_KEY']}"},
      json={
          "model": "gemini-3-pro-image",
          "input": {"prompt": "a paper crane on a walnut desk"},
          "callback_url": "https://your-app.example/hooks/widerouter/9f2c1e7b0a4d",
          "callback_secret": os.environ["WIDEROUTER_CALLBACK_SECRET"],
      },
  )
  task_id = resp.json()["id"]   # remember it: it is how you match the callback
  ```
</CodeGroup>

두 필드는 제출 시점에 모두 확인되므로, 잘못된 값이 있으면 즉시 실패하며
결과를 전혀 보고하지 않는 작업이 조용히 생성되지 않습니다. 잘못된 URL은
`400 invalid_callback_url`이고, 잘못된 비밀 키는 `param`이
`callback_secret`로 설정된 `400 invalid_params`입니다.

| 규칙                                                    | 이유                                                                                                                                                      |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `callback_url`은 `https`이어야 합니다                        | 작업 페이로드에는 출력 URL이 포함됩니다.                                                                                                                                |
| 공개 호스트여야 합니다                                          | `localhost`, `*.local`, 루프백, 비공개 및 링크 로컬 주소는 거부됩니다. WideRouter가 인터넷에서 해당 호스트에 연결할 수 있어야 합니다.                                                            |
| 원하는 경로와 쿼리를 사용할 수 있습니다                                | 그래도 경로에 긴 무작위 세그먼트를 넣으십시오. `callback_secret`이 없으면 요청이 WideRouter에서 왔음을 입증하는 유일한 방법입니다.                                                                  |
| `callback_secret`은 선택 사항입니다: 16\~128바이트이며 공백이 없어야 합니다 | WideRouter는 이를 사용하여 콜백에 서명합니다. 자세한 내용은 [서명 확인](#verifying-the-signature)을 참조하십시오. 직접 선택하고, 여러 작업에서 재사용하며, 수신기 옆에 보관하십시오. `callback_url` 없이는 전송할 수 없습니다. |

## WideRouter가 전송하는 내용

작업당 `POST` 하나가 전송되며, 작업이
최종 상태에 도달할 때 `Content-Type: application/json`됩니다. 본문을 파싱하기 전에 라우팅하고
인증할 수 있도록 세 개의 헤더가 제공됩니다.

| 헤더                 | 값                                                                          |
| ------------------ | -------------------------------------------------------------------------- |
| `X-Wide-Event`     | `task.completed` 또는 `task.failed`                                          |
| `X-Wide-Task-Id`   | 제출 응답의 작업 ID                                                               |
| `X-Wide-Signature` | 작업이 `callback_secret`과 함께 제출된 경우에만 `t=<unix seconds>,v1=<hex HMAC-SHA256>` |

본문은 **해당 시점에 `GET /v1/task/{task_id}`가 반환하는 작업 객체와 바이트 단위로
동일합니다**. 콜백 경로와 폴링 경로 모두 하나의 파서를 사용합니다.

<Tabs>
  <Tab title="task.completed">
    ```json theme={null}
    {
      "id": "task_7BeWYLhGoLZYMId5IYuFwc25UQXuFfo6",
      "model": "gemini-3-pro-image",
      "status": "completed",
      "created_at": 1788104905,
      "started_at": 1788104906,
      "completed_at": 1788104925,
      "expires_at": 1788191325,
      "outputs": [
        "https://…/task_7BeWYLhGoLZYMId5IYuFwc25UQXuFfo6_0.jpg"
      ],
      "counts": { "requested": 1, "succeeded": 1, "failed": 0 }
    }
    ```
  </Tab>

  <Tab title="task.failed">
    ```json theme={null}
    {
      "id": "task_jodb26qdNoG5CuwzA23OukOgjeH5ktnh",
      "model": "gemini-3-pro-image",
      "status": "failed",
      "created_at": 1788104830,
      "started_at": 1788104831,
      "completed_at": 1788104831,
      "counts": { "requested": 1, "succeeded": 0, "failed": 1 },
      "error": { "code": "upstream_timeout", "message": "..." }
    }
    ```
  </Tab>
</Tabs>

실패한 작업은 콜백이 전송되기 전에 환불되므로, `task.failed`는
과금이 취소되었다는 신호이기도 합니다. 출력 파일은 작업 ID를 이름으로
사용하므로 이름을 변경하지 않고도 쉽게 정리할 수 있습니다.

## 전달 규칙

| 규칙         | 값                                                                                  |
| ---------- | ---------------------------------------------------------------------------------- |
| 성공         | 모든 `2xx` 응답입니다. 타임아웃을 포함한 그 외 모든 경우는 실패한 시도로 처리됩니다.                                |
| 시도당 타임아웃   | 10초입니다. 먼저 응답하고 느린 작업은 그 후에 처리합니다.                                                 |
| 재시도        | 이전 시도 후 약 10초, 1분, 5분 시점에 재시도합니다. 총 4회 시도하며 약 6분 동안 진행한 후 WideRouter가 포기합니다.       |
| 순서         | 보장되지 않습니다. 순서대로 제출한 두 작업이 어느 순서로든 반환될 수 있습니다.                                      |
| 작업에 미치는 영향 | 없습니다. 응답하지 않는 콜백은 작업 상태를 변경하지 않으며, 작업은 `GET /v1/task/{task_id}`을 통해 계속 조회할 수 있습니다. |

다음 두 가지 결과를 고려하여 설계하는 것이 좋습니다.

* **멱등성을 보장하십시오.** 느린 `200` 이후 재시도되면 동일한 작업이 두 번 도착할 수 있습니다. `id`를 기준으로 핸들러의 키를 지정하십시오.
* **지연된 작업을 위한 폴링 대체 경로를 유지하십시오.** 제출 시 작업 ID를 저장하십시오. 모델의 일반적인 지연 시간보다 몇 분이 지난 후에도 콜백이 도착하지 않으면 작업을 한 번 조회하십시오. 콜백은 지연 시간을 줄이는 최적화일 뿐, 전달을 보장하지는 않습니다.

## 서명 확인

제출 시 `callback_secret`을 전송하며 해당 작업의 모든 콜백에는
`X-Wide-Signature`이 포함됩니다. 이는 두 가지를 증명합니다. 요청이 WideRouter에서
발송되었으며, 전송 중 본문이 변경되지 않았다는 것입니다. 이는 암호화가 아닙니다. 본문에는
공개 URL만 포함됩니다.

```
X-Wide-Signature: t=1788884867,v1=7bfbeeeb97ca77af5ee3ab9ba4552a7898390de81478dfa1ef14d8345b5d4874
```

`t`은 콜백이 전송된 유닉스 시간입니다. `v1`은
`hex(HMAC-SHA256(callback_secret, "<t>.<raw body>"))`입니다. 즉 타임스탬프, 마침표,
그리고 수신한 그대로의 요청 본문입니다.

<Steps>
  <Step title="원시 본문을 가져옵니다">
    다이제스트는 바이트를 대상으로 계산됩니다. JSON을 파싱한 다음 다시 직렬화하면
    바이트가 변경되므로, 프레임워크가 본문을 처리하기 전에 읽어야 합니다.
  </Step>

  <Step title="재계산한 후 일정 시간 비교를 수행합니다">
    동일한 시크릿으로 HMAC을 다시 생성하고 일정 시간 비교 함수를 사용하여 비교합니다. 절대로
    `==`을 사용해서는 안 됩니다.
  </Step>

  <Step title="오래된 타임스탬프를 거부합니다">
    `t`이 어느 방향으로든 현재 시각에서 300초 넘게 차이 나는 항목은
    삭제합니다. 이렇게 하면 캡처된 콜백이 나중에 재생되는 것을 방지할 수 있습니다.
    테스트에서 측정된 시간 차이는 약 3초였습니다.
  </Step>
</Steps>

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib, time

  def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
      parts = dict(p.split("=", 1) for p in header.split(","))
      t, v1 = int(parts["t"]), parts["v1"]
      if abs(time.time() - t) > tolerance:
          return False
      expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, v1)
  ```

  ```javascript Node theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  function verify(secret, header, rawBody, tolerance = 300) {
    const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
    const t = Number(parts.t);
    if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > tolerance) return false;
    const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
    const a = Buffer.from(expected), b = Buffer.from(parts.v1 ?? "");
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```java Java theme={null}
  static boolean verify(String secret, String header, byte[] rawBody, long tolerance) throws Exception {
    long t = 0; String v1 = "";
    for (String kv : header.split(",")) {
      String[] p = kv.split("=", 2);
      if (p[0].equals("t")) t = Long.parseLong(p[1]);
      if (p[0].equals("v1")) v1 = p[1];
    }
    if (Math.abs(System.currentTimeMillis() / 1000 - t) > tolerance) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
    return MessageDigest.isEqual(mac.doFinal(rawBody), HexFormat.of().parseHex(v1));
  }
  ```
</CodeGroup>

`callback_secret`이 없으면 서명 헤더도 없으며, `callback_url`의 추측하기 어려운
경로만이 사용자와 위조된 “완료” 상태 사이를 막는 유일한 수단입니다.
어느 경우든 작업을 다운로드하거나 과금하기 전에 API 키를 사용하여
`GET /v1/task/{task_id}`을 다시 읽어야 합니다. 서명은 누가 콜백을 보냈는지 증명하고,
다시 읽은 결과는 현재 작업 상태가 무엇인지 증명합니다.

## 세 언어로 구현한 수신기

각 핸들러는 동일한 다섯 가지 작업을 수행합니다. 원시 본문에 대한 서명을 확인하고, 이벤트 헤더를 검사하며, `200`로 즉시 응답하고, 작업 ID를 큐에 전달한 다음, 워커가 작업을 다시 읽고 결과를 다운로드하도록 합니다.
`verify` 함수는 이전 섹션에서 사용한 함수입니다. 결과가 수 메가바이트에 이를 수 있고 응답할 수 있는 시간이 10초이므로, 다운로드 작업은 의도적으로 요청 핸들러 외부에서 수행합니다.

<CodeGroup>
  ```python Python (Flask) theme={null}
  import os, requests
  from flask import Flask, request

  app = Flask(__name__)
  API_KEY = os.environ["WIDEROUTER_API_KEY"]
  CALLBACK_SECRET = os.environ["WIDEROUTER_CALLBACK_SECRET"]
  SEEN = set()   # use your database in production

  @app.post("/hooks/widerouter/<secret>")
  def widerouter_hook(secret):
      if secret != os.environ["HOOK_SECRET"]:
          return "", 404
      if not verify(CALLBACK_SECRET, request.headers.get("X-Wide-Signature", ""), request.get_data()):
          return "", 401
      task_id = request.headers.get("X-Wide-Task-Id", "")
      event = request.headers.get("X-Wide-Event", "")
      if task_id in SEEN:
          return "", 200                 # retry of a task we already handled
      SEEN.add(task_id)
      enqueue(task_id, event)            # your job queue; returns instantly
      return "", 200

  def worker(task_id, event):
      task = requests.get(
          f"https://api.widerouter.com/v1/task/{task_id}",
          headers={"Authorization": f"Bearer {API_KEY}"},
      ).json()
      if task["status"] != "completed":
          record_failure(task_id, task.get("error"))
          return
      for i, url in enumerate(task["outputs"]):
          with open(f"{task_id}_{i}.jpg", "wb") as f:
              f.write(requests.get(url, timeout=60).content)
  ```

  ```javascript Node (Express) theme={null}
  import fs from "node:fs";
  import express from "express";

  const app = express();
  const API_KEY = process.env.WIDEROUTER_API_KEY;
  const CALLBACK_SECRET = process.env.WIDEROUTER_CALLBACK_SECRET;
  const seen = new Set(); // use your database in production

  // express.raw keeps the body as bytes: the signature is over the raw body
  app.post("/hooks/widerouter/:secret", express.raw({ type: "application/json" }), (req, res) => {
    if (req.params.secret !== process.env.HOOK_SECRET) return res.sendStatus(404);
    if (!verify(CALLBACK_SECRET, req.get("X-Wide-Signature") ?? "", req.body)) return res.sendStatus(401);
    const taskId = req.get("X-Wide-Task-Id");
    const event = req.get("X-Wide-Event");
    if (seen.has(taskId)) return res.sendStatus(200); // retry, already handled
    seen.add(taskId);
    res.sendStatus(200);                              // acknowledge first
    setImmediate(() => worker(taskId, event));        // then do the slow part
  });

  async function worker(taskId) {
    const task = await fetch(`https://api.widerouter.com/v1/task/${taskId}`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    }).then((r) => r.json());
    if (task.status !== "completed") return recordFailure(taskId, task.error);
    for (const [i, url] of task.outputs.entries()) {
      const bytes = Buffer.from(await (await fetch(url)).arrayBuffer());
      await fs.promises.writeFile(`${taskId}_${i}.jpg`, bytes);
    }
  }
  ```

  ```java Java (Spring Boot) theme={null}
  @RestController
  public class WideRouterHook {
    private final String apiKey = System.getenv("WIDEROUTER_API_KEY");
    private final String callbackSecret = System.getenv("WIDEROUTER_CALLBACK_SECRET");
    private final Set<String> seen = ConcurrentHashMap.newKeySet(); // use a DB in production

    @PostMapping("/hooks/widerouter/{secret}")
    public ResponseEntity<Void> onTask(@PathVariable String secret,
                                       @RequestHeader("X-Wide-Task-Id") String taskId,
                                       @RequestHeader("X-Wide-Event") String event,
                                       @RequestHeader(value = "X-Wide-Signature", required = false) String signature,
                                       @RequestBody byte[] rawBody) throws Exception {
      if (!secret.equals(System.getenv("HOOK_SECRET"))) return ResponseEntity.notFound().build();
      if (signature == null || !verify(callbackSecret, signature, rawBody, 300)) return ResponseEntity.status(401).build();
      if (!seen.add(taskId)) return ResponseEntity.ok().build();   // retry, already handled
      executor.submit(() -> download(taskId));                     // acknowledge first
      return ResponseEntity.ok().build();
    }

    void download(String taskId) throws Exception {
      HttpRequest read = HttpRequest.newBuilder(URI.create("https://api.widerouter.com/v1/task/" + taskId))
          .header("Authorization", "Bearer " + apiKey).GET().build();
      JsonNode task = mapper.readTree(http.send(read, BodyHandlers.ofString()).body());
      if (!"completed".equals(task.get("status").asText())) { recordFailure(taskId, task.get("error")); return; }
      int i = 0;
      for (JsonNode url : task.get("outputs")) {
        HttpRequest get = HttpRequest.newBuilder(URI.create(url.asText())).GET().build();
        Files.write(Path.of(taskId + "_" + (i++) + ".jpg"), http.send(get, BodyHandlers.ofByteArray()).body());
      }
    }
  }
  ```
</CodeGroup>

<Tip>
  워커는 제출할 때 사용하는 **동일한** API 키로 작업을 읽습니다. 제출용으로는 로드했지만 읽기 경로에는 로드하지 않은 키는 수신기에 작업 ID만 전달되고 이미지가 생성되지 않는 가장 흔한 원인입니다. 모든 `GET`가 `401`로 응답하는 동안 모든 `POST`는 성공합니다. 두 작업 모두 하나의 구성된 클라이언트를 사용하십시오.
</Tip>

## 서버 없이 사용해 보기

공개 `https` URL을 제공하는 모든 요청 검사 서비스는 임시 수신기로 사용할 수 있습니다. 해당 URL을 `callback_url`로 지정하여 작업을 제출하고, 검사기에 작업 객체가 도착하는지 확인하면 됩니다. 직접 운영하는 머신에서 수신할 준비가 되면, 로컬 포트로 연결되는 `https` 터널을 사용해 동일한 작업을 수행할 수 있습니다. URL은 인터넷에서 연결 가능해야 하며, LAN 주소는 제출 시 거부된다는 점에 유의하시기 바랍니다.

## 즉시 다운로드

출력 URL은 작업 완료 후 **24시간이 지나면** 작동하지 않으며, 콜백은 해당 URL의 존재를 알 수 있는 가장 이른 시점입니다. 수신 시 다운로드하는 수신기는 만료를 고려할 필요가 없지만, URL만 저장했다가 나중에 읽는 수신기는 결국 아무것도 가리키지 않는 링크를 저장하게 됩니다.

## 다음 단계

<CardGroup cols={2}>
  <Card title="비동기 작업 API" icon="clock" href="/ko/api/async-tasks">
    엔벌로프, 폴링 루프, 작업 상태 및 오류 표입니다.
  </Card>

  <Card title="빠른 시작" icon="rocket" href="/ko/quickstart">
    약 5분 안에 제출하고, 폴링하고, 다운로드합니다.
  </Card>
</CardGroup>
