Webhooks
Webhooks
OpenAI 웹훅은 배치가 완료되거나, 백그라운드 응답이 생성되거나, 파인튜닝 작업이 끝나는 등 API에서 발생하는 이벤트에 대한 실시간 알림을 받을 수 있게 해줘요. 웹훅은 Standard Webhooks 스펙을 따라 여러분이 제어하는 HTTP 엔드포인트로 전달돼요. 웹훅 이벤트 전체 목록은 API 레퍼런스에서 확인할 수 있어요.
출처: 문서
본문
API 프로젝트에 대한 정렬 불일치 모니터링 알림을 받으려면 프로젝트 안전 알림 받기를 참고하세요.
Agents API 세션에 대해서는 세션 웹훅에서 세션 이벤트와 복구 패턴을 확인하세요. 웹훅 리시버에는 이 페이지의 엔드포인트 설정, 서명 검증, 전달 지침을 사용하세요.
웹훅 이벤트 API 레퍼런스 — 전체 웹훅 이벤트 목록을 보려면 여기를 확인하세요.
아래는 OpenAI에서 웹훅을 수신할 수 있는 서버 예시이며, 구체적으로 response.completed 이벤트용이에요.
Ruby 예시는 gem install openai webrick으로 필요한 의존성을 설치한 다음 OPENAI_API_KEY와 OPENAI_WEBHOOK_SECRET을 설정하세요.
웹훅 서버
import OpenAI from "openai";
import express from "express";
const app = express();
const client = new OpenAI({ webhookSecret: process.env.OPENAI_WEBHOOK_SECRET });
// Don't use express.json() because signature verification needs the raw text body
app.use(express.text({ type: "application/json" }));
app.post("/webhook", async (req, res) => {
try {
const event = await client.webhooks.unwrap(req.body, req.headers);
if (event.type === "response.completed") {
const response_id = event.data.id;
const response = await client.responses.retrieve(response_id);
const output_text = response.output
.filter((item) => item.type === "message")
.flatMap((item) => item.content)
.filter((contentItem) => contentItem.type === "output_text")
.map((contentItem) => contentItem.text)
.join("");
console.log("Response output:", output_text);
}
res.status(200).send();
} catch (error) {
if (error instanceof OpenAI.InvalidWebhookSignatureError) {
console.error("Invalid signature", error);
res.status(400).send("Invalid signature");
} else {
throw error;
}
}
});
app.listen(8000, () => {
console.log("Webhook server is running on port 8000");
});
import os
from openai import OpenAI, InvalidWebhookSignatureError
from flask import Flask, request, Response
app = Flask(__name__)
client = OpenAI(webhook_secret=os.environ["OPENAI_WEBHOOK_SECRET"])
@app.route("/webhook", methods=["POST"])
def webhook():
try:
# with webhook_secret set above, unwrap will raise an error if the signature is invalid
event = client.webhooks.unwrap(request.data, request.headers)
if event.type == "response.completed":
response_id = event.data.id
response = client.responses.retrieve(response_id)
print("Response output:", response.output_text)
return Response(status=200)
except InvalidWebhookSignatureError as e:
print("Invalid signature", e)
return Response("Invalid signature", status=400)
if __name__ == "__main__":
app.run(port=8000)
require "openai"
require "webrick"
client = OpenAI::Client.new(
webhook_secret: ENV.fetch("OPENAI_WEBHOOK_SECRET")
)
server = WEBrick::HTTPServer.new(
BindAddress: "127.0.0.1",
Port: Integer(ENV.fetch("OPENAI_WEBHOOK_PORT", "8000")),
Logger: WEBrick::Log.new($stderr, WEBrick::BasicLog::WARN),
AccessLog: []
)
response_workers = []
server.mount_proc("/webhook") do |request, response|
if request.request_method != "POST"
response.status = 405
next
end
headers = request.header.transform_values(&:first)
event = client.webhooks.unwrap(request.body, headers)
if event.is_a?(OpenAI::Models::Webhooks::ResponseCompletedWebhookEvent)
response_workers.select!(&:alive?)
response_workers << Thread.new(event.data.id) do |response_id|
completed_response = client.responses.retrieve(response_id)
puts "Response output: #{completed_response.output_text}"
end
end
response.status = 200
response.body = "ok"
rescue OpenAI::Errors::InvalidWebhookSignatureError, ArgumentError => error
warn "Invalid signature: #{error.message}"
response.status = 400
response.body = "Invalid signature"
ensure
server.shutdown if ENV["OPENAI_WEBHOOK_EXIT_AFTER_REQUEST"] == "1"
end
Signal.trap("INT") { server.shutdown }
port = server.listeners.first.addr[1]
puts "Webhook server listening on http://127.0.0.1:#{port}/webhook"
$stdout.flush
server.start
response_workers.each(&:join)
이런 웹훅을 실제로 동작시키려면 OpenAI 대시보드에서 response.completed를 구독하는 웹훅 엔드포인트를 설정하고, 백그라운드 모드로 응답 생성을 하는 API 요청을 만들면 돼요.
웹훅 설정 페이지에서 샘플 데이터로 테스트 이벤트를 트리거할 수도 있어요.
백그라운드 응답 생성
curl https://api.openai.com/v1/responses \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ***" \
-d '{
"model": "gpt-6-astra",
"input": "Write a very long novel about otters in space.",
"background": true
}'
import OpenAI from "openai";
const client = new OpenAI();
const resp = await client.responses.create({
model: "gpt-6-astra",
input: "Write a very long novel about otters in space.",
background: true,
});
console.log(resp.status);
from openai import OpenAI
client = OpenAI()
resp = client.responses.create(
model="gpt-6-astra",
input="Write a very long novel about otters in space.",
background=True,
)
print(resp.status)
package main
import (
"context"
"fmt"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/responses"
)
func main() {
client := openai.NewClient()
response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
Model: "gpt-6-astra",
Background: openai.Bool(true),
Input: responses.ResponseNewParamsInputUnion{
OfString: openai.String("Write a very long novel about otters in space."),
},
})
if err != nil {
panic(err)
}
fmt.Println(response.Status)
}
import com.openai.client.OpenAIClient;
import com.openai.client.okhttp.OpenAIOkHttpClient;
import com.openai.models.responses.ResponseCreateParams;
ResponseCreateParams params =
ResponseCreateParams.builder()
.model("gpt-6-astra")
.input("Write a detailed market analysis.")
.background(true)
.build();
var response = client.responses().create(params);
System.out.println(response.status().orElseThrow());
using OpenAI.Responses;
#pragma warning disable OPENAI001
string key = Environment.GetEnvironmentVariable("OPENAI_API_KEY")!;
ResponsesClient client = new(key);
CreateResponseOptions options = new()
{
Model = "gpt-6-astra",
BackgroundModeEnabled = true,
};
options.InputItems.Add(
ResponseItem.CreateUserMessageItem("Write a very long novel about otters in space.")
);
ResponseResult response = await client.CreateResponseAsync(options);
Console.WriteLine(response.Status);
require "openai"
client = OpenAI::Client.new
response = client.responses.create(
model: "gpt-6-astra",
input: "Write a detailed market analysis.",
background: true
)
puts(response.status)
이 가이드에서는 대시보드에서 웹훅 엔드포인트를 만들고, 이를 처리하는 서버 측 코드를 설정하고, 인바운드 요청이 실제로 OpenAI에서 왔는지 검증하는 방법을 배울 거예요.
웹훅 엔드포인트 만들기
서버에서 웹훅 요청을 받기 시작하려면 대시보드에 로그인해 웹훅 설정 페이지를 여세요. 웹훅은 프로젝트별로 구성돼요.
"Create" 버튼을 클릭해 새 웹훅 엔드포인트를 만드세요. 다음 세 가지를 구성하게 돼요.
- 엔드포인트 이름 (참고용으로만 사용).
- 여러분이 제어하는 서버의 공용 URL.
- 구독할 하나 이상의 이벤트 유형. 해당 이벤트가 발생하면 OpenAI가 지정한 URL로 HTTP POST 요청을 보내요.

