발송 API 연동 가이드

구매자 서비스(청구 시스템·쇼핑몰·예약 시스템 등)에서 rousme-mailer 로 메일을 보내는 개발자를 위한 문서입니다. HTTPS 로 JSON 을 보내면 되고, 별도 라이브러리는 필요 없습니다.

이 문서는 쓰는 쪽(구매자 서비스 개발자) 기준입니다. 관리 화면 사용법은 사용법.

1. 시작 전에 관리자에게 받을 것

항목 예 어디서
API 주소 https://mailer.example.com/api/v1 관리 화면 [발송 API] 탭 아래 "연동 안내"
API 키 mk_ 로 시작하는 긴 문자열 관리 화면 [발송 API] 탭 → [키 만들기] (한 번만 보여 줌)
보낼 수 있는 보낸사람 도메인 example.com GET /api/v1/status 의 sender_domains
템플릿 이름·변수 (템플릿을 쓸 때) invoice / 고객명, 금액 … 관리 화면 [템플릿] 탭, GET /api/v1/templates

2. 연동 시험은 이렇게

서버가 테스트 모드면 받는사람이 누구든 모든 메일이 서버 안의 Mailpit 으로만 가고 밖으로 나가지 않습니다. 응답의 "mode": "test" 로 알 수 있습니다. 연동을 만들 때는 관리자에게 테스트 모드로 두어 달라고 하거나, 받는사람을 아무거나@mailpit.test 로 쓰세요 (실서버 모드에서도 이 도메인은 Mailpit 으로 갑니다).

curl https://mailer.example.com/api/v1/status -H "Authorization: Bearer $MAILER_KEY"
# {"mode": "test", "james": "active", "key": "청구 시스템", "scopes": ["send"],
#  "sender_domains": ["example.com"], "test_domain": "mailpit.test",
#  "live_limit": {"daily_limit": 1000, "used_today": 12, "remaining": 988}, "queue": 0}

3. 공통

4. 한 통 보내기 — POST /api/v1/messages

받는사람 1명에 한 통입니다. 청구서처럼 사람마다 내용이 다르므로 여러 명이면 한 명씩 보냅니다.

{
  "from": "billing",
  "from_name": "○○상사",
  "to": "customer@example.org",
  "subject": "[○○상사] 10월 청구서",
  "text": "홍길동 님, 10월 청구 금액은 55,000원입니다.",
  "html": "<p>홍길동 님, 10월 청구 금액은 <b>55,000원</b>입니다.</p>",
  "attachments": [{"filename": "청구서-202610.pdf", "content": "<base64>", "type": "application/pdf"}],
  "category": "transactional",
  "tags": ["invoice", "2026-10"],
  "metadata": {"invoice_id": "00123"}
}
항목 필수 설명
from ✔ 보낸사람. billing 처럼 앞부분만 주면 대표 발신 도메인(billing@example.com), 전체 주소는 발신 도메인 안이어야 함
from_name 보낸사람 이름 (80자)
to ✔ 받는사람 주소 1개
subject ✔ 제목 (한 줄, 300자)
text, html 하나 이상 본문 (합계 1MB). html 만 주면 텍스트 본문은 서버가 HTML 에서 만들어 넣음
attachments [{filename, content(base64), type?}] 5개·합계 10MB까지. type 을 빼면 파일 이름으로 정함
category transactional(거래성, 기본) 또는 marketing(광고) — 7절
tags 문자열 10개까지 (각 40자, 한글·영문·숫자·_.:-). 목록에서 거를 때 씀
metadata 아무 JSON 객체 (2,000자). 조회할 때 그대로 돌려줌 — 주문번호·청구번호 등
template, variables 템플릿으로 보낼 때 (5절). 이때 subject·text·html·category 는 넣지 않음

모르는 항목을 넣으면 400 입니다 (오타를 조용히 무시하지 않으려고).

응답 202 Accepted:

