알림과 제한
알림 하나는 금, 은, 구리 중 한 금속의 현물 가격을 규칙 하나로 감시하고 URL 하나로 전송합니다. Plus와 Pro는 각각 알림 5개를 포함합니다. Free와 Basic에는 가격 알림이 포함되지 않습니다. 요금제가 종료되거나 결제에 실패하면 요금제가 다시 활성화될 때까지 전송이 중단됩니다.
- 가격 수준 통과: 가격이 지정 수준의 한쪽에서 반대쪽으로 이동합니다. 상향, 하향 또는 양방향을 설정할 수 있습니다. 알림이 발생한 뒤 최소 10분 동안은 알림을 보내지 않습니다. 그동안 가격이 반대 방향으로 수준을 통과했다가 되돌아와도 두 번째 통과 알림은 보내지 않습니다. 그 시간이 지나고 가격이 수준을 반대 방향으로 넘어 통과 전의 쪽으로 돌아와 수준에서 0.1%만큼 떨어져야 다시 발생할 수 있는 상태가 됩니다. 실제로 다시 수준을 통과하면 알림을 보내고, 수준 주변의 작은 움직임은 걸러냅니다.
- 변동률: 1시간 또는 24시간 동안 가격이 지정 비율만큼 변동합니다. 한 번 발생한 뒤 1시간이 지나고 변동률이 임계값 아래로 내려가면 다시 발생할 수 있습니다. 매주 시장이 열린 직후의 24시간 변동은 재개장 가격을 기준으로 측정합니다. 주말 동안 생긴 가격 차이는 변동에 포함하지 않습니다.
- 일일 종가: UTC 기준 하루의 마지막 가격을 UTC 자정 직후에 전송합니다. 주말처럼 그날 가격이 없는 금속은 종가도 없습니다.
하루는 UTC 기준입니다. 일일 종가 알림은 하루에 한 번 전송합니다. 가격이 지정 수준 주변에서 오르내려도 가격이 갱신될 때마다 요청을 보내지는 않습니다.
수신할 수 있는 이벤트
price.level_cross: 가격이 설정한 수준을 통과했습니다.price.percent_move: 가격이 설정한 비율만큼 변동했습니다.price.daily_close: UTC 기준 하루가 끝났습니다.webhook.test: 알림에서 Send test를 눌렀습니다.data는{"alert_id":"<alert id>"}입니다. 알림마다 분당 한 번 테스트를 보낼 수 있으며, 동시에 전송 대기 중인 테스트는 최대 20개입니다.
요청 읽기
내 URL로 POST를 받습니다. URL은 https://, 포트 443, 공개 호스트 이름을 사용해야 합니다. 사용자 이름이나 비밀번호를 포함할 수 없으며 2048자를 넘을 수 없습니다. 주소는 전송할 때마다 확인합니다. 리디렉션은 따르지 않으며 3xx 응답은 실패로 처리합니다. 응답에서는 상태 코드만 읽습니다.
본문은 JSON이며 최상위 필드는 id, type, created_at, data 네 개입니다. 실제 본문의 키 순서와 공백은 여기의 예제와 다를 수 있습니다.
- idstring
- 고유한 이벤트 식별자입니다. 중복을 건너뛰는 데 사용하세요.
- typestring
- 위에 나열된 이벤트 유형 중 하나입니다.
- created_atstring
Z로 끝나는 ISO 8601 형식의 UTC 시각입니다. 일일 종가는 종료된 날짜의 23:59:59입니다.- data.alert_idstring
- 발생한 알림입니다. 시크릿을 교체해도 바뀌지 않습니다.
- data.kindstring
level_cross,percent_move또는daily_close입니다.- data.symbolstring
XAU-USD-SPOT,XAG-USD-SPOT또는HG-USD-SPOT입니다.- data.directionstring
- 알림에 설정한
above,below또는either입니다. - data.pricestring
- 알림을 발생시킨 가격을 십진수 문자열로 나타냅니다. 가격 수준 및 변동률 이벤트에만 있습니다.
- data.observed_atstring
- 가격이 관측된 시각입니다. 가격 수준 및 변동률 이벤트에만 있습니다.
- data.levelstring
- 설정한 가격 수준입니다. 가격 수준 이벤트에만 있습니다.
- data.percent, data.window, data.reference_pricestring
- 설정한 변동률, 기간(
1h또는24h), 기간 시작 시점의 가격입니다. 변동률 이벤트에만 있습니다. - data.date, data.closestring
- UTC 기준 날짜와 그날의 마지막 가격입니다. 일일 종가 이벤트에만 있습니다.
요청 본문 예제
price.level_cross
{
"id": "evt_3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03_level_cross:492841",
"type": "price.level_cross",
"created_at": "2026-10-23T14:02:11.482113Z",
"data": {
"alert_id": "3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03",
"kind": "level_cross",
"symbol": "XAU-USD-SPOT",
"direction": "above",
"price": "4203.1",
"level": "4200",
"observed_at": "2026-10-23T14:02:11.482113+00:00"
}
}price.percent_move
{
"id": "evt_8a1d52c4-0b7e-4f55-a3d9-61e2c7b8f410_percent_move:492855",
"type": "price.percent_move",
"created_at": "2026-10-23T09:15:40.120934Z",
"data": {
"alert_id": "8a1d52c4-0b7e-4f55-a3d9-61e2c7b8f410",
"kind": "percent_move",
"symbol": "XAU-USD-SPOT",
"direction": "either",
"price": "4168.8",
"percent": "2",
"window": "24h",
"reference_price": "4083.05",
"observed_at": "2026-10-23T09:15:40.120934+00:00"
}
}price.daily_close
{
"id": "evt_c92e0f7b-31a4-4d6a-8e57-a05b3d1c92e8_close:2026-10-22",
"type": "price.daily_close",
"created_at": "2026-10-22T23:59:59Z",
"data": {
"alert_id": "c92e0f7b-31a4-4d6a-8e57-a05b3d1c92e8",
"kind": "daily_close",
"symbol": "XAG-USD-SPOT",
"direction": "either",
"date": "2026-10-22",
"close": "48.62"
}
}webhook.test
{
"id": "evt_test_1792143012345",
"type": "webhook.test",
"created_at": "2026-10-23T09:30:12.345000Z",
"data": {
"alert_id": "3f0c6a52-6a2e-4b8e-9c1d-2f4b7e9a1c03"
}
}서명 검증
이벤트를 처리하기 전에 Webhook-Signature를 확인하세요. 수신한 원시 본문 바이트를 그대로 사용하세요. 검증하기 전에 JSON을 파싱하고 다시 직렬화해서는 안 됩니다.
Webhook-Signature를 쉼표로 나누어t와v1을 읽습니다.t가 서버 시계보다 300초 넘게 빠르거나 늦으면 요청을 거부합니다.t, 마침표, 원시 본문을 이어 붙여 메시지를 만듭니다.<t>.뒤에 본문이 옵니다.- 알림의 서명 시크릿(
whsec_로 시작)을 사용해 메시지의 HMAC-SHA256을 계산하고 십육진수로 인코딩합니다. - 상수 시간 비교로
v1과 비교합니다. 값이 다르면 요청을 거부합니다.
Node.js
Python
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const TOLERANCE_SECONDS = 300;
// rawBody: the request body exactly as received (a Buffer), before any JSON parsing.
function verifyWebhook(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
const fields = {};
for (const part of String(header ?? "").split(",")) {
const i = part.indexOf("=");
if (i > 0) fields[part.slice(0, i).trim()] = part.slice(i + 1).trim();
}
const t = Number(fields.t);
if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret).update(t + ".").update(rawBody).digest();
const given = Buffer.from(fields.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
const seen = new Set(); // use your database in production
createServer((req, res) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks);
const header = req.headers["webhook-signature"];
if (!verifyWebhook(rawBody, header, process.env.WEBHOOK_SECRET)) {
res.writeHead(400).end();
return;
}
const event = JSON.parse(rawBody);
if (!seen.has(event.id)) {
seen.add(event.id);
// Hand the event to a queue or background job here, then answer quickly.
console.log(event.type, event.data);
}
res.writeHead(204).end();
});
}).listen(Number(process.env.PORT ?? 3000));import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_webhook(raw_body: bytes, header: str, secret: str, now: int | None = None) -> bool:
"""raw_body is the request body exactly as received, before any JSON parsing."""
fields = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
try:
t = int(fields["t"])
except (KeyError, ValueError):
return False
now = int(time.time()) if now is None else now
if abs(now - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(fields.get("v1", "").encode(), expected.encode())
# Flask: verify_webhook(request.get_data(), request.headers.get("Webhook-Signature", ""), secret)
# Django: verify_webhook(request.body, request.headers.get("Webhook-Signature", ""), secret)응답과 재시도 처리
10초 안에 아무 2xx 코드나 반환하세요. 3xx, 4xx, 5xx, 시간 초과 또는 연결 오류는 실패한 시도로 처리합니다. 이벤트를 저장하고 빠르게 응답한 뒤, 오래 걸리는 작업은 백그라운드에서 처리하세요.
- 첫 번째 재시도: 첫 실패 후 1분.
- 두 번째 재시도: 이전 실패 후 5분.
- 세 번째 재시도: 이전 실패 후 30분.
- 네 번째 재시도: 이전 실패 후 2시간.
- 다섯 번째이자 마지막 재시도: 이전 실패 후 6시간.
총 6번을 약 8.5시간에 걸쳐 시도합니다. 마지막 시도까지 실패하면 해당 이벤트는 다시 전송되지 않으며, 이메일로 대신 알리지도 않습니다. 각 시도는 새 타임스탬프로 다시 서명됩니다. 처음 전송을 시도할 때 이미 한 시간이 지난 이벤트는 폐기됩니다. 전송 기록은 30일 동안 보관합니다.
서명 시크릿 교체
시크릿은 알림을 추가할 때 한 번만 표시됩니다. 유출이 의심되거나 분실했다면 대시보드의 알림에서 Rotate secret을 선택하세요. 새 시크릿도 한 번만 표시됩니다. 알림 식별자는 유지되며, 기존 시크릿은 즉시 작동을 멈추고 이후 전송에는 새 시크릿을 사용합니다. 전송 실패를 방지하려면 전환하는 동안 몇 분간 서버가 두 시크릿을 모두 허용하도록 하세요.
질문
가격 알림을 사용하려면 어떤 요금제가 필요한가요?
Plus ($39/월) 요금제와 Pro ($89/월) 요금제에는 각각 가격 알림 5개가 포함되어 있습니다. Free 요금제와 Basic 요금제에는 가격 알림이 포함되어 있지 않으며, 이 두 요금제에서는 가격 알림을 사용할 수 없습니다.
알림 하나가 얼마나 자주 발생할 수 있나요?
가격 수준 알림은 발생 후 최소 10분 동안 통과 알림을 보내지 않습니다. 그 시간이 지나고 가격이 수준을 반대 방향으로 넘어 통과 전의 쪽으로 돌아와 수준에서 0.1%만큼 떨어져야 다시 발생할 수 있는 상태가 됩니다. 변동률 알림은 한 번 발생한 뒤 1시간이 지나고 변동이 사라지면 다시 발생할 수 있습니다. 일일 종가는 UTC 기준으로 하루에 한 번 전송합니다.
서버가 중단되면 어떻게 되나요?
전송에 실패한 경우에는 약 8.5시간에 걸쳐 최대 5번의 추가 전송을 시도합니다. 마지막 전송 시도까지 실패하면 해당 이벤트는 더 이상 전송하지 않습니다. 이메일로 대신 알리는 기능도 없습니다.
같은 이벤트를 두 번 받을 수도 있나요?
네. 이벤트 처리가 이미 끝났더라도 전송을 다시 시도할 수 있습니다. 예를 들어 서버의 응답이 10초가 지난 뒤에 도착하면 재시도할 수 있습니다. 처리한 이벤트의 식별자를 저장해 두고, 같은 이벤트가 다시 오면 건너뛰세요.
관련 문서
- /docs/stream : WebSocket과 SSE를 통한 실시간 가격
- /pricing : 요금제와 제한