세션 웹훅

세션 웹훅 (Session webhooks)

웹훅을 사용하면 이벤트 스트림을 열어 두지 않아도 세션 상태 변경에 응답할 수 있어요. 웹훅 핸들러는 샌드박스 컴퓨팅을 시작하거나 다시 연결하고, 애플리케이션을 갱신하거나, 워크플로를 트리거할 수 있어요.

출처: 문서

본문

지원되는 이벤트 (Supported events)

이벤트 발생 시점
agent.session.created 세션이 생성될 때.
agent.session.action_required 세션이 함수 결과, 초기 환경 연결, 또는 재연결을 필요로 할 때.
agent.session.in_progress 세션이 턴 처리를 시작할 때.
agent.session.idle 세션이 유휴 상태이고 더 많은 입력을 받을 준비가 됐을 때.
agent.session.failed 세션이 실패 상태에 들어갈 때.

agent.session.action_required 이벤트는 세션 ID와 function_call 또는 environment_connection 유형의 required_action.type을 포함해요.

{
  "type": "agent.session.action_required",
  "data": {
    "id": "sess_abc123",
    "required_action": { "type": "function_call" }
  }
}

세션을 검색하고 호출 ID, 인자, 환경 ID를 위해 required_actions를 검사하세요. 웹훅은 이런 세부 정보를 포함하지 않아요.

웹훅 설정하기 (Set up a webhook)

공용 웹훅 설정 가이드를 따라 엔드포인트를 만들고 Agents API 이벤트를 선택하세요. 서명 검증을 위해 엔드포인트의 서명 시크릿을 저장하세요.

이벤트 받기 (Receive events)

구독한 이벤트가 발생할 때마다 OpenAI가 서명된 HTTP POST 요청을 보내요:

{
  "id": "evt_123",
  "object": "event",
  "created_at": 1750287018,
  "type": "agent.session.created",
  "data": {
    "id": "sess_abc123",
    "environment_id": "ccarenv_abc123",
    "environment_type": "self_hosted",
    "connect": {
      "remote_url": "https://api.openai.com/v1/agents/api"
    }
  }
}

샌드박스를 프로비저닝하기 전에 세션의 현재 상태를 검색하세요. Sandbox lifecycle을 참고하세요.

실행기 시작하기 (Start the executor)

자체 호스팅 세션에서는 agent.session.created가 실행기를 시작하는 데 필요한 환경 ID와 연결 URL을 포함해요. ENVIRONMENT_ID를 data.environment_id로, REMOTE_URL을 data.connect.remote_url로 설정하세요. 이것은 세션의 environment.remote_url로 반환되는 것과 같은 URL이에요. 두 값을 저장하고 재연결 시 재사용하세요:

CODEX_API_KEY="$OPENAI_ENVIRONMENT_KEY" \
codex exec-server \
  --remote "$REMOTE_URL" \
  --environment-id "$ENVIRONMENT_ID"

CODEX_API_KEY로 환경 키를 사용하세요. 애플리케이션 API 키는 환경 밖에 두세요.

이벤트 검증하고 처리하기 (Verify and process events)

OPENAI_API_KEY와 OPENAI_WEBHOOK_SECRET을 설정하세요. Python에서는 fastapi, uvicorn, openai를, JavaScript에서는 express와 openai를 설치하세요.

핸들러는 서명을 검증하고 포트 8000에서 수신해요. PORT로 포트를 바꿀 수 있어요. 프로덕션에서는 느린 작업을 큐에 넣으세요.

웹훅 핸들러

import express from "express";
import OpenAI from "openai";

const app = express();
const webhooks = new OpenAI({
  webhookSecret: process.env.OPENAI_WEBHOOK_SECRET,
});

app.post(
  "/webhooks/openai",
  express.raw({ type: "application/json" }),
  async (request, response) => {
    const payload = request.body.toString("utf8");
    try {
      await webhooks.webhooks.verifySignature(payload, request.headers);
    } catch {
      response.status(400).send("Invalid signature");
      return;
    }
    const event = JSON.parse(payload);
    if (event.type === "agent.session.idle") {
      const session = await webhooks.beta.agents.sessions.retrieve(
        event.data.id
      );
      console.log("session idle event:", session.id);
    } else {
      console.log("session event:", event.type, event.data.id);
    }
    response.sendStatus(200);
  }
);

app.listen(Number(process.env.PORT ?? 8000));
import json
import os

import uvicorn
from fastapi import FastAPI, Request, Response
from openai import AsyncOpenAI, InvalidWebhookSignatureError

app = FastAPI()
webhooks = AsyncOpenAI(webhook_secret=os.environ["OPENAI_WEBHOOK_SECRET"])


@app.post("/webhooks/openai")
async def handle_webhook(request: Request):
    payload = await request.body()
    try:
        webhooks.webhooks.verify_signature(payload=payload, headers=request.headers)
    except (InvalidWebhookSignatureError, ValueError):
        return Response("Invalid signature", status_code=400)

    event = json.loads(payload)
    if event["type"] == "agent.session.idle":
        session_id = event["data"]["id"]
        session = await webhooks.beta.agents.sessions.retrieve(session_id, timeout=10)
        print("session idle event:", session.id)
    else:
        print("session event:", event["type"], event["data"]["id"])
    return Response(status_code=200)


if __name__ == "__main__":
    uvicorn.run(app, port=int(os.environ.get("PORT", "8000")))
import (
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"

	"github.com/openai/openai-go/v3"
)