{"id": "msg_1a0eacc71711a615dc", "status": "queued", "mode": "live", "to": "customer@example.org",
 "from": "billing@example.com", "subject": "[○○상사] 10월 청구서", "category": "transactional",
 "template": null, "tags": ["invoice", "2026-10"], "metadata": {"invoice_id": "00123"},
 "reason": null, "host": null, "created": "2026-10-01T09:30:12+09:00", "accepted": null, "finished": null,
 "events": [{"time": "2026-10-01T09:30:12+09:00", "status": "queued"}]}

202 는 "받아서 대기열에 넣었다" 는 뜻입니다. 실제 전달 결과는 6절처럼 조회합니다 (보통 몇 초).

중복 방지: Idempotency-Key (꼭 쓰세요)

네트워크 시간 초과로 응답을 못 받으면, 보냈는지 모르는 채로 다시 보내게 됩니다. 요청에 Idempotency-Key 머리글을 주면 같은 키의 요청은 24시간 동안 한 통만 보내고 처음 메일을 다시 돌려줍니다 (응답 머리글 Idempotent-Replayed: true).

5. 템플릿으로 보내기

문구는 관리 화면 [템플릿] 탭에서 만들고, 구매자 서비스는 템플릿 이름과 변수 값만 보냅니다. 문구를 고칠 때 구매자 서비스를 고치지 않아도 됩니다.

{
  "template": "invoice",
  "to": "customer@example.org",
  "variables": {"고객명": "홍길동", "청구월": "10월", "금액": "55,000", "납부기한": "10월 31일",
                "청구번호": "INV-202610-00123", "항목표": "<table><tr><td>기본료</td><td>50,000</td></tr></table>"},
  "attachments": [{"filename": "청구서-202610.pdf", "content": "<base64>"}]
}

도움 API (권한 read 이상):

GET  /api/v1/templates                    → {"items": [{"name", "description", "category", "subject", "variables", "from", "from_name", "active"}]}
POST /api/v1/templates/{이름}/render       {"variables": {...}} → {"subject", "text", "html"}   (보내지 않음)

6. 결과 조회

GET /api/v1/messages/{id}
status 뜻 다음
queued 받아서 대기 중 몇 초 안에 accepted
accepted 메일 서버(James)가 접수, 받는 쪽으로 보내는 중 delivered / deferred / bounced
delivered 받는 쪽 메일 서버가 받음 (host 에 서버 이름, 테스트 모드는 Mailpit) 끝
deferred 일시 실패, 메일 서버가 다시 시도 중 (reason) delivered 또는 bounced
bounced 영구 실패 (reason 에 받는 쪽 응답, 예: 550 5.1.1 … does not exist) 끝. 주소 문제면 발송 제외 목록에 자동 추가
suppressed 발송 제외 목록에 있어 보내지 않음 (reason) — 한도도 쓰지 않음 끝
failed 메일 서버에 넣지 못함 (30분 넘게 멈춤, 거부, 받은 뒤에 관리자가 모드를 바꾸거나 하루 한도를 줄여 한도에 걸림) 끝. 필요하면 새로 보냄

delivered 는 받는 쪽 서버가 받았다는 뜻이고 받은편지함·스팸함 중 어디에 들어갔는지까지는 알 수 없습니다. events 에 단계별 시각이 쌓이고, 광고 메일은 받는 사람이 수신거부를 누르면 unsubscribed 사건이 붙습니다.

조회 주기: 결과 알림(웹훅)은 아직 없습니다. 보낸 뒤 5초·30초·5분 정도 간격으로 조회하고, delivered·bounced·suppressed·failed 가 되면 멈추세요. 청구서라면 bounced·failed 일 때 담당자에게 알리면 됩니다.

목록:

GET /api/v1/messages?status=bounced&tag=invoice&since=2026-10-01T00:00:00%2B09:00&limit=50&cursor=<next_cursor>
→ {"items": [...], "next_cursor": "msg_…" 또는 null}

최신순이고, next_cursor 를 다음 요청의 cursor 로 넣으면 다음 쪽입니다. 기록은 90일 보관됩니다.

7. 거래성 메일과 광고 메일

