openapi: 3.0.3 info: title: CARI PAY 청구서 발송 API version: 1.5.0 description: | 가맹점 인증으로 청구서를 생성하고 카리(CARI) 범용 알림톡을 발송하는 API. 결제 링크 생성용 API_SIGN과 다른 인증을 사용한다. 업체별 청구 기능 등록이 필요하다. 같은 주문에 requestPayment와 이 API를 함께 호출하면 별도 청구가 생성되므로 한 경로만 사용한다. 발송 접수 성공은 고객 도착 또는 결제 완료가 아니다. 과거 실패 건을 자동 재발송하지 않는다. 개발 서버도 모의 발송을 보장하지 않는다. 실제 고객 번호로 테스트하지 않는다. servers: - url: https://api.dev.caripay.co.kr description: 카리페이 청구 서버 — 현재 단일 환경. 실제 결제 링크 생성·알림톡/문자 발송·포인트 차감이 일어난다. security: - MerchantAccessToken: [] paths: /app/v1/auth/login: post: operationId: login security: [] summary: 가맹점 계정 로그인 → 접근 토큰(1시간)·리프레시 토큰(30일) requestBody: required: true content: application/json: schema: type: object required: [email, password, loginType] properties: email: { type: string } password: { type: string } loginType: { type: string, enum: [EMAIL] } responses: '200': description: result_data.accessToken / refreshToken / accessTokenExpiresIn(초) content: application/json: schema: { $ref: '#/components/schemas/Result' } /app/v1/auth/refresh: post: operationId: refreshToken security: [] summary: 토큰 갱신. 접근 토큰 만료(result_code -2) 시 호출 requestBody: required: true content: application/json: schema: type: object required: [refreshToken] properties: refreshToken: { type: string } responses: '200': description: 새 accessToken / refreshToken content: application/json: schema: { $ref: '#/components/schemas/Result' } /app/v1/sales/bill: post: operationId: sendInvoice summary: 청구서 생성 및 발송 접수 description: | 발급처는 인증된 가맹점에서 결정한다. 원시 API의 studentName/studentPhone 필드는 수신자 이름과 전화번호이며 비교육 업종도 같은 필드로 사용한다. 생년월일/클래스는 필요하지 않다. 같은 requestId와 같은 payload의 재시도는 중복 생성하지 않는다. requestId는 반드시 주문별 저장한다. requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/InvoiceRequest' responses: '200': description: result_code=0일 때만 접수 성공. result_data는 null이며 발송/납부 완료 응답이 아니다. content: application/json: schema: $ref: '#/components/schemas/Result' '400': description: 입력 오류 또는 같은 requestId에 다른 payload '401': description: 가맹점 인증 필요 '403': description: 해당 가맹점 청구 권한 없음 get: operationId: listInvoices summary: 인증된 가맹점 청구 목록 parameters: - in: query name: month schema: { type: string, pattern: '^\d{4}-(0[1-9]|1[0-2])$' } - in: query name: page schema: { type: integer, minimum: 1, default: 1 } - in: query name: size schema: { type: integer, minimum: 1, maximum: 100, default: 10 } responses: '200': description: result_data에 청구 목록. result_code도 확인해야 한다. content: application/json: schema: { $ref: '#/components/schemas/Result' } /app/v1/sales/bill/resend: post: operationId: resendInvoice summary: 재발송 — 같은 거래번호·같은 링크·청구서에 저장된 발송 수단. 건당 포인트 차감 requestBody: required: true content: application/json: schema: type: object required: [billIds] properties: billIds: { type: array, items: { type: string }, description: 청구서 uniqueId 목록 } responses: '200': description: result_data 에 청구서 요약 목록 content: application/json: schema: { $ref: '#/components/schemas/Result' } /app/v1/sales/public/bill/{transSeqNo}: get: operationId: getPublicInvoice security: [] summary: 공개 청구서 조회(인증 없음). caripay.co.kr/pay/{거래번호} 가 사용. 연락처는 마스킹 parameters: - in: path name: transSeqNo required: true schema: { type: string } responses: '200': description: transSeqNo, status, paymentUrl, corpName, businessName, receiverName, receiverPhoneMasked, reason, description, amount, issuedAt, paidAt, canceledAt, items[] content: application/json: schema: { $ref: '#/components/schemas/Result' } '404': description: 청구서 없음 /app/v1/sales/bill/{id}: get: operationId: getInvoice summary: 인증된 가맹점의 청구 상세와 발송 이력 parameters: - in: path name: id required: true schema: { type: string } responses: '200': description: result_data의 payment와 sendHistories로 상태 확인. 접수와 납부 상태를 구분한다. content: application/json: schema: { $ref: '#/components/schemas/Result' } components: securitySchemes: MerchantAccessToken: type: apiKey in: header name: x-access-token description: 해당 가맹점 계정의 청구 API 접근 토큰. 결제 서명 API_KEY가 아니다. 서버에만 보관. schemas: InvoiceRequest: type: object required: [templateType, requestId, reason, amount, members] properties: templateType: { type: string, enum: [SAME], default: SAME } requestId: { type: string, pattern: '^[A-Za-z0-9_-]{8,64}$' } reason: { type: string, minLength: 1, maxLength: 60 } description: { type: string, maxLength: 200, nullable: true } amount: { type: integer, minimum: 100, maximum: 2147483647 } sendChannel: type: string enum: [ALIMTALK, SMS, ALIMTALK_THEN_SMS] default: ALIMTALK description: | 발송 수단. ALIMTALK=카카오 알림톡(기본), SMS=문자(LMS)만, ALIMTALK_THEN_SMS=알림톡 실패·수신 불가 시 문자 대체. 재발송과 예약 발송도 청구서에 저장된 수단을 따른다. 실제 나간 수단은 발송 이력의 sendChannel 로 확인한다. webhookUrl: type: string format: uri maxLength: 500 nullable: true description: | 결제 완료·취소 시 POST 받을 파트너 웹훅 주소(https 만). 본문: event(bill.paid|bill.canceled), billId, requestId, transSeqNo, status, amount, reason, paidAt, canceledAt, occurredAt. 헤더 X-CariPay-Event / X-CariPay-Delivery. 2xx 가 아니면 1분·5분·30분·2시간 뒤 재시도 후 포기(총 5회). webhookSecret 이 있으면 X-CariPay-Signature 가 붙는다. 어느 쪽이든 수신 후 getInvoice 로 상태를 확인한다. webhookSecret: type: string pattern: '^[\x21-\x7E]{16,128}$' nullable: true description: | 웹훅 서명 비밀(webhookUrl 과 함께). 있으면 전송마다 헤더 X-CariPay-Signature: t=,v1= 를 붙인다. v1 = HMAC-SHA256(secret, ".<본문 원문>") 소문자 hex. 수신 측은 같은 식으로 계산해 상수 시간 비교하고 |now - t| ≤ 300초를 확인한다. billTemplateId: { type: string, nullable: true, description: '단순 청구는 null. 알리고 템플릿 코드가 아님.' } items: type: array nullable: true maxItems: 30 description: | 청구 항목(상품명·금액). 있으면 알림톡 안내 메시지 자리와 문자 본문에 "[청구 항목]" 목록(5줄까지, 나머지는 "외 N건")으로, 결제 페이지(caripay.co.kr/pay/{거래번호})에는 전부 나온다. price 합계가 amount 와 같아야 한다. 단순 금액 청구는 null. items: type: object required: [name, price] properties: name: { type: string, minLength: 1, maxLength: 20 } price: { type: integer, minimum: 100, description: '항목 금액(원). 수량이 있으면 합친 금액' } discountAmount: { type: integer, nullable: true } discountUnit: { type: string, enum: [AMOUNT, RATE], nullable: true } type: { type: string, nullable: true, description: '과세 유형. null 이면 가맹점 기본값' } relatedSubject: { type: string, nullable: true } etc: { type: string, nullable: true } members: type: array minItems: 1 items: type: object required: [studentName, studentPhone] properties: studentName: { type: string, minLength: 1, maxLength: 30, description: 수신자 이름 } studentPhone: { type: string, pattern: '^[0-9]{10,11}$', description: 수신자 전화번호 } guardianPhone: { type: string, nullable: true, description: '일반 고객 청구에서는 null' } studentBirthDate: { type: string, nullable: true, description: '일반 고객 청구에서는 null' } classroomId: { type: integer, nullable: true, description: '일반 고객 청구에서는 null' } Result: type: object properties: result_code: oneOf: [{ type: integer }, { type: string }] description: 0만 성공. HTTP 200만으로 판단하지 않는다. result_msg: { type: string } result_data: { type: object, nullable: true, additionalProperties: true }