새 웹훅을 만든 후에는, 들어오는 웹훅 요청을 서버 측에서 검증하는 데 사용할 서명 시크릿(signing secret)을 받게 돼요. 이 값은 다시 볼 수 없으므로 나중을 위해 저장해 두세요.
웹훅 엔드포인트를 만들었다면, 다음으로 그 들어오는 이벤트 페이로드를 처리할 서버 측 엔드포인트를 설정할 거예요.
서버에서 웹훅 요청 처리
구독한 이벤트가 발생하면 웹훅 URL이 다음과 같은 HTTP POST 요청을 받게 돼요.
POST https://yourserver.com/webhook
user-agent: OpenAI/1.0 (+https://platform.openai.com/docs/webhooks)
content-type: application/json
webhook-id: wh_685342e6c53c8190a1be43f081506c52
webhook-timestamp: 1750287078
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"object": "event",
"id": "evt_685343a1381c819085d44c354e1b330e",
"type": "response.completed",
"created_at": 1750287018,
"data": { "id": "resp_abc123" }
}
이러한 인바운드 HTTP 요청에는 성공적인 수신을 나타내는 성공(2xx) 상태 코드로 신속하게 응답해야 해요. 타임아웃을 피하려면 엔드포인트가 즉시 응답할 수 있도록 그다지 중요하지 않은 처리는 백그라운드 워커로 오프로드할 것을 권장해요. 엔드포인트가 성공(2xx) 상태 코드를 반환하지 않거나 수 초 내에 응답하지 않으면 웹훅 요청이 재시도돼요. OpenAI는 지수 백오프 방식으로 최대 72시간 동안 전달을 시도해요. 3xx 리다이렉트는 따르지 않는다는 점에 유의하세요. 리다이렉트는 실패로 처리되므로 엔드포인트를 최종 대상 URL을 사용하도록 업데이트해야 해요.
드물게 내부 시스템 문제로 OpenAI가 같은 웹훅 이벤트를 중복으로 전달할 수 있어요. 멱등성 키(idempotency key)로 webhook-id 헤더를 사용해 중복을 제거할 수 있어요.
로컬에서 웹훅 테스트
웹훅을 테스트하려면 공개 인터넷에서 접근 가능한 URL이 필요해요. 로컬 개발 환경이 공개되어 있지 않을 가능성이 높기 때문에 개발이 까다로울 수 있어요. 도움이 될 수 있는 몇 가지 옵션은 다음과 같아요.
- 로컬호스트 서버를 공용 URL에 노출할 수 있는 ngrok
- Replit, GitHub Codespaces, Cloudflare Workers, Vercel의 v0 같은 클라우드 개발 환경
웹훅 서명 검증
OpenAI에서 웹훅 이벤트를 받아 어떤 검증도 없이 처리할 수는 있지만, 특히 웹훅이 백엔드에서 어떤 종류의 작업을 수행한다면 인바운드 요청이 OpenAI에서 온 것인지 검증해야 해요. 웹훅 요청과 함께 전송되는 헤더에는 웹훅 시크릿 키와 함께 사용해 웹훅이 OpenAI에서 왔는지 검증할 수 있는 정보가 담겨 있어요.
OpenAI 대시보드에서 웹훅 엔드포인트를 만들면 서명 시크릿을 받게 되며, 이를 서버에서 환경 변수로 사용할 수 있게 해야 해요.
export OPENAI_WEBHOOK_SECRET="<your secret here>"
웹훅 서명을 검증하는 가장 간단한 방법은 공식 OpenAI SDK 헬퍼의 unwrap() 메서드를 사용하는 것이에요.
OpenAI SDK로 서명 검증
const client = new OpenAI();
const webhook_secret = process.env.OPENAI_WEBHOOK_SECRET;
if (!webhook_secret) throw new Error("Set OPENAI_WEBHOOK_SECRET.");
// will throw if the signature is invalid
const event = await client.webhooks.unwrap(
req.body,
req.headers,
webhook_secret
);
import os
from flask import request
from openai import OpenAI
client = OpenAI()
webhook_secret = os.environ["OPENAI_WEBHOOK_SECRET"]
# will raise if the signature is invalid
event = client.webhooks.unwrap(
request.data,
request.headers,
secret=webhook_secret,
)
require "openai"
require "webrick"
client = OpenAI::Client.new(
api_key: ENV.fetch("OPENAI_API_KEY"),
webhook_secret: ENV.fetch("OPENAI_WEBHOOK_SECRET")
)
server = WEBrick::HTTPServer.new(
BindAddress: "127.0.0.1",
Port: Integer(ENV.fetch("OPENAI_WEBHOOK_PORT", "8000")),
Logger: WEBrick::Log.new($stderr, WEBrick::BasicLog::WARN),
AccessLog: []
)
server.mount_proc("/webhook") do |request, response|
if request.request_method != "POST"
response.status = 405
next
end
headers = request.header.transform_values(&:first)
event = client.webhooks.unwrap(request.body, headers)
puts "Verified webhook event: #{event.type}"
response.status = 200
response.body = "ok"
rescue OpenAI::Errors::InvalidWebhookSignatureError, ArgumentError
response.status = 400
response.body = "Invalid signature"
ensure
server.shutdown if ENV["OPENAI_WEBHOOK_EXIT_AFTER_REQUEST"] == "1"
end
Signal.trap("INT") { server.shutdown }
port = server.listeners.first.addr[1]
puts "Webhook server listening on http://127.0.0.1:#{port}/webhook"
$stdout.flush
server.start
서명은 Standard Webhooks 라이브러리로도 검증할 수 있어요.
Standard Webhooks 라이브러리로 서명 검증
use standardwebhooks::Webhook;
let webhook_secret = std::env::var("OPENAI_WEBHOOK_SECRET").expect("OPENAI_WEBHOOK_SECRET not set");
let wh = Webhook::new(webhook_secret);
wh.verify(webhook_payload, webhook_headers).expect("Webhook verification failed");
$webhook_secret = getenv("OPENAI_WEBHOOK_SECRET");
$wh = new \StandardWebhooks\Webhook($webhook_secret);
$wh->verify($webhook_payload, $webhook_headers);
필요하다면 Standard Webhooks 스펙에 설명된 대로 직접 서명 검증을 구현할 수도 있어요.
서명 시크릿을 분실하거나 실수로 노출했다면 서명 시크릿 회전으로 새 서명 시크릿을 생성할 수 있어요.