프로비저닝 작업을 생성하고 나면 202를 받게 됩니다. 엔진 ID와 status: "in_progress"입니다. 이제 AI 에이전트가 백그라운드에서 소스를 크롤링하고, 해당 엔진에 브랜드 보이스, 용어집 항목, 지침을 적용하고 있습니다. 크롤링해야 할 링크 수에 따라 이 작업은 금방 끝날 수도 있고, 시간이 좀 걸릴 수도 있습니다. 실시간 WebSocket 연결을 열어 진행 상황을 지켜볼 수도 있지만, 에이전트가 언제 끝났는지와 무엇을 만들었는지만 확인하려고 연결을 계속 열어 두고 싶지는 않을 것입니다.
이럴 때 webhook이 필요합니다. 작업 생성 시 callbackUrl를 함께 보내면, Lingo는 작업이 끝나는 즉시 최종 결과를 해당 URL로 POST합니다. 즉, 엔진이 준비된 시점과 생성된 항목 목록을 요청 없이 바로 전달받게 됩니다. 작업이 완료되면 provisioning.completed로 전달되고, AI가 생성한 모든 레코드의 요약이 포함됩니다. 작업이 실패하면 provisioning.failed로 전달되고, 실패 이유가 포함됩니다. 어느 쪽이든, 설정 플로는 따로 조회하지 않아도 결과를 받게 됩니다.
이 페이지에서는 두 가지 payload와 이를 처리하는 방법을 설명합니다. 전송은 서명되며 재시도됩니다. 이 메커니즘은 로컬라이제이션과 공통으로 사용되며, 자세한 내용은 webhook 서명 검증 페이지에 정리되어 있습니다. 필요한 지점마다 해당 페이지로 연결됩니다.
이 페이지에서 다루는 내용
전송 방식#
프로비저닝 작업은 정확히 한 번만 종료됩니다. 모든 소스의 크롤링과 분석이 끝났든, 실행이 중단되었든 최종 상태에 도달하는 순간 결과는 단일 POST로 callbackUrl에 전달됩니다. 로컬라이제이션 그룹은 대상 로캘마다 작업이 하나씩 생성되어 각각 자체 콜백을 전송하지만, 프로비저닝 작업은 작업 하나당 전송도 한 번뿐입니다.
전송 대상은 작업을 생성할 때 callbackUrl로 지정합니다. 전송되는 payload는 두 가지 형태이며, type 필드 값으로 구분됩니다. 각각 provisioning.completed와 provisioning.failed입니다. 두 형식 모두 자신이 속한 jobId와 engineId를 포함하므로, 하나의 핸들러에서 type를 기준으로 라우팅해 올바른 레코드를 업데이트할 수 있습니다.
HTTPS만 지원
callbackUrl에는 반드시 HTTPS를 사용해야 합니다. HTTP URL은 작업 생성 시 거부됩니다. webhook은 서명되는데, 평문 전송 위에 서명된 payload를 올리는 것은 의미가 없기 때문입니다.
알 수 없는 이벤트 유형도 안전하게 처리하세요
현재 전송되는 유형은 provisioning.completed와 provisioning.failed입니다. 하지만 이 집합은 앞으로 확장될 수 있다고 보고 처리해야 합니다. 알고 있는 유형에만 분기하고 나머지는 무시하면, 향후 새 이벤트 유형이 추가되어도 이미 배포된 핸들러가 깨지지 않습니다.
completed payload#
작업이 완료되면 payload에는 summary가 담깁니다. 이는 작업을 조회했을 때 받게 되는 동일한 목록을, 폴링 대신 푸시로 전달받는 것에 가깝습니다. 여기에는 엔진에서 AI가 생성한 모든 브랜드 보이스, 용어집 항목, 지침이 담기며, 처리 중 발생한 항목별 실패도 함께 나열됩니다.
{
"type": "provisioning.completed",
"jobId": "pjb_A1b2C3d4E5f6G7h8",
"engineId": "eng_X1y2Z3a4B5c6D7e8",
"summary": {
"brandVoices": { "count": 3, "ids": ["bv_A1b2C3d4", "bv_B2c3D4e5", "bv_C3d4E5f6"] },
"glossaryItems": { "count": 12, "ids": ["gi_A1b2C3d4", "..."] },
"instructions": { "count": 5, "ids": ["ins_A1b2C3d4", "..."] },
"errors": []
}
}| 필드 | 설명 |
|---|---|
type | provisioning.completed |
jobId | 완료된 프로비저닝 작업(pjb_ 접두사) |
engineId | 구성된 엔진(eng_ 접두사) |
summary | AI가 엔진에 생성한 내용입니다. 구성 요소별 개수와 ID, 그리고 errors에 담긴 항목별 실패가 포함됩니다. |
summary는 작업에 포함되는 것과 동일한 객체입니다. 각 필드의 의미, 각 구성 요소가 무엇인지, 항목이 로캘에 어떻게 매핑되는지, errors에 무엇이 들어가는지는 AI가 추출하는 내용 페이지에 한 번만 문서화되어 있습니다. 여기서 중요한 점은 completed payload가 에이전트가 생성한 모든 항목의 ID를 바로 전달해 주기 때문에, 핸들러가 작업을 다시 조회하지 않고도 이를 기록하거나 대시보드에 표시할 수 있다는 것입니다.
errors 배열이 비어 있지 않아도 completed로 전달됩니다.
항목별 실패가 있다고 해서 작업 전체가 실패로 처리되지는 않습니다. 특정 소스 하나를 크롤링하지 못했거나 레코드 하나를 생성하지 못했다면, 해당 내용은 summary.errors에 기록되고 나머지는 그대로 엔진에 적용됩니다. 이 경우에도 payload는 provisioning.completed이며 provisioning.failed가 아닙니다. completed 이벤트는 작업이 끝까지 실행되었음을 의미합니다. 무엇을 수정해야 하는지는 errors를 확인하세요. provisioning.failed payload는 실행 결과 아예 사용할 수 있는 엔진이 나오지 않았을 때 전송됩니다.
failed payload#
프로비저닝 작업은 실행 결과로 활용할 수 있는 것이 아무것도 남지 않을 때 실패합니다. 예를 들어 모든 소스의 크롤링이 실패해 에이전트가 분석할 콘텐츠가 전혀 없는 경우입니다. 이런 경우에도 결과는 전달됩니다. payload 유형은 provisioning.failed이며, 요약 대신 error 문자열이 포함됩니다.
{
"type": "provisioning.failed",
"jobId": "pjb_A1b2C3d4E5f6G7h8",
"engineId": "eng_X1y2Z3a4B5c6D7e8",
"error": "All sources failed to crawl. No content available for analysis."
}| 필드 | 설명 |
|---|---|
type | provisioning.failed |
jobId | 실패한 프로비저닝 작업 |
engineId | 생성되었지만 구성되지 않은 상태로 남은 엔진 |
error | 작업을 완료하지 못한 이유를 사람이 읽을 수 있는 형태로 설명한 문자열 |
여기서라면 이런 질문이 나오는 게 당연합니다. 작업이 실패했다면 엔진도 함께 사라진 건가요? 그렇지 않습니다. 이 payload의 engineId는 202에서 받은 것과 동일한 엔진입니다. 이 엔진은 호출을 보낸 순간 이미 생성되었고 지금도 그대로 존재합니다. 다만 실패한 실행이 추가했어야 할 구성만 빠져 있을 뿐입니다. 실패로 잃는 것은 추출 결과이지 엔진 자체가 아닙니다. 제출한 내용을 조정해 다시 시도하거나, 대시보드에서 직접 엔진을 구성할 수 있습니다. 작업이 크롤링 단계에서 실패했다면 대개 원인은 소스에 있습니다. 소스 유형 페이지에서 어떤 소스가 적합한지 확인할 수 있습니다.
webhook 처리#
여기서 가장 먼저 떠오르는 걱정도 타당합니다. 내 핸들러는 데이터베이스 쓰기, 알림 전송, 대시보드 새로고침처럼 실제 작업을 처리하는데, 그러면 연결이 오래 열려 webhook이 타임아웃되지 않나요?
맞습니다. 그래서 Lingo가 그 작업을 기다리게 하면 안 됩니다. 먼저 200을 반환하고, 처리는 그다음에 하세요. 수신만 확인한 뒤, 응답을 보낸 다음 실제 작업을 처리하세요. 왜 먼저 확인 응답을 보내야 하는지, 그리고 그렇지 않을 경우 어떤 재시도 일정이 뒤따르는지는 서명 및 전송 페이지에 설명되어 있습니다. 아래 핸들러 예시는 프로비저닝 payload에서 그 형태가 어떻게 보이는지 보여줍니다.
app.post("/webhooks/provisioning", verifyWebhook, async (req, res) => {
// Acknowledge first - the job ends once, so this fires once.
res.status(200).send("ok");
const { type, jobId, engineId } = req.body;
if (type === "provisioning.completed") {
const { summary } = req.body;
await db.engines.update({
where: { engineId },
data: {
status: "ready",
brandVoiceCount: summary.brandVoices.count,
glossaryCount: summary.glossaryItems.count,
instructionCount: summary.instructions.count,
},
});
}
if (type === "provisioning.failed") {
console.error(`Provisioning failed: ${jobId} (${engineId})`, req.body.error);
await db.engines.update({
where: { engineId },
data: { status: "needs_configuration" },
});
}
});verifyWebhook 미들웨어는 이 페이지에서 따로 정의하지 않는 유일한 요소입니다. 모든 전송은 Standard Webhooks 사양에 따라 서명됩니다. 원시 본문에 대한 HMAC, 세 개의 헤더, 그리고 callback과 함께 작업을 처음 제출할 때 발급되는 whsec_ 시크릿이 사용됩니다. 프로비저닝과 로컬라이제이션 콜백은 이 방식을 그대로 공유하므로, 관련 설명은 webhook 서명 검증 페이지에 한 번만 정리되어 있습니다. payload를 신뢰하기 전에 반드시 이 미들웨어를 연결하세요. 검증되지 않은 본문은 인증되지 않은 본문과 같습니다.
본문을 신뢰하기 전에 먼저 검증하세요
엔드포인트는 공개 URL이므로 누구나 여기에 POST할 수 있습니다. payload를 기준으로 동작하기 전에, 즉 엔진을 준비 완료로 표시하거나 payload가 생성되었다고 주장하는 ID를 저장하기 전에, 반드시 원시 요청 본문을 기준으로 서명을 검증하세요. 방법 자체, 즉 헤더, HMAC, whsec_ 시크릿에 대한 설명은 서명 검증 페이지에 있습니다.
전송이 적합하지 않은 경우#
webhook은 푸시 기반의 편의 기능일 뿐, 시스템 오브 레코드는 아닙니다. 다른 접근이 더 적합한 경우는 두 가지이며, 둘 다 한 번의 링크 클릭으로 확인할 수 있습니다.
결과가 전달될 때 엔드포인트가 다운되어 있었더라도, 플랫폼은 모든 Lingo webhook과 동일한 일정으로 재시도합니다. 그리고 결과가 callback 안에만 갇혀 있는 것도 아닙니다. AI가 생성한 레코드는 엔진의 실제 구성 그 자체이며, completed 요약은 이미 실제 엔진에 반영된 작업에 대한 보고일 뿐 그 유일한 사본이 아닙니다. 따라서 잠시 다운타임이 있더라도 놓치는 것은 알림뿐이지 엔진은 아닙니다. 재시도 일정 자체는 서명 및 전송 페이지에 있습니다.
반대로 원하는 것이 엔진이 구성되는 동안의 실시간 진행 상황, 즉 서버로 끝났다는 단일 callback을 받는 대신 UI에서 크롤링 후 구성 상태를 보는 것이라면, 필요한 것은 webhook이 아니라 프로비저닝 작업 WebSocket입니다. 이 방식은 연결 시 스냅샷을 스트리밍하고 실행이 진행됨에 따라 진행 이벤트를 전달하며, 작업이 이미 끝난 뒤에도 언제든 연결할 수 있습니다.