거래성 transactional (기본) 광고 marketing
예 청구서·영수증·예약 확인·비밀번호 재설정 뉴스레터·할인·신상품 안내
수신거부한 주소 보냄 (광고가 아니므로) 보내지 않음 (suppressed)
영구 반송·관리자가 막은 주소 보내지 않음 보내지 않음
제목 제한 없음 (광고) 로 시작해야 함 — 없으면 400 (정보통신망법 50조, 자동으로 붙이지 않음)
수신거부 링크 없음 메일 앱의 "구독 취소" 버튼(List-Unsubscribe)이 자동으로 붙음
주소록에서 "광고 동의 안 함" 보냄 보내지 않음 (suppressed, 사유 "주소록에서 광고 수신 동의 안 함")
보내는 시각 언제나 기본 8~21시만 — 밤(21시~다음 날 8시)에 요청하면 202 queued 로 받아 두었다가 다음 날 8시에 보냄 (reason 에 보낼 시각). 법으로는 전자우편이 야간 광고 별도 동의의 예외지만, 밤 광고는 신고로 이어지기 쉬워 기본으로 막아 둠. 관리자가 서버 설정 AD_HOURS 로 바꾸거나 끌 수 있음

광고를 거래성으로 보내면 법 위반이고 메일 평판도 떨어집니다. 종류를 정확히 넣어 주세요. 광고 수신 동의는 구매자 서비스가 받아 두고 주소록 API(10절)로 알려 주세요. 주소록에 없는 주소로 광고를 보내면 막지 않으므로(구매자 서비스 책임) 동의한 사람에게만 보내야 합니다.

8. 오류와 다시 보내기

상태 error 예 다시 보내도 되나
400 invalid_request 아니오 — 요청을 고침 (message 참고)
401 unauthorized 아니오 — 키 확인 (없음·틀림·꺼짐)
403 forbidden 아니오 — 권한(read 키로 보내기)·허용 IP
409 idempotency_conflict 아니오 — 같은 키로 다른 내용
413 too_large 아니오 — 본문·첨부 줄이기
422 invalid_recipient, sender_not_allowed, template_not_found, missing_variables 아니오 — 값 고침
429 rate_limited, daily_limit 예 — Retry-After 초 뒤. daily_limit 은 자정 뒤
503 unavailable 예 — 잠시 뒤
연결 실패·시간 초과·5xx 예 — 같은 Idempotency-Key 로

다시 보낼 때는 반드시 같은 Idempotency-Key 를 쓰세요. 그래야 앞 요청이 사실은 성공했더라도 두 통이 가지 않습니다. 간격은 1초·5초·30초·2분처럼 늘리는 것을 권합니다.

9. 예시 코드

