# 웹훅 받기

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

웹훅은 콘솔 화면 없이 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](https://docs.atmark.ai/api/inbound)를 부릅니다. `recipient_positions`는 그 메일의 받는 사람 목록에서의 위치(0부터)입니다. 목록은 주소를 소문자로 바꾸고 중복을 없앤 뒤 유니코드 코드 포인트 순으로 정렬한 것입니다. 보낸 쪽에서 같은 규칙으로 정렬하면 위치를 주소로 되돌릴 수 있습니다. 값은 오름차순이고 겹치지 않습니다.

## 서명 확인

모든 요청에 세 헤더가 붙습니다(Standard Webhooks 형식).

| 헤더 | 값 |
|---|---|
| `webhook-id` | 이벤트 ID. 중복 처리에 씁니다. |
| `webhook-timestamp` | 보낸 시각(유닉스 초) |
| `webhook-signature` | `v1,<base64 서명>`. 여럿이면 공백으로 나뉩니다. |

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

```js title="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` | 뜻 |
|---|---|---|
| `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` | 시간당 재전송 한도에 닿았습니다. 나중에 다시 합니다. |

---

원문: https://docs.atmark.ai/api/webhooks · 마지막 수정 2026-09-27
