42 KiB
SAM E-Sign API 명세서
버전: 1.0.0 최종 수정: 2026-02-12 작성자: IT 혁신팀
목차
1. 개요
1.1 기본 정보
| 항목 | 값 |
|---|---|
| Base URL | https://api.sam.kr/api/v1/esign |
| 프로토콜 | HTTPS |
| 데이터 형식 | JSON (multipart/form-data는 파일 업로드 시) |
| 문자 인코딩 | UTF-8 |
| 인증 방식 | API Key + Bearer Token / Access Token |
1.2 API 그룹
| 그룹 | 경로 접두사 | 인증 | 엔드포인트 수 | 대상 |
|---|---|---|---|---|
| 계약 관리 | /api/v1/esign/contracts |
API Key + Bearer Token | 10개 | MNG 관리자 |
| 서명 프로세스 | /api/v1/esign/sign/{token} |
API Key + Access Token | 6개 | 외부 서명자 |
1.3 엔드포인트 요약
계약 관리 API (인증 필요)
| # | Method | Path | 설명 |
|---|---|---|---|
| 1 | GET | /contracts |
계약 목록 조회 |
| 2 | GET | /contracts/stats |
상태별 통계 조회 |
| 3 | GET | /contracts/{id} |
계약 상세 조회 |
| 4 | POST | /contracts |
계약 생성 |
| 5 | POST | /contracts/{id}/fields |
서명 필드 설정 |
| 6 | POST | /contracts/{id}/send |
서명 요청 발송 |
| 7 | POST | /contracts/{id}/remind |
리마인더 발송 |
| 8 | POST | /contracts/{id}/cancel |
계약 취소 |
| 9 | GET | /contracts/{id}/download |
PDF 다운로드 |
| 10 | GET | /contracts/{id}/verify |
무결성 검증 |
서명 프로세스 API (토큰 기반)
| # | Method | Path | 설명 |
|---|---|---|---|
| 1 | GET | /sign/{token} |
계약 정보 조회 |
| 2 | POST | /sign/{token}/otp/send |
OTP 발송 |
| 3 | POST | /sign/{token}/otp/verify |
OTP 인증 |
| 4 | GET | /sign/{token}/document |
계약 문서 조회 |
| 5 | POST | /sign/{token}/submit |
서명 제출 |
| 6 | POST | /sign/{token}/reject |
서명 거절 |
2. 인증
2.1 API Key 인증
모든 API 요청에 API Key가 필요합니다.
X-API-Key: {api-key}
- 미들웨어:
auth.apikey - API Key가 없거나 유효하지 않으면
401 Unauthorized반환
2.2 Bearer Token 인증 (계약 관리 API)
계약 관리 API는 추가로 Bearer Token이 필요합니다.
Authorization: Bearer {sanctum-token}
- 미들웨어:
auth:sanctum - Laravel Sanctum 기반
- 로그인 후 발급되는 Personal Access Token 사용
2.3 Access Token 인증 (서명 프로세스 API)
서명 프로세스 API는 URL 경로에 포함된 토큰으로 인증합니다.
/api/v1/esign/sign/{access_token}
- 128자 랜덤 문자열 (서명자별 고유)
- 계약 발송 시 자동 생성
token_expires_at이후 만료
3. 공통 규칙
3.1 응답 형식
성공 응답
{
"success": true,
"message": "요청 성공 메시지",
"data": { ... }
}
에러 응답
{
"success": false,
"message": "[에러코드] 에러 메시지",
"error": {
"code": 404,
"details": null
}
}
유효성 검증 에러 (422)
{
"message": "The title field is required.",
"errors": {
"title": ["The title field is required."],
"file": ["The file must be a file of type: pdf."]
}
}
3.2 페이지네이션
목록 조회 API는 Laravel 표준 페이지네이션 형식을 사용합니다.
{
"success": true,
"data": {
"current_page": 1,
"data": [ ... ],
"first_page_url": "...?page=1",
"from": 1,
"last_page": 5,
"last_page_url": "...?page=5",
"links": [ ... ],
"next_page_url": "...?page=2",
"path": "...",
"per_page": 20,
"prev_page_url": null,
"to": 20,
"total": 100
}
}
3.3 Multi-Tenant
- 모든 데이터는
tenant_id로 격리됩니다 BelongsToTenant글로벌 스코프가 자동으로 필터링합니다- 다른 테넌트의 데이터에는 접근할 수 없습니다
3.4 Soft Delete
- 계약(
esign_contracts)은 Soft Delete를 지원합니다 - 삭제된 데이터는
deleted_at필드에 삭제 시각이 기록됩니다 - 감사 로그(
esign_audit_logs)는 삭제 불가능합니다
4. 계약 관리 API
미들웨어:
auth.apikey,auth:sanctum경로 접두사:/api/v1/esign/contracts
4.1 계약 목록 조회
GET /api/v1/esign/contracts
계약 목록을 페이지네이션으로 조회합니다. 필터 및 검색을 지원합니다.
Query Parameters
| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---|---|---|---|---|
status |
string | - | - | 계약 상태 필터 |
search |
string | - | - | 제목 또는 계약 코드 검색 |
date_from |
date | - | - | 생성일 시작 (YYYY-MM-DD) |
date_to |
date | - | - | 생성일 종료 (YYYY-MM-DD) |
per_page |
integer | - | 20 | 페이지당 항목 수 |
Request
GET /api/v1/esign/contracts?status=pending&search=공급&per_page=10 HTTP/1.1
X-API-Key: {api-key}
Authorization: Bearer {token}
Response (200 OK)
{
"success": true,
"message": "데이터 조회 완료",
"data": {
"current_page": 1,
"data": [
{
"id": 1,
"tenant_id": 1,
"contract_code": "ES-20260212-A1B2C3",
"title": "제품 공급 계약서",
"description": "2026년 제품 공급 계약",
"sign_order_type": "counterpart_first",
"original_file_name": "supply-contract.pdf",
"original_file_size": 102400,
"status": "pending",
"expires_at": "2026-02-26T00:00:00.000000Z",
"completed_at": null,
"created_by": 1,
"created_at": "2026-02-12T10:00:00.000000Z",
"updated_at": "2026-02-12T10:30:00.000000Z",
"signers": [
{
"id": 1,
"name": "김철수",
"email": "kim@example.com",
"role": "counterpart",
"status": "notified",
"signed_at": null
},
{
"id": 2,
"name": "이영희",
"email": "lee@company.com",
"role": "creator",
"status": "waiting",
"signed_at": null
}
],
"creator": {
"id": 1,
"name": "관리자"
}
}
],
"per_page": 10,
"total": 25
}
}
4.2 상태별 통계 조회
GET /api/v1/esign/contracts/stats
계약 상태별 건수를 집계합니다.
Request
GET /api/v1/esign/contracts/stats HTTP/1.1
X-API-Key: {api-key}
Authorization: Bearer {token}
Response (200 OK)
{
"success": true,
"message": "데이터 조회 완료",
"data": {
"total": 150,
"draft": 10,
"pending": 25,
"partially_signed": 15,
"completed": 80,
"expired": 5,
"cancelled": 10,
"rejected": 5
}
}
4.3 계약 상세 조회
GET /api/v1/esign/contracts/{id}
계약의 상세 정보를 서명자, 서명 필드, 감사 로그와 함께 조회합니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
id |
integer | 계약 ID |
Request
GET /api/v1/esign/contracts/1 HTTP/1.1
X-API-Key: {api-key}
Authorization: Bearer {token}
Response (200 OK)
{
"success": true,
"message": "데이터 조회 완료",
"data": {
"id": 1,
"tenant_id": 1,
"contract_code": "ES-20260212-A1B2C3",
"title": "제품 공급 계약서",
"description": "2026년 제품 공급 계약",
"sign_order_type": "counterpart_first",
"original_file_path": "esign/1/originals/abc123.pdf",
"original_file_name": "supply-contract.pdf",
"original_file_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"original_file_size": 102400,
"signed_file_path": null,
"signed_file_hash": null,
"status": "pending",
"expires_at": "2026-02-26T00:00:00.000000Z",
"completed_at": null,
"created_by": 1,
"updated_by": 1,
"created_at": "2026-02-12T10:00:00.000000Z",
"updated_at": "2026-02-12T10:30:00.000000Z",
"signers": [
{
"id": 1,
"tenant_id": 1,
"contract_id": 1,
"role": "counterpart",
"sign_order": 1,
"name": "김철수",
"email": "kim@example.com",
"phone": "010-1234-5678",
"token_expires_at": "2026-02-26T00:00:00.000000Z",
"otp_attempts": 0,
"auth_verified_at": null,
"signature_image_path": null,
"signed_at": null,
"consent_agreed_at": null,
"sign_ip_address": null,
"sign_user_agent": null,
"status": "notified",
"rejected_reason": null
},
{
"id": 2,
"tenant_id": 1,
"contract_id": 1,
"role": "creator",
"sign_order": 2,
"name": "이영희",
"email": "lee@company.com",
"phone": "010-9876-5432",
"status": "waiting"
}
],
"sign_fields": [
{
"id": 1,
"contract_id": 1,
"signer_id": 1,
"page_number": 1,
"position_x": "10.50",
"position_y": "80.25",
"width": "20.00",
"height": "10.00",
"field_type": "signature",
"field_label": "상대방 서명",
"field_value": null,
"is_required": true,
"sort_order": 0
}
],
"audit_logs": [
{
"id": 2,
"contract_id": 1,
"signer_id": null,
"action": "sent",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"metadata": null,
"created_at": "2026-02-12T10:30:00.000000Z"
},
{
"id": 1,
"contract_id": 1,
"signer_id": null,
"action": "created",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"metadata": null,
"created_at": "2026-02-12T10:00:00.000000Z"
}
],
"creator": {
"id": 1,
"name": "관리자"
}
}
}
Error (404)
{
"success": false,
"message": "[404] 데이터를 찾을 수 없습니다",
"error": { "code": 404, "details": null }
}
4.4 계약 생성
POST /api/v1/esign/contracts
새 전자계약을 생성합니다. PDF 파일 업로드와 함께 작성자/상대방 정보를 등록합니다.
Content-Type: multipart/form-data
Request Body
| 필드 | 타입 | 필수 | 검증 규칙 | 설명 |
|---|---|---|---|---|
title |
string | O | max:200 | 계약 제목 |
description |
string | - | max:2000 | 계약 설명 |
sign_order_type |
string | - | in:counterpart_first,creator_first | 서명 순서 (기본: counterpart_first) |
file |
file | O | mimes:pdf, max:20480 | PDF 파일 (최대 20MB) |
expires_at |
date | - | after:now | 만료일 (기본: 14일 후) |
creator_name |
string | O | max:100 | 작성자 이름 |
creator_email |
string | O | email, max:255 | 작성자 이메일 |
creator_phone |
string | - | max:20 | 작성자 전화번호 |
counterpart_name |
string | O | max:100 | 상대방 이름 |
counterpart_email |
string | O | email, max:255 | 상대방 이메일 |
counterpart_phone |
string | - | max:20 | 상대방 전화번호 |
Request (cURL)
curl -X POST https://api.sam.kr/api/v1/esign/contracts \
-H "X-API-Key: {api-key}" \
-H "Authorization: Bearer {token}" \
-F "title=제품 공급 계약서" \
-F "description=2026년 제품 공급 계약" \
-F "sign_order_type=counterpart_first" \
-F "file=@/path/to/contract.pdf" \
-F "expires_at=2026-02-26" \
-F "creator_name=이영희" \
-F "creator_email=lee@company.com" \
-F "creator_phone=010-9876-5432" \
-F "counterpart_name=김철수" \
-F "counterpart_email=kim@example.com" \
-F "counterpart_phone=010-1234-5678"
Response (200 OK)
{
"success": true,
"message": "계약이 생성되었습니다",
"data": {
"id": 1,
"contract_code": "ES-20260212-A1B2C3",
"title": "제품 공급 계약서",
"description": "2026년 제품 공급 계약",
"sign_order_type": "counterpart_first",
"original_file_name": "contract.pdf",
"original_file_hash": "e3b0c44298fc1c14...",
"original_file_size": 102400,
"status": "draft",
"expires_at": "2026-02-26T00:00:00.000000Z",
"created_by": 1,
"signers": [
{
"id": 1,
"role": "counterpart",
"sign_order": 1,
"name": "김철수",
"email": "kim@example.com",
"status": "waiting"
},
{
"id": 2,
"role": "creator",
"sign_order": 2,
"name": "이영희",
"email": "lee@company.com",
"status": "waiting"
}
]
}
}
Error (422 Validation)
{
"message": "The title field is required. (and 2 more errors)",
"errors": {
"title": ["The title field is required."],
"file": ["The file must be a file of type: pdf."],
"counterpart_email": ["The counterpart email must be a valid email address."]
}
}
처리 순서:
- PDF 파일을
storage/app/esign/{tenant_id}/originals/에 저장 - SHA-256 해시 생성 및 저장
- 계약 코드 자동 생성 (
ES-YYYYMMDD-XXXXXX) - 서명자 2명 생성 (creator, counterpart)
- 각 서명자에게 128자 access_token 발급
- 감사 로그 기록 (
created)
4.5 서명 필드 설정
POST /api/v1/esign/contracts/{id}/fields
PDF 문서 위에 서명 위치를 설정합니다. 기존 필드는 삭제 후 재생성됩니다.
Content-Type: application/json
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
id |
integer | 계약 ID |
Request Body
| 필드 | 타입 | 필수 | 검증 규칙 | 설명 |
|---|---|---|---|---|
fields |
array | O | min:1 | 서명 필드 배열 |
fields.*.signer_id |
integer | O | exists:esign_signers,id | 서명자 ID |
fields.*.page_number |
integer | O | min:1 | 페이지 번호 |
fields.*.position_x |
numeric | O | min:0, max:100 | X 좌표 (%) |
fields.*.position_y |
numeric | O | min:0, max:100 | Y 좌표 (%) |
fields.*.width |
numeric | O | min:1, max:100 | 너비 (%) |
fields.*.height |
numeric | O | min:1, max:100 | 높이 (%) |
fields.*.field_type |
string | - | in:signature,stamp,text,date,checkbox | 유형 (기본: signature) |
fields.*.field_label |
string | - | max:100 | 라벨 |
fields.*.is_required |
boolean | - | - | 필수 여부 (기본: true) |
fields.*.sort_order |
integer | - | min:0 | 정렬 순서 (기본: 0) |
Request
POST /api/v1/esign/contracts/1/fields
{
"fields": [
{
"signer_id": 1,
"page_number": 3,
"position_x": 10.5,
"position_y": 80.25,
"width": 20.0,
"height": 10.0,
"field_type": "signature",
"field_label": "상대방 서명",
"is_required": true,
"sort_order": 0
},
{
"signer_id": 2,
"page_number": 3,
"position_x": 60.0,
"position_y": 80.25,
"width": 20.0,
"height": 10.0,
"field_type": "signature",
"field_label": "작성자 서명",
"is_required": true,
"sort_order": 1
}
]
}
Response (200 OK)
{
"success": true,
"message": "서명 필드가 설정되었습니다",
"data": {
"id": 1,
"contract_code": "ES-20260212-A1B2C3",
"status": "draft",
"sign_fields": [
{
"id": 1,
"signer_id": 1,
"page_number": 3,
"position_x": "10.50",
"position_y": "80.25",
"width": "20.00",
"height": "10.00",
"field_type": "signature",
"field_label": "상대방 서명",
"is_required": true,
"sort_order": 0
},
{
"id": 2,
"signer_id": 2,
"page_number": 3,
"position_x": "60.00",
"position_y": "80.25",
"width": "20.00",
"height": "10.00",
"field_type": "signature",
"field_label": "작성자 서명",
"is_required": true,
"sort_order": 1
}
]
}
}
에러 조건:
404: 계약을 찾을 수 없음400:draft상태가 아닌 계약 → 필드 설정 불가
좌표 체계:
- 모든 좌표는 백분율(%) 기준 (0~100)
position_x: 페이지 왼쪽 기준 가로 위치position_y: 페이지 상단 기준 세로 위치width,height: 페이지 대비 크기
4.6 서명 요청 발송
POST /api/v1/esign/contracts/{id}/send
계약을 발송하고 첫 번째 서명자에게 이메일을 보냅니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
id |
integer | 계약 ID |
Request
POST /api/v1/esign/contracts/1/send HTTP/1.1
X-API-Key: {api-key}
Authorization: Bearer {token}
Response (200 OK)
{
"success": true,
"message": "계약이 발송되었습니다",
"data": {
"id": 1,
"contract_code": "ES-20260212-A1B2C3",
"status": "pending",
"signers": [
{
"id": 1,
"status": "notified",
"email": "kim@example.com"
}
]
}
}
전제 조건:
- 계약 상태가
draft여야 함 - 서명 필드가 최소 1개 이상 설정되어 있어야 함
처리 순서:
- 계약 상태:
draft→pending - 첫 번째 서명자(sign_order=1) 상태:
waiting→notified - 첫 번째 서명자에게
EsignRequestMail발송 - 감사 로그 기록 (
sent)
4.7 리마인더 발송
POST /api/v1/esign/contracts/{id}/remind
아직 서명하지 않은 서명자에게 알림 이메일을 재발송합니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
id |
integer | 계약 ID |
Response (200 OK)
{
"success": true,
"message": "서명 알림이 재발송되었습니다",
"data": {
"id": 1,
"contract_code": "ES-20260212-A1B2C3",
"status": "pending"
}
}
전제 조건:
- 계약 상태가
pending또는partially_signed여야 함
4.8 계약 취소
POST /api/v1/esign/contracts/{id}/cancel
진행 중인 계약을 취소합니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
id |
integer | 계약 ID |
Response (200 OK)
{
"success": true,
"message": "계약이 취소되었습니다",
"data": {
"id": 1,
"contract_code": "ES-20260212-A1B2C3",
"status": "cancelled"
}
}
에러 조건:
400: 이미completed또는cancelled상태인 계약
4.9 계약 파일 다운로드
GET /api/v1/esign/contracts/{id}/download
계약 PDF 파일을 다운로드합니다. 서명 완료 시 서명된 파일, 미완료 시 원본을 반환합니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
id |
integer | 계약 ID |
Response (200 OK)
Content-Type: application/pdf
Content-Disposition: attachment; filename="supply-contract.pdf"
<binary PDF data>
에러 조건:
404: 계약 또는 파일을 찾을 수 없음
4.10 무결성 검증
GET /api/v1/esign/contracts/{id}/verify
저장된 해시와 현재 파일의 해시를 비교하여 문서 변조 여부를 확인합니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
id |
integer | 계약 ID |
Response (200 OK)
{
"success": true,
"message": "무결성 검증 완료",
"data": {
"verified": true,
"original_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
}
변조가 감지된 경우:
{
"success": true,
"message": "무결성 검증 완료",
"data": {
"verified": false,
"original_hash": "e3b0c44298fc1c14..."
}
}
5. 서명 프로세스 API
미들웨어:
auth.apikey경로 접두사:/api/v1/esign/sign/{token}인증: Access Token (URL 경로 포함)
5.1 계약 정보 조회
GET /api/v1/esign/sign/{token}
토큰으로 계약 및 서명자 정보를 조회합니다. 서명자가 이메일 링크를 클릭하면 호출됩니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
token |
string | 서명자 Access Token (128자) |
Response (200 OK)
{
"success": true,
"message": "데이터 조회 완료",
"data": {
"contract": {
"id": 1,
"contract_code": "ES-20260212-A1B2C3",
"title": "제품 공급 계약서",
"description": "2026년 제품 공급 계약",
"status": "pending",
"expires_at": "2026-02-26T00:00:00.000000Z",
"signers": [
{
"id": 1,
"name": "김철수",
"role": "counterpart",
"status": "notified",
"signed_at": null
}
]
},
"signer": {
"id": 1,
"role": "counterpart",
"sign_order": 1,
"name": "김철수",
"email": "kim@example.com",
"phone": "010-1234-5678",
"status": "notified",
"auth_verified_at": null,
"signed_at": null
}
}
}
에러 조건:
404: 유효하지 않은 토큰400: 토큰 만료
5.2 OTP 발송
POST /api/v1/esign/sign/{token}/otp/send
서명자 이메일로 6자리 OTP 코드를 발송합니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
token |
string | 서명자 Access Token |
Response (200 OK)
{
"success": true,
"message": "OTP가 발송되었습니다",
"data": {
"message": "OTP가 발송되었습니다"
}
}
참고:
- OTP: 6자리 숫자 (100000~999999)
- 유효 시간: 5분
- 최대 시도 횟수: 5회
- 재발송 시 기존 OTP는 무효화되고 새 OTP 발급
- 개발 환경: OTP가 로그에 출력됨
5.3 OTP 인증
POST /api/v1/esign/sign/{token}/otp/verify
발송된 OTP를 검증하고 세션 토큰을 발급합니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
token |
string | 서명자 Access Token |
Request Body
| 필드 | 타입 | 필수 | 검증 규칙 | 설명 |
|---|---|---|---|---|
otp_code |
string | O | size:6 | OTP 코드 |
Request
{
"otp_code": "482637"
}
Response (200 OK)
{
"success": true,
"message": "OTP 인증이 완료되었습니다",
"data": {
"sign_session_token": "a3f8b2c1d4e5f6...",
"signer": {
"id": 1,
"status": "authenticated",
"auth_verified_at": "2026-02-12T11:00:00.000000Z"
}
}
}
에러 조건:
| 상태 코드 | 조건 | 메시지 |
|---|---|---|
| 400 | OTP 미발송 | OTP가 발송되지 않았습니다 |
| 400 | OTP 만료 (5분 초과) | OTP가 만료되었습니다 |
| 400 | OTP 불일치 | OTP가 일치하지 않습니다 |
| 400 | 시도 횟수 초과 (5회) | OTP 최대 시도 횟수를 초과했습니다 |
5.4 계약 문서 조회
GET /api/v1/esign/sign/{token}/document
계약 PDF를 브라우저에서 표시할 수 있도록 스트리밍합니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
token |
string | 서명자 Access Token |
Response (200 OK)
Content-Type: application/pdf
<binary PDF data>
참고: Content-Disposition 헤더 없음 (브라우저 인라인 표시)
5.5 서명 제출
POST /api/v1/esign/sign/{token}/submit
서명 이미지를 제출하고 서명을 완료합니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
token |
string | 서명자 Access Token |
Request Body
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
signature_image |
string | O | Base64 인코딩된 서명 이미지 (PNG) |
Request
{
"signature_image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
}
Response (200 OK)
{
"success": true,
"message": "서명이 완료되었습니다",
"data": {
"id": 1,
"contract_id": 1,
"name": "김철수",
"status": "signed",
"signed_at": "2026-02-12T11:05:00.000000Z",
"consent_agreed_at": "2026-02-12T11:05:00.000000Z",
"sign_ip_address": "203.246.xx.xx",
"signature_image_path": "esign/1/signatures/1_1.png"
}
}
전제 조건:
- OTP 인증 완료 필수 (
auth_verified_at존재) - 아직 서명하지 않은 상태여야 함
- 계약이 서명 가능 상태여야 함 (pending 또는 partially_signed)
처리 순서:
- Base64 이미지 → PNG 파일로 저장 (
esign/{tenant_id}/signatures/{contract_id}_{signer_id}.png) - 서명자 상태:
authenticated→signed signed_at,consent_agreed_at, IP, User Agent 기록- 감사 로그 기록 (
signed) - 모든 서명자 완료 여부 확인 → 완료 시 계약 상태:
completed - 순차 서명인 경우 다음 서명자에게 이메일 발송
5.6 서명 거절
POST /api/v1/esign/sign/{token}/reject
서명을 거절합니다. 계약 전체가 거절 상태로 변경됩니다.
Path Parameters
| 파라미터 | 타입 | 설명 |
|---|---|---|
token |
string | 서명자 Access Token |
Request Body
| 필드 | 타입 | 필수 | 검증 규칙 | 설명 |
|---|---|---|---|---|
reason |
string | O | max:1000 | 거절 사유 |
Request
{
"reason": "계약 조건에 대해 추가 협의가 필요합니다."
}
Response (200 OK)
{
"success": true,
"message": "서명이 거절되었습니다",
"data": {
"id": 1,
"contract_id": 1,
"name": "김철수",
"status": "rejected",
"rejected_reason": "계약 조건에 대해 추가 협의가 필요합니다."
}
}
처리 순서:
- 서명자 상태: →
rejected rejected_reason기록- 계약 상태: →
rejected - 감사 로그 기록 (
rejected)
6. 데이터 모델
6.1 esign_contracts (계약)
| 컬럼 | 타입 | NULL | 기본값 | 설명 |
|---|---|---|---|---|
| id | bigint unsigned | NO | AUTO | PK |
| tenant_id | bigint unsigned | NO | - | 테넌트 ID (FK) |
| contract_code | varchar(50) | NO | - | 계약 코드 (UNIQUE) |
| title | varchar(200) | NO | - | 계약 제목 |
| description | text | YES | NULL | 계약 설명 |
| sign_order_type | enum | NO | counterpart_first | 서명 순서 |
| original_file_path | varchar(500) | NO | - | 원본 PDF 경로 |
| original_file_name | varchar(255) | NO | - | 원본 파일명 |
| original_file_hash | varchar(64) | NO | - | SHA-256 해시 |
| original_file_size | int unsigned | NO | - | 파일 크기 (bytes) |
| signed_file_path | varchar(500) | YES | NULL | 서명 PDF 경로 |
| signed_file_hash | varchar(64) | YES | NULL | 서명 파일 해시 |
| status | enum | NO | draft | 계약 상태 |
| expires_at | datetime | YES | NULL | 만료일시 |
| completed_at | datetime | YES | NULL | 완료일시 |
| created_by | bigint unsigned | NO | - | 생성자 ID |
| updated_by | bigint unsigned | NO | - | 수정자 ID |
| deleted_by | bigint unsigned | YES | NULL | 삭제자 ID |
| created_at | timestamp | NO | CURRENT | 생성일시 |
| updated_at | timestamp | NO | CURRENT | 수정일시 |
| deleted_at | timestamp | YES | NULL | 삭제일시 |
인덱스: contract_code (UNIQUE), tenant_id + status (INDEX)
6.2 esign_signers (서명자)
| 컬럼 | 타입 | NULL | 기본값 | 설명 |
|---|---|---|---|---|
| id | bigint unsigned | NO | AUTO | PK |
| tenant_id | bigint unsigned | NO | - | 테넌트 ID |
| contract_id | bigint unsigned | NO | - | 계약 ID (FK) |
| role | enum | NO | - | 역할 (creator/counterpart) |
| sign_order | tinyint unsigned | NO | - | 서명 순서 |
| name | varchar(100) | NO | - | 이름 |
| varchar(255) | NO | - | 이메일 | |
| phone | varchar(20) | YES | NULL | 전화번호 |
| access_token | varchar(128) | NO | - | 액세스 토큰 (UNIQUE) |
| token_expires_at | datetime | YES | NULL | 토큰 만료일시 |
| otp_code | varchar(6) | YES | NULL | OTP 코드 |
| otp_expires_at | datetime | YES | NULL | OTP 만료일시 |
| otp_attempts | tinyint unsigned | NO | 0 | OTP 시도 횟수 |
| auth_verified_at | datetime | YES | NULL | 인증 완료 일시 |
| signature_image_path | varchar(500) | YES | NULL | 서명 이미지 경로 |
| signed_at | datetime | YES | NULL | 서명 일시 |
| consent_agreed_at | datetime | YES | NULL | 동의 일시 |
| sign_ip_address | varchar(45) | YES | NULL | 서명 IP (IPv6 지원) |
| sign_user_agent | varchar(500) | YES | NULL | 서명 User Agent |
| status | enum | NO | waiting | 서명자 상태 |
| rejected_reason | text | YES | NULL | 거절 사유 |
| created_at | timestamp | NO | CURRENT | 생성일시 |
| updated_at | timestamp | NO | CURRENT | 수정일시 |
인덱스: access_token (UNIQUE), contract_id (INDEX)
6.3 esign_sign_fields (서명 필드)
| 컬럼 | 타입 | NULL | 기본값 | 설명 |
|---|---|---|---|---|
| id | bigint unsigned | NO | AUTO | PK |
| tenant_id | bigint unsigned | NO | - | 테넌트 ID |
| contract_id | bigint unsigned | NO | - | 계약 ID (FK) |
| signer_id | bigint unsigned | NO | - | 서명자 ID (FK) |
| page_number | int unsigned | NO | - | 페이지 번호 |
| position_x | decimal(5,2) | NO | - | X 좌표 (%) |
| position_y | decimal(5,2) | NO | - | Y 좌표 (%) |
| width | decimal(5,2) | NO | - | 너비 (%) |
| height | decimal(5,2) | NO | - | 높이 (%) |
| field_type | enum | NO | signature | 필드 유형 |
| field_label | varchar(100) | YES | NULL | 필드 라벨 |
| field_value | text | YES | NULL | 필드 값 |
| is_required | boolean | NO | true | 필수 여부 |
| sort_order | int | NO | 0 | 정렬 순서 |
| created_at | timestamp | NO | CURRENT | 생성일시 |
| updated_at | timestamp | NO | CURRENT | 수정일시 |
6.4 esign_audit_logs (감사 로그)
| 컬럼 | 타입 | NULL | 기본값 | 설명 |
|---|---|---|---|---|
| id | bigint unsigned | NO | AUTO | PK |
| tenant_id | bigint unsigned | NO | - | 테넌트 ID |
| contract_id | bigint unsigned | NO | - | 계약 ID (FK) |
| signer_id | bigint unsigned | YES | NULL | 서명자 ID (FK) |
| action | varchar(50) | NO | - | 액션 유형 |
| ip_address | varchar(45) | YES | NULL | IP 주소 |
| user_agent | varchar(500) | YES | NULL | User Agent |
| metadata | json | YES | NULL | 추가 메타데이터 |
| created_at | timestamp | NO | CURRENT | 생성일시 |
특징: updated_at 없음, Soft Delete 없음 (삭제 불가)
7. 에러 코드
7.1 HTTP 상태 코드
| 코드 | 설명 | 사용 상황 |
|---|---|---|
| 200 | 성공 | 모든 성공 응답 |
| 400 | Bad Request | 비즈니스 로직 에러 |
| 401 | Unauthorized | 인증 실패 |
| 404 | Not Found | 리소스 없음 |
| 422 | Unprocessable Entity | 유효성 검증 실패 |
| 500 | Internal Server Error | 서버 오류 |
7.2 E-Sign 에러 메시지
| 키 (i18n) | 메시지 | 상태 코드 |
|---|---|---|
error.esign.invalid_token |
유효하지 않은 토큰입니다 | 404 |
error.esign.token_expired |
토큰이 만료되었습니다 | 400 |
error.esign.already_completed |
이미 완료된 계약입니다 | 400 |
error.esign.already_cancelled |
이미 취소된 계약입니다 | 400 |
error.esign.invalid_status_for_send |
발송 가능한 상태가 아닙니다 | 400 |
error.esign.no_sign_fields |
서명 필드가 설정되지 않았습니다 | 400 |
error.esign.cannot_remind |
알림을 발송할 수 없습니다 | 400 |
error.esign.fields_only_in_draft |
초안 상태에서만 필드를 설정할 수 있습니다 | 400 |
error.esign.contract_not_signable |
서명 가능한 계약이 아닙니다 | 400 |
error.esign.otp_max_attempts |
OTP 최대 시도 횟수를 초과했습니다 | 400 |
error.esign.otp_not_sent |
OTP가 발송되지 않았습니다 | 400 |
error.esign.otp_expired |
OTP가 만료되었습니다 | 400 |
error.esign.otp_invalid |
OTP가 일치하지 않습니다 | 400 |
error.esign.not_verified |
OTP 인증이 필요합니다 | 400 |
error.esign.already_signed |
이미 서명되었습니다 | 400 |
error.esign.file_not_found |
파일을 찾을 수 없습니다 | 404 |
8. 워크플로우
8.1 기본 서명 플로우
[관리자] [시스템] [서명자]
│ │ │
│── POST /contracts ──────────────>│ │
│<── 201 계약 생성 (draft) ────────│ │
│ │ │
│── POST /contracts/{id}/fields ──>│ │
│<── 200 필드 설정 완료 ───────────│ │
│ │ │
│── POST /contracts/{id}/send ────>│ │
│<── 200 발송 완료 (pending) ──────│── Email 발송 ──────────────────>│
│ │ │
│ │<── GET /sign/{token} ───────────│
│ │── 200 계약 정보 ───────────────>│
│ │ │
│ │<── POST /sign/{token}/otp/send ─│
│ │── 200 OTP 발송 ────────────────>│
│ │ │
│ │<── POST /sign/{token}/otp/verify│
│ │── 200 인증 완료 + 세션토큰 ────>│
│ │ │
│ │<── GET /sign/{token}/document ──│
│ │── 200 PDF 스트리밍 ────────────>│
│ │ │
│ │<── POST /sign/{token}/submit ───│
│ │── 200 서명 완료 ───────────────>│
│ │ │
│ │ (다음 서명자에게 Email) │
│ │ │
│ │ (모든 서명 완료 → completed) │
8.2 상태 전이 다이어그램
계약 상태
draft ──(send)──> pending ──(1명 서명)──> partially_signed ──(전원 서명)──> completed
│ │ │
│ │──(거절)──> rejected │──(거절)──> rejected
│ │ │
│──(취소)──> cancelled ──(취소)──> cancelled ──(취소)──> cancelled
│
│──(만료)──> expired
서명자 상태
waiting ──(알림)──> notified ──(OTP인증)──> authenticated ──(서명)──> signed
│ │
│──(거절)──> rejected │──(거절)──> rejected
8.3 순차 서명 (counterpart_first)
1. 계약 발송 → 상대방(sign_order=1)에게 이메일
2. 상대방 서명 완료 → 계약 상태: partially_signed
3. 작성자(sign_order=2)에게 자동 이메일 발송
4. 작성자 서명 완료 → 계약 상태: completed
8.4 순차 서명 (creator_first)
1. 계약 발송 → 작성자(sign_order=1)에게 이메일
2. 작성자 서명 완료 → 계약 상태: partially_signed
3. 상대방(sign_order=2)에게 자동 이메일 발송
4. 상대방 서명 완료 → 계약 상태: completed
9. 이메일 알림
9.1 EsignRequestMail
서명 요청 이메일을 발송합니다.
| 항목 | 값 |
|---|---|
| 발송 주체 | shine1324@gmail.com (Gmail SMTP) |
| 수신자 | 서명자 이메일 |
| 제목 | [SAM E-Sign] 전자계약 서명을 요청합니다 |
| 발송 방식 | Mail::queue() (비동기) |
발송 시점:
- 계약 발송 시: 첫 번째 서명자에게
- 이전 서명자 서명 완료 시: 다음 서명자에게
- 리마인더 발송 시: 미서명 서명자에게
이메일 내용:
- 계약 제목
- 계약 설명
- 만료일
- 서명 링크:
{MNG_URL}/esign/sign/{access_token}
10. 보안
10.1 인증 계층
| 계층 | 방식 | 대상 |
|---|---|---|
| 1단계 | API Key | 모든 API |
| 2단계 | Bearer Token (Sanctum) | 계약 관리 API |
| 2단계 | Access Token (128자) | 서명 프로세스 API |
| 3단계 | OTP (6자리, 5분) | 서명 제출 전 |
10.2 데이터 보호
| 항목 | 방법 |
|---|---|
| 파일 무결성 | SHA-256 해시 생성 및 검증 |
| 데이터 격리 | Multi-Tenant (tenant_id 글로벌 스코프) |
| 감사 추적 | 모든 액션 로그 (IP, User Agent, 시각) |
| 토큰 보안 | 128자 랜덤 + 만료일 |
| OTP 보안 | 5분 만료 + 최대 5회 시도 |
| 전송 보안 | HTTPS (TLS) |
10.3 파일 저장 경로
storage/app/esign/{tenant_id}/
├── originals/ # 원본 PDF
│ └── {uuid}.pdf
├── signatures/ # 서명 이미지
│ └── {contract_id}_{signer_id}.png
└── signed/ # 서명 완료 PDF
└── {uuid}_signed.pdf
11. 부록: Enum 값 정리
계약 상태 (esign_contracts.status)
| 값 | 한글 | 설명 |
|---|---|---|
draft |
초안 | 생성됨, 아직 발송 전 |
pending |
대기 | 서명 요청 발송됨 |
partially_signed |
일부 서명 | 1명 이상 서명 완료 |
completed |
완료 | 모든 서명 완료 |
expired |
만료 | 기한 초과 |
cancelled |
취소 | 관리자가 취소 |
rejected |
거절 | 서명자가 거절 |
서명 순서 (esign_contracts.sign_order_type)
| 값 | 한글 | 설명 |
|---|---|---|
counterpart_first |
상대방 먼저 | 상대방 → 작성자 순서 |
creator_first |
작성자 먼저 | 작성자 → 상대방 순서 |
서명자 역할 (esign_signers.role)
| 값 | 한글 | 설명 |
|---|---|---|
creator |
작성자 | 계약 작성자 (갑) |
counterpart |
상대방 | 서명 상대방 (을) |
서명자 상태 (esign_signers.status)
| 값 | 한글 | 설명 |
|---|---|---|
waiting |
대기 | 초기 상태 |
notified |
알림됨 | 이메일 발송됨 |
authenticated |
인증됨 | OTP 인증 완료 |
signed |
서명됨 | 서명 제출 완료 |
rejected |
거절됨 | 서명 거절 |
필드 유형 (esign_sign_fields.field_type)
| 값 | 한글 | 설명 |
|---|---|---|
signature |
서명 | 수기 서명 |
stamp |
도장 | 도장 이미지 |
text |
텍스트 | 텍스트 입력 |
date |
날짜 | 날짜 입력 |
checkbox |
체크박스 | 체크 표시 |
감사 로그 액션 (esign_audit_logs.action)
| 값 | 한글 | 설명 |
|---|---|---|
created |
생성 | 계약 생성 |
sent |
발송 | 서명 요청 발송 |
viewed |
조회 | 서명자가 계약 조회 |
otp_sent |
OTP 발송 | OTP 이메일 발송 |
authenticated |
인증 | OTP 인증 완료 |
signed |
서명 | 서명 제출 |
rejected |
거절 | 서명 거절 |
completed |
완료 | 모든 서명 완료 |
cancelled |
취소 | 관리자가 계약 취소 |
reminded |
리마인드 | 리마인더 발송 |
downloaded |
다운로드 | PDF 파일 다운로드 |
문서 끝 | SAM E-Sign API 명세서 v1.0.0