아래 예시는 모두 같은 일을 합니다: 템플릿 invoice 로 PDF 첨부 청구서 한 통 → 실패하면 같은 키로 다시 → 결과 조회. 주소와 키는 환경변수 MAILER_URL(예 https://mailer.example.com), MAILER_KEY 로 받습니다.

curl

PDF=$(base64 < 청구서-202610.pdf | tr -d '\n')
curl -sS "$MAILER_URL/api/v1/messages" \
  -H "Authorization: Bearer $MAILER_KEY" \
  -H "Idempotency-Key: INV-202610-00123" \
  -H "Content-Type: application/json" \
  --retry 3 --retry-all-errors --max-time 30 \
  -d @- <<EOF
{"template": "invoice", "to": "customer@example.org",
 "variables": {"고객명": "홍길동", "청구월": "10월", "금액": "55,000", "납부기한": "10월 31일",
               "청구번호": "INV-202610-00123", "항목표": ""},
 "attachments": [{"filename": "청구서-202610.pdf", "content": "$PDF"}],
 "tags": ["invoice"], "metadata": {"invoice_id": "00123"}}
EOF

curl -sS "$MAILER_URL/api/v1/messages/msg_…" -H "Authorization: Bearer $MAILER_KEY"

Python (requests)

import base64, os, time
import requests

URL, KEY = os.environ["MAILER_URL"], os.environ["MAILER_KEY"]
H = {"Authorization": f"Bearer {KEY}"}
RETRY = {429, 500, 502, 503, 504}


def send_invoice(to, invoice_no, values, pdf_path):
    with open(pdf_path, "rb") as f:
        pdf = base64.b64encode(f.read()).decode()
    body = {"template": "invoice", "to": to, "variables": values,
            "attachments": [{"filename": os.path.basename(pdf_path), "content": pdf}],
            "tags": ["invoice"], "metadata": {"invoice_no": invoice_no}}
    for wait in (1, 5, 30, 120, None):
        try:
            r = requests.post(f"{URL}/api/v1/messages", json=body, timeout=30,
                              headers={**H, "Idempotency-Key": invoice_no})  # 다시 보낼 때도 같은 키
            if r.status_code not in RETRY:
                break
            if wait:
                wait = max(wait, int(r.headers.get("Retry-After", 0)))
        except requests.RequestException:
            pass
        if wait is None:
            raise RuntimeError("메일 서버에 보내지 못했습니다")
        time.sleep(wait)
    if r.status_code != 202:
        err = r.json()
        raise ValueError(f"{r.status_code} {err['error']}: {err['message']} {err.get('missing', '')}")
    return r.json()["id"]


def wait_result(msg_id, timeout=300):
    end = time.time() + timeout
    while time.time() < end:
        m = requests.get(f"{URL}/api/v1/messages/{msg_id}", headers=H, timeout=30).json()
        if m["status"] in ("delivered", "bounced", "suppressed", "failed"):
            return m
        time.sleep(5)
    return m  # 아직 accepted/deferred — 나중에 다시 조회


msg_id = send_invoice("customer@example.org", "INV-202610-00123",
                      {"고객명": "홍길동", "청구월": "10월", "금액": "55,000", "납부기한": "10월 31일",
                       "청구번호": "INV-202610-00123", "항목표": ""},
                      "청구서-202610.pdf")
m = wait_result(msg_id)
print(m["status"], m["host"] or m["reason"])

PHP (curl 확장)

<?php
$url = getenv('MAILER_URL');
$key = getenv('MAILER_KEY');

function mailer($method, $path, $body = null, $idem = null) {
    global $url, $key;
    $h = ["Authorization: Bearer $key", "Content-Type: application/json"];
    if ($idem) $h[] = "Idempotency-Key: $idem";
    $ch = curl_init($url . $path);
    curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h,
        CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30]);
    if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_UNESCAPED_UNICODE));
    $res = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    return [$code, $res === false ? null : json_decode($res, true)];
}

$no = 'INV-202610-00123';
$body = [
    'template' => 'invoice', 'to' => 'customer@example.org',
    'variables' => ['고객명' => '홍길동', '청구월' => '10월', '금액' => '55,000', '납부기한' => '10월 31일',
                    '청구번호' => $no, '항목표' => ''],
    'attachments' => [['filename' => '청구서-202610.pdf', 'content' => base64_encode(file_get_contents('청구서-202610.pdf'))]],
    'tags' => ['invoice'], 'metadata' => ['invoice_no' => $no],
];
foreach ([1, 5, 30, 120, 0] as $wait) {       // 실패하면 같은 키로 다시
    [$code, $res] = mailer('POST', '/api/v1/messages', $body, $no);
    if ($code && !in_array($code, [429, 500, 502, 503, 504])) break;
    if (!$wait) throw new Exception('메일 서버에 보내지 못했습니다');
    sleep($wait);
}
if ($code != 202) throw new Exception("$code {$res['error']}: {$res['message']}");

$id = $res['id'];
for ($i = 0; $i < 60; $i++) {
    sleep(5);
    [, $m] = mailer('GET', "/api/v1/messages/$id");
    if (in_array($m['status'], ['delivered', 'bounced', 'suppressed', 'failed'])) break;
}
echo $m['status'], ' ', $m['host'] ?? $m['reason'], "\n";

Node.js (18 이상, 내장 fetch)

send-invoice.mjs 처럼 .mjs 로 저장하거나 package.json 에 "type": "module".

import { readFile } from "node:fs/promises";

