비동기 작업이 끝나면 Lingo.dev는 계속 폴링하게 두지 않습니다. 대신 등록한 callbackUrl HTTPS 엔드포인트로 POST를 보냅니다. 이게 편리한 점입니다. 동시에 노출 지점이기도 하죠. 공개 URL은 인터넷에서 들어오는 어떤 요청이든 받을 수 있고, 누군가 그 URL을 알아내면 위조된 "작업 완료" 이벤트를 여러분의 핸들러에 POST할 수 있습니다.
그래서 모든 콜백의 원칙은 같습니다. 신뢰하기 전에 먼저 검증하세요. 각 전송에는 여러분과 Lingo.dev만 공유하는 시크릿으로 계산된 서명이 포함됩니다. 여러분 쪽에서 이를 다시 계산해 상수 시간으로 비교하면, 위조된 페이로드는 비즈니스 로직까지 도달하지 못합니다. 이 페이지는 그 메커니즘을 설명하는 기준 문서입니다. localization 콜백과 provisioning 콜백 모두 동일한 방식을 그대로 사용하며, 각 페이지에서는 자신들의 페이로드 형식을 설명하고 검증 방식은 이 페이지를 다시 참조합니다.
이 페이지에서 다루는 내용
세 가지 헤더#
Lingo.dev는 여러 제공업체가 구현하는 공개 규격인 Standard Webhooks 사양을 따릅니다. 즉, 벤더마다 제각각인 방식이 아니라 문서화된 공개 계약을 기준으로 검증하게 됩니다. 모든 전송에는 다음 세 가지 헤더가 포함됩니다:
| 헤더 | 설명 |
|---|---|
webhook-id | 전송을 식별하는 고유 ID입니다. |
webhook-timestamp | 전송 시각을 나타내는 초 단위 Unix 타임스탬프입니다. |
webhook-signature | 서명 값 자체입니다: v1,{base64(HMAC-SHA256(secret, "{id}.{timestamp}.{body}"))} |
서명 대상은 세 부분을 마침표로 이어 붙인 문자열입니다. 정확히 webhook-id, 그다음 webhook-timestamp, 그리고 원시 요청 본문 순서입니다. 이 문자열을 그대로 재구성한 뒤 시크릿으로 HMAC-SHA256을 적용하고 결과를 base64 인코딩하면, 비교할 값을 얻을 수 있습니다.
webhook-signature 헤더에는 공백으로 구분된 서명이 여러 개 들어갈 수 있고, 각 서명에는 스킴 버전(v1,...)이 붙습니다. 검증기는 그중 하나라도 일치하면 해당 전송을 유효하다고 봅니다. 따라서 단일 값만 읽기보다 목록 전체를 순회하는 쪽이 더 안전한 파싱 방식이며, 아래 예제도 포함된 모든 서명을 순회합니다.
서명 시크릿#
시크릿은 여러분이 처음으로 callbackUrl와 함께 작업을 제출하는 시점에 조직 단위로 생성됩니다. 형식은 whsec_ 접두사 뒤에 base64 인코딩된 키 바이트가 이어지는 형태입니다:
whsec_Mf9aQ7n...base64...key...bytes원시 키 바이트를 복원하려면 whsec_ 접두사를 제거한 뒤 나머지를 base64 디코딩하세요. HMAC 키는 접두사가 붙은 문자열 자체가 아니라, 이렇게 디코딩한 값입니다. 겉보기에 맞아 보이는 구현이 계속 검증에 실패하는 가장 흔한 이유가 리터럴 whsec_... 문자열에 그대로 서명하는 것이므로, 반드시 먼저 디코딩해야 합니다.
시크릿은 API 키처럼 다루세요
서명 시크릿은 정상 콜백과 위조된 콜백을 가르는 기준입니다. 서버 측에만 보관하고, 소스 제어와 클라이언트 번들에는 절대 포함하지 마세요. 이 값을 가진 사람은 누구나 여러분의 핸들러가 수락할 페이로드에 서명할 수 있습니다. Lingo.dev가 조직 범위 자격 증명을 어떻게 다루는지는 API Keys를 참고하세요.
서명 검증#
검증은 핸들러 앞단에 한 번만 연결해 두면 되는 단일 함수로 끝납니다. 이 함수는 세 가지를 수행합니다. 원시 본문으로 예상 서명을 다시 계산하고, 도착한 값과 상수 시간 비교를 수행하고, 일치하지 않는 요청은 여러분의 코드가 실행되기 전에 차단합니다. localization 완료, provisioning 완료를 비롯해 Lingo.dev가 보내는 모든 비동기 이벤트에, 모든 타입과 모든 제품 표면에서 똑같이 적용되는 함수입니다.
import crypto from "node:crypto";
function verifyWebhook(payload, headers, secret) {
const msgId = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
// Reject timestamps outside a tolerance window (replay prevention)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp, 10)) > 300) {
throw new Error("Webhook timestamp too old");
}
// Recompute the expected signature over id.timestamp.body
const content = `${msgId}.${timestamp}.${payload}`;
const secretBytes = Buffer.from(secret.replace("whsec_", ""), "base64");
const expected = crypto
.createHmac("sha256", secretBytes)
.update(content)
.digest("base64");
// A delivery may carry several signatures; accept if any matches
for (const sig of signatures.split(" ")) {
const [version, value] = sig.split(",", 2);
if (version === "v1" && crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(value)
)) {
return JSON.parse(payload);
}
}
throw new Error("Invalid webhook signature");
}비교에는 일반 문자열 비교 ==가 아니라 상수 시간 함수(crypto.timingSafeEqual, hmac.compare_digest)를 사용하세요. 일반 문자열 비교는 두 바이트가 달라지는 순간 바로 반환하므로, 그 시간 차이만으로도 서명이 한 바이트씩 유출될 수 있습니다. 상수 시간 비교는 이런 부채널을 차단하기 때문에 위의 두 예제도 모두 이 방식을 사용합니다.
원시 본문이 중요한 이유#
두 함수 모두 payload에 서명한다는 점에 주목하세요. 즉, JSON으로 파싱되기 전, 네트워크를 통해 도착한 그대로의 본문입니다. 이 부분은 겉보기에는 올바른 통합이 가장 자주 걸려 넘어지는 지점이라, 문제가 실제로 생기는 바로 그 자리에서 분명히 짚고 넘어갈 가치가 있습니다:
서명은 Lingo.dev가 보낸 정확한 바이트를 기준으로 계산됩니다. 본문을 객체로 파싱한 뒤 다시 직렬화하는 순간 공백, 키 순서, 숫자 형식이 달라질 수 있고, 그러면 다시 계산한 HMAC는 원래 바이트를 기준으로 한 서명과 더 이상 일치하지 않습니다. 페이로드의 의미는 같아도 바이트는 다를 수 있습니다.
파싱된 객체가 아니라 원시 본문으로 검증하세요
프레임워크가 요청 본문을 파싱하기 전에 원시 요청 본문을 확보하고, 그 바이트를 검증 함수에 전달하세요. Express에서는 webhook 라우트에 express.raw({ type: "application/json" })를 사용하고, FastAPI에서는 await request.body()를 읽으세요. 파싱은 서명 검증이 끝난 뒤에 하세요. 먼저 검증하고, 그다음 파싱하세요.
재전송 공격 차단#
공격자가 가로챈 유효한 서명 페이로드는 내용 그대로 재전송될 수 있습니다. 첫 전송이든 한 시간 뒤에 보낸 복사본이든, 그 안의 값이 바뀌지 않는 한 서명은 여전히 유효하기 때문입니다. 이 허용 범위를 제한하는 것이 webhook-timestamp 헤더입니다. 이 헤더는 전송 시각을 기록하므로, 검증기는 여러분이 정한 허용 오차보다 오래된 요청을 거부할 수 있습니다. 위 예제에서는 5분을 사용합니다.
타임스탬프 검사는 오래된 재전송을 막아 줍니다. 허용 오차를 넘긴 뒤 다시 전송된 복사본은 최신성 검사를 통과하지 못하고 핸들러까지 도달하지 않습니다.
빠르게 응답하고 처리는 나중에#
전송이 검증되면 즉시 200을 반환하고, 실제 작업인 데이터베이스 쓰기, 다운스트림 호출, 캐시 무효화는 응답을 보낸 뒤 처리하세요.
app.post(
"/webhooks/lingo",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = verifyWebhook(req.body.toString(), req.headers, process.env.LINGO_WEBHOOK_SECRET);
} catch {
return res.status(401).send("invalid signature");
}
// Acknowledge first, process after - never block the response on slow work
res.status(200).send("ok");
void handleEvent(event);
}
);이유는 스타일이 아니라 동작 방식에 있습니다. 느린 핸들러는 HTTP 연결을 계속 붙잡고 있고, 너무 오래 걸려 타임아웃되면 해당 전송은 실패로 간주되어 재시도됩니다. 즉, 응답 경로 안에서 무거운 작업을 처리하면 하나의 이벤트가 여러 번 처리될 수 있습니다. 빠르게 수신 확인 응답을 보내고, 실제 작업은 큐나 백그라운드 작업으로 넘기세요. 그러면 하나의 이벤트가 그대로 하나의 이벤트로 남습니다. handleEvent 안에서 분기할 페이로드 형식은 각 제품 문서에 있습니다: localization callbacks 및 provisioning callbacks.
재시도와 백오프#
엔드포인트가 일시적으로 내려가는 일은 언제든 생길 수 있습니다. 배포 중일 수도 있고, 타임아웃이 날 수도 있고, bad gateway가 발생할 수도 있습니다. 그럴 때도 Lingo.dev는 이벤트를 버리지 않습니다.
엔드포인트가 2xx가 아닌 상태 코드를 반환하거나 도달할 수 없는 경우, 전송은 30초부터 시작하는 지수 백오프로 재시도되며 최대 5번 시도합니다. 다섯 번째 시도 이후에는 해당 전송이 실패로 표시되고 Lingo.dev는 더 이상 시도하지 않습니다. 하지만 결과 자체가 사라지는 것은 아닙니다. 결과는 작업 레코드에서 계속 조회할 수 있으므로, 일정 시간 동안 다운되더라도 잃는 것은 콜백뿐이고 결과는 그대로 남습니다. 이 작업 레코드는 여러분의 안전장치입니다. 일반적인 경우를 위해 webhook을 구축하되, 언제든 되돌아가 확인할 수 있는 단일 진실 공급원으로 저장된 작업을 두세요. 번역 작업이라면 직접 폴링할 수 있습니다.