client := openai.NewClient()
http.HandleFunc("/webhooks/openai", func(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Invalid body", http.StatusBadRequest)
		return
	}
	if err := client.Webhooks.VerifySignature(body, r.Header); err != nil {
		http.Error(w, "Invalid signature", http.StatusBadRequest)
		return
	}
	var event struct {
		Type string `json:"type"`
		Data struct {
			ID string `json:"id"`
		} `json:"data"`
	}
	if err := json.Unmarshal(body, &event); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}
	if event.Type == "agent.session.idle" {
		session, err := client.Beta.Agents.Sessions.Get(r.Context(), event.Data.ID)
		if err != nil {
			http.Error(w, "Could not retrieve session", http.StatusInternalServerError)
			return
		}
		fmt.Println("session idle event:", session.ID)
	} else {
		fmt.Println("session event:", event.Type, event.Data.ID)
	}
	w.WriteHeader(http.StatusOK)
})
port := os.Getenv("PORT")
if port == "" {
	port = "8000"
}
if err := http.ListenAndServe(":"+port, nil); err != nil {
	panic(err)
}
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.core.http.Headers;
import com.openai.errors.InvalidWebhookSignatureException;
import com.openai.models.webhooks.WebhookVerificationParams;
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

OpenAIClient client = OpenAIOkHttpClient.fromEnv();
var json = new JsonMapper();
int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8000"));
var server = HttpServer.create(new InetSocketAddress(port), 0);
server.createContext(
    "/webhooks/openai",
    exchange -> {
      try (exchange) {
        if (!exchange.getRequestMethod().equals("POST")) {
          exchange.sendResponseHeaders(405, -1);
          return;
        }
        String payload =
            new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
        try {
          client
              .webhooks()
              .verifySignature(
                  WebhookVerificationParams.builder()
                      .payload(payload)
                      .headers(Headers.builder().putAll(exchange.getRequestHeaders()).build())
                      .build());
        } catch (InvalidWebhookSignatureException e) {
          exchange.sendResponseHeaders(400, -1);
          return;
        }
        var event = json.readTree(payload);
        if (event.path("type").asText().equals("agent.session.idle")) {
          var session =
              client
                  .beta()
                  .agents()
                  .sessions()
                  .retrieve(event.path("data").path("id").asText());
          System.out.println("session idle event: " + session.id());
        } else {
          System.out.println(
              "session event: "
                  + event.path("type").asText()
                  + " "
                  + event.path("data").path("id").asText());
        }
        exchange.sendResponseHeaders(200, -1);
      }
    });
server.start();
require "openai"
require "webrick"
require "json"

client = OpenAI::Client.new
server = WEBrick::HTTPServer.new(Port: Integer(ENV.fetch("PORT", "8000")))
server.mount_proc "/webhooks/openai" do |request, response|
  if request.request_method != "POST"
    response.status = 405
    next
  end
  payload = request.body
  begin
    client.webhooks.verify_signature(payload, request.header.transform_values(&:first))
  rescue OpenAI::Errors::InvalidWebhookSignatureError
    response.status = 400
    response.body = "Invalid signature"
    next
  end
  event = JSON.parse(payload)
  if event["type"] == "agent.session.idle"
    session = client.beta.agents.sessions.retrieve(event.fetch("data").fetch("id"))
    puts "session idle event: #{session.id}"
  else
    puts "session event: #{event["type"]} #{event.dig("data", "id")}"
  end
  response.status = 200
end
trap("INT") { server.shutdown }
server.start

환경 연결 이벤트 (Environment connection events)

초기 또는 후속 입력이 연결되지 않은 자체 호스팅 실행기를 필요로 하면 API가 environment_connection 필수 작업을 추가해요. 연결을 기다리기 전에 agent.session.action_required를 내보내요.

세션을 검색하고 required_actions가 여전히 연결을 요청하는지 확인하세요. session.environment.id와 session.environment.remote_url로 실행기를 시작하세요. 이 웹훅은 connect.remote_url을 포함하지 않아요. 대기 시간이 만료되기 전에 실행기가 연결되면 API가 필수 작업을 지우고 클라이언트가 다시 제출하지 않아도 제출을 재개해요.

API는 연결을 최대 5분 기다려요. 후속 입력 요청은 이 대기 동안 열려 있을 수 있어요. 클라이언트와 프록시 타임아웃을 그에 맞게 구성하세요. agent.session.in_progress는 실행이 시작됐음을 확인하는 것이지, API가 연결을 기다리고 있다는 뜻이 아니에요.

대기 시간이 만료되면 제출이 실패해요. 초기 입력은 비동기로 실패할 수 있고 세션을 failed 상태로 남길 수 있어요. 연결 대기는 지속적인 입력 큐를 제공하지 않아요. 프로세스 충돌이나 클라이언트 연결 끊김은 재시도가 필요할 수 있어요.

세션과 턴 결과 (Session and turn outcomes)

agent.session.idle은 세션이 더 많은 입력을 받을 준비가 됐다는 뜻이지, 마지막 턴이 성공했다는 뜻이 아니에요. 그 턴의 상태를 검사하거나 세션 스트림에서 agent.session.turn.completed, agent.session.turn.failed, agent.session.turn.cancelled를 관찰하세요. 완료된 턴에도 실패한 도구 호출이 있을 수 있어요. 도구 결과와 에이전트의 최종 응답을 확인하세요.

agent.session.failed는 실패한 세션을 보고하는 것이지, 모든 실패한 턴을 보고하는 게 아니에요. 세션 삭제에는 해당하는 웹훅이 없고, 프로바이더 컴퓨팅도 중지되지 않아요.

더 알아보기 (Learn more)