const URL = process.env.MAILER_URL, KEY = process.env.MAILER_KEY;
const H = { Authorization: `Bearer ${KEY}` };
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));

async function sendInvoice(to, invoiceNo, variables, pdfPath) {
  const body = JSON.stringify({
    template: "invoice", to, variables,
    attachments: [{ filename: pdfPath.split("/").pop(), content: (await readFile(pdfPath)).toString("base64") }],
    tags: ["invoice"], metadata: { invoice_no: invoiceNo },
  });
  for (const wait of [1, 5, 30, 120, 0]) {
    let r;
    try {
      r = await fetch(`${URL}/api/v1/messages`, {
        method: "POST", body, signal: AbortSignal.timeout(30000),
        headers: { ...H, "Content-Type": "application/json", "Idempotency-Key": invoiceNo },  // 다시 보낼 때도 같은 키
      });
    } catch { r = null; }
    if (r && ![429, 500, 502, 503, 504].includes(r.status)) {
      const d = await r.json();
      if (r.status !== 202) throw new Error(`${r.status} ${d.error}: ${d.message}`);
      return d.id;
    }
    if (!wait) throw new Error("메일 서버에 보내지 못했습니다");
    await sleep(Number(r?.headers.get("retry-after")) || wait);
  }
}

async function waitResult(id) {
  for (let i = 0; i < 60; i++) {
    await sleep(5);
    const m = await (await fetch(`${URL}/api/v1/messages/${id}`, { headers: H })).json();
    if (["delivered", "bounced", "suppressed", "failed"].includes(m.status)) return m;
  }
}

const id = await sendInvoice("customer@example.org", "INV-202610-00123",
  { 고객명: "홍길동", 청구월: "10월", 금액: "55,000", 납부기한: "10월 31일", 청구번호: "INV-202610-00123", 항목표: "" },
  "청구서-202610.pdf");
const m = await waitResult(id);
console.log(m.status, m.host ?? m.reason);

10. 주소록 맞추기 — 회원 가입·동의 변경·탈퇴

관리 화면의 [주소록]은 광고·뉴스레터를 그룹으로 보낼 때 쓰고, 사람마다 광고 수신 동의(일시·방법) 를 기록합니다. 회원 가입·설정 변경·탈퇴 때 구매자 서비스가 아래 API 를 부르면 주소록이 늘 맞게 유지됩니다. 키에 contacts 권한이 필요합니다.

방법 경로 설명
PUT /api/v1/contacts/{이메일} 넣거나 고침 — 새로 만들면 201, 고치면 200 (result: added·updated·unchanged)
GET /api/v1/contacts/{이메일} 한 명 (그룹, 동의, 발송 제외 사유, 동의 기록)
GET /api/v1/contacts?email=&group=&consent=yes\|no\|unknown&limit=&cursor= 목록·찾기 (email 은 일부만 넣어도 됨)
DELETE /api/v1/contacts/{이메일} 지움 (탈퇴 — 동의 기록도 지움. 발송 제외 목록의 주소는 남아 다시 보내지 않음)
GET /api/v1/groups 그룹 이름·인원

PUT 본문 (준 항목만 바뀜):

{
  "name": "홍길동",
  "fields": {"등급": "골드", "지역": "서울", "예전필드": null},
  "groups": {"add": ["회원", "뉴스레터"], "remove": ["휴면"]},
  "consent": {"marketing": true, "at": "2026-09-01T10:00:00+09:00", "method": "회원가입 체크박스"}
}
# 회원 가입·설정 변경 때
def sync_member(m):
    body = {"name": m.name, "fields": {"등급": m.grade}, "groups": {"add": ["회원"]}}
    if m.marketing_changed:
        body["consent"] = {"marketing": m.marketing, "at": m.marketing_changed_at.isoformat(), "method": "회원 설정"}
    requests.put(f"{URL}/api/v1/contacts/{m.email}", json=body, headers=H, timeout=30).raise_for_status()

# 탈퇴 때
requests.delete(f"{URL}/api/v1/contacts/{email}", headers=H, timeout=30)

11. 자주 묻는 것