웹훅 받기
메일 도착, 발송, 승인 결과를 내 서버로 받습니다. 웹훅은 API로만 관리하고, 모든 요청에 서명이 붙습니다.
웹훅은 콘솔 화면 없이 API로만 등록하고 관리합니다. 에이전트마다 따로 등록합니다.
엔드포인트 등록
POST /v1/agents/{agentId}/webhook-endpoints읽기(messages:read) 권한이 필요합니다.
{ "url": "https://example.com/atmark-webhook", "event_types": ["message.received", "message.bounced"] }url은https주소여야 합니다. 내부망 주소는 거부합니다(400 invalid_webhook_url).event_types를 생략하면 기본 네 가지(approval.resolved,approval.expired,message.sent,message.received)만 받습니다.- 구독은 등록한 뒤 바꿀 수 없습니다. 바꾸려면 새로 등록합니다.
응답(201)에 시크릿(whsec_…)이 한 번만 옵니다. 다시 조회할 수 없으니 바로 저장하세요. 잃어버렸다면 엔드포인트를 새로 등록합니다.
에이전트당 활성 엔드포인트 수는 요금제가 정합니다(409 webhook_endpoint_limit). 하루에 등록할 수 있는 횟수에도 제한이 있습니다(409 webhook_endpoint_registration_limit).
목록과 삭제
GET /v1/agents/{agentId}/webhook-endpoints
DELETE /v1/agents/{agentId}/webhook-endpoints/{endpointId}목록에는 시크릿이 없습니다. 삭제에는 보내기(messages:send) 권한이 필요하고, 삭제한 엔드포인트로는 더 보내지 않습니다. 되살릴 수 없습니다.
이벤트
| 이벤트 | 언제 | data |
|---|---|---|
message.received | 메일이 에이전트에게 도착했을 때(격리된 메일은 제외) | message_id, thread_id, received_at |
message.sent | 메일이 나갔을 때 | message_id, sent_at |
approval.resolved | 승인 요청이 승인되거나 거절됐을 때 | approval_id, decision, resolved_at, message_ids |
approval.expired | 승인 기한이 지났을 때 | approval_id, expired_at, message_ids |
message.delivered | 받는 쪽 메일 서버가 받았을 때(구독해야 받음) | message_id, delivered_at, recipient_positions |
message.bounced | 반송됐을 때(구독해야 받음) | message_id, bounced_at, recipient_positions, bounce_type(permanent·transient·undetermined) |
message.complained | 받는 사람이 스팸으로 신고했을 때(구독해야 받음) | message_id, complained_at, recipient_positions |
요청 본문은 이런 모양입니다.
{
"id": "…",
"type": "message.received",
"api_version": "2026-09-23",
"created_at": "2026-09-27T09:00:00.000Z",
"agent_id": "7c21…f5a1",
"data": { "message_id": "…", "thread_id": "…", "received_at": "2026-09-27T09:00:00.000Z" }
}웹훅에는 보낸 사람, 제목, 본문, 받는 사람 주소가 들어 있지 않습니다. 필요하면 message_id로 받은 메일 API를 부릅니다. recipient_positions는 그 메일의 받는 사람 목록에서의 위치(0부터)입니다. 목록은 주소를 소문자로 바꾸고 중복을 없앤 뒤 유니코드 코드 포인트 순으로 정렬한 것입니다. 보낸 쪽에서 같은 규칙으로 정렬하면 위치를 주소로 되돌릴 수 있습니다. 값은 오름차순이고 겹치지 않습니다.
서명 확인
모든 요청에 세 헤더가 붙습니다(Standard Webhooks 형식).
| 헤더 | 값 |
|---|---|
webhook-id | 이벤트 ID. 중복 처리에 씁니다. |
webhook-timestamp | 보낸 시각(유닉스 초) |
webhook-signature | v1,<base64 서명>. 여럿이면 공백으로 나뉩니다. |
서명은 <webhook-id>.<webhook-timestamp>.<본문>을 시크릿으로 HMAC-SHA256한 값입니다. 시크릿의 whsec_ 뒤는 base64입니다.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyWebhook(secret, headers, rawBody) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) return false;
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const given = Buffer.from(value, 'base64');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}본문은 파싱하기 전의 원문 그대로 씁니다. 시각은 5분 이내만 받아 재생 공격을 막습니다.
전달 규칙
- 10초 안에
2xx로 답해야 성공입니다. 무거운 처리는 받은 뒤 따로 합니다. 2xx가 아니거나, 시간이 넘었거나, 연결이 안 되면 실패입니다. 리다이렉트(3xx)는 따라가지 않고 실패로 칩니다.- 실패하면 30초, 2분, 10분, 30분, 1시간, 2시간, 4시간, 8시간, 16시간 뒤에 다시 보냅니다. 첫 시도까지 모두 10번, 약 31시간 40분 동안입니다.
- 다시 보낼 때도 이벤트 ID와 본문은 같고 서명 시각만 새로 붙습니다.
webhook-id로 중복을 거릅니다. - 보낼 때 목적지가 거부되면(내부망 주소로 풀리는 도메인, 잘못된 URL, 삭제된 엔드포인트) 다시 시도하지 않고 바로
dead가 됩니다. - 끝내 실패한 전달도
dead가 됩니다.
전달 기록과 재전송
GET /v1/agents/{agentId}/webhook-deliveries?status=dead
POST /v1/agents/{agentId}/webhook-deliveries/{deliveryId}/redeliver목록(읽기 권한)은 status(pending·delivered·dead), limit(1~100), cursor를 받습니다. 항목마다 id, event_id, event_type, endpoint_id, status, attempt_count, last_error, last_http_status, redelivery_of, created_at, next_attempt_at(pending일 때만), delivered_at, dead_at이 있습니다. 본문과 URL은 목록에 없습니다.
재전송(보내기 권한)은 dead인 전달만 되고, 같은 이벤트 ID와 본문으로 새 전달을 만듭니다(201). 한 전달은 3번까지, 에이전트당 시간당 100건까지 다시 보낼 수 있습니다.
오류
| 상태 | error | 뜻 |
|---|---|---|
400 | invalid_webhook_url | 등록할 수 없는 URL입니다. |
404 | not_found | 엔드포인트나 전달이 없습니다(삭제, 재전송). |
409 | webhook_endpoint_limit | 에이전트당 활성 엔드포인트 한도에 닿았습니다. |
409 | webhook_endpoint_registration_limit | 하루 등록 횟수 한도에 닿았습니다. |
409 | delivery_not_dead | dead인 전달만 다시 보낼 수 있습니다. |
409 | webhook_endpoint_deleted | 삭제된 엔드포인트로 가는 전달은 다시 보낼 수 없습니다. |
409 | redelivery_destination_rejected | 목적지가 거부돼 죽은 전달입니다. URL을 고쳐 새 엔드포인트를 등록합니다. |
409 | delivery_already_delivered | 이 전달(또는 그 재전송)은 이미 받았습니다. |
409 | redelivery_limit | 한 전달의 재전송 횟수를 다 썼습니다. |
429 | redelivery_rate_limited | 시간당 재전송 한도에 닿았습니다. 나중에 다시 합니다. |
이 문서에 대한 의견은 support@atmark.ai로 보내 주세요.