웹훅 받기

메일 도착, 발송, 승인 결과를 내 서버로 받습니다. 웹훅은 API로만 관리하고, 모든 요청에 서명이 붙습니다.

Markdown 보기마지막 수정

웹훅은 콘솔 화면 없이 API로만 등록하고 관리합니다. 에이전트마다 따로 등록합니다.

엔드포인트 등록

http
POST /v1/agents/{agentId}/webhook-endpoints

읽기(messages:read) 권한이 필요합니다.

json
{ "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).

목록과 삭제

http
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

요청 본문은 이런 모양입니다.

json
{
  "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-signaturev1,<base64 서명>. 여럿이면 공백으로 나뉩니다.

서명은 <webhook-id>.<webhook-timestamp>.<본문>을 시크릿으로 HMAC-SHA256한 값입니다. 시크릿의 whsec_ 뒤는 base64입니다.

verify-webhook.mjs
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가 됩니다.

전달 기록과 재전송

http
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뜻
400invalid_webhook_url등록할 수 없는 URL입니다.
404not_found엔드포인트나 전달이 없습니다(삭제, 재전송).
409webhook_endpoint_limit에이전트당 활성 엔드포인트 한도에 닿았습니다.
409webhook_endpoint_registration_limit하루 등록 횟수 한도에 닿았습니다.
409delivery_not_deaddead인 전달만 다시 보낼 수 있습니다.
409webhook_endpoint_deleted삭제된 엔드포인트로 가는 전달은 다시 보낼 수 없습니다.
409redelivery_destination_rejected목적지가 거부돼 죽은 전달입니다. URL을 고쳐 새 엔드포인트를 등록합니다.
409delivery_already_delivered이 전달(또는 그 재전송)은 이미 받았습니다.
409redelivery_limit한 전달의 재전송 횟수를 다 썼습니다.
429redelivery_rate_limited시간당 재전송 한도에 닿았습니다. 나중에 다시 합니다.

이 문서에 대한 의견은 support@atmark.ai로 보내 주세요.