개발자 문서
개발자 문서

모든 계좌를 한 번에 호출하기

은행 연동에 계좌가 여러 개일 때, 계좌마다 따로 부르지 않고 한 번의 호출로 접수하는 방법입니다. 요청, 응답, 이어받기, 멈춘 호출, MCP 사용법을 안내합니다.

이 방식이 하는 일

은행 연동에 계좌가 여러 개일 때 계좌마다 작업을 따로 요청하지 않고, 한 번의 호출로 모두 접수하는 방법입니다.

같은 연동(account_link_id)으로 낸 작업은 한 번에 하나씩 실행됩니다.

결과를 한꺼번에 돌려주지는 않습니다. REST 응답에는 계좌마다 작업 번호가 있고, 그 번호로 기존 조회(GET /v1/jobs/{job_id}, GET /v1/jobs/{job_id}/result)를 불러 결과를 읽습니다.

  1. STEP 01
    계좌 확인

    은행의 계좌 목록 액션으로 연결된 계좌를 확인합니다.

  2. STEP 02
    한 번에 요청

    모든 계좌 호출을 한 번 보냅니다.

  3. STEP 03
    계좌마다 접수

    계좌마다 작업이 하나씩 만들어집니다.

  4. STEP 04
    결과 읽기

    작업 번호로 계좌마다 결과를 읽습니다.

계좌 목록을 확인하고, 모든 계좌를 한 번에 요청하면 계좌마다 작업이 하나씩 접수되고, 작업마다 결과를 읽습니다.
접수POST /v1/jobs/all-accounts (202)
결과 읽기GET /v1/jobs/{job_id}/result
인증Authorization: Basic
환경 헤더X-Env-Scope (필수)
멱등 키Idempotency-Key 헤더 (필수)
사용 토큰계좌마다 차감
MCP 입력all_accounts 또는 bank_account_credential_ids

계좌마다 사용 토큰이 하나씩 듭니다. 기업정보 브리프처럼 한 번에 여러 사용 토큰을 쓰는 업무는 그 값만큼 계좌마다 듭니다.

접수 전에 확인한 사용 토큰이 모자라면 일부만 접수하지 않고 하나도 접수하지 않으며, 요청 전체를 거절합니다.

부를 수 있는 상품

상품provideractionMCP 도구
IBK기업은행 빠른조회 계좌 잔액 요약 조회ibk_fast_accountibk_fast_account.accounts.quick_summary.getibk_fast_account__accounts__quick_summary__get
KB국민은행 빠른조회 거래내역 조회kb_fast_accountkb_fast_account.accounts.transaction_history.searchkb_fast_account__accounts__transaction_history__search
신한은행 간편조회 거래내역 조회shinhan_easyshinhan_easy.accounts.transaction_history.searchshinhan_easy__accounts__transaction_history__search
우리은행 빠른조회 거래내역 조회woori_bankwoori_bank.accounts.transaction_history.search-

MCP 도구 칸이 - 인 상품은 지금 공개 MCP 도구 목록에 그 도구가 없습니다.

계좌를 하나 고르는 칸은 위 상품 모두 bank_account_credential_id 입니다.

계좌를 고르는 세 가지 방법

방법RESTMCP
계좌 하나이 호출을 쓰지 않고 POST /v1/jobs 의 params 에 bank_account_credential_id 를 넣습니다.도구 입력 bank_account_credential_id 를 보냅니다. 전과 같습니다.
고른 계좌 여럿본문에 bank_account_credential_ids 를 넣습니다.도구 입력 bank_account_credential_ids 를 보냅니다.
모든 계좌bank_account_credential_ids 를 빼고 부릅니다. 이 경로가 「모든 계좌」 입니다.도구 입력 all_accounts: true 를 보냅니다.

연동 하나의 계좌만 부르려면 account_link_id 를 함께 보냅니다(REST 는 본문 칸, MCP 는 도구 입력).

접수 - POST /v1/jobs/all-accounts

새로 접수한 줄이 하나라도 있으면 HTTP 202, 새로 접수한 줄이 없으면 200 입니다.

요청 헤더

헤더필수설명
Authorization필수API 자격증명입니다. client_id 와 client_secret 을 콜론으로 이어 Base64 로 인코딩해 보냅니다.
X-Env-Scope필수호출 환경입니다.
Idempotency-Key필수재시도 식별 키입니다. 같은 키와 같은 본문으로 다시 호출하면 기존 작업이 그대로 반환됩니다. ASCII 문자만 사용하며 UUID v4 를 권장합니다. 고객 정보는 넣지 않습니다.
Content-Type필수요청 본문 형식입니다.

요청 본문

필드타입필수설명예시
providerstring필수은행 상품의 provider 입니다(위 표의 provider 열).—
actionstring필수위 표의 action 을 보냅니다. 계좌를 고르지 않는 액션(로그인, 계좌 목록 등)은 거절합니다.—
account_link_idstring선택연동 하나로 좁힐 때 보냅니다. 보내면 그 연동의 계좌만 처리하고, 보내지 않으면 이 은행의 모든 연동을 처리합니다.—
bank_account_credential_idsarray선택처리할 계좌의 식별자 목록입니다. 보내지 않으면 계좌 전부를 처리합니다. 빈 목록은 전부로 읽지 않고 거절하며, 1개 이상이어야 합니다. 아직 접수하지 못한 계좌를 이어서 접수할 때 응답의 next.bank_account_credential_ids 를 그대로 넣습니다.—
paramsobject선택액션의 조회 조건입니다. 칸의 뜻은 상품 페이지의 요청 표를 봅니다. 계좌를 하나 고르는 칸(bank_account_credential_id)은 넣지 않습니다. 이 호출이 계좌마다 채우며, 값을 넣으면 거절합니다. 계좌 목록(bank_account_credential_ids)도 여기에 넣지 않고 요청 맨 위에 둡니다. 넣으면 거절합니다.—

본문에서 모르는 이름을 보내면 조용히 무시하지 않고 거절합니다.

접수 요청

bash
curl --request POST \
  --url 'https://api.xdata.kr/v1/jobs/all-accounts' \
  --header 'Authorization: Basic {base64(client_id:client_secret)}' \
  --header 'X-Env-Scope: real_test' \
  --header 'Idempotency-Key: demo-all-accounts-001' \
  --header 'Content-Type: application/json' \
  --data '{
  "provider": "kb_fast_account",
  "action": "kb_fast_account.accounts.transaction_history.search",
  "params": {
    "start_date": "2026-09-01",
    "end_date": "2026-09-30"
  }
}'

응답

응답은 계좌마다 한 줄을 담은 봉투입니다. 아래 표의 「있음」 칸은 그 필드가 늘 있는지, 값이 null 일 수 있는지, 칸 자체가 없을 수 있는지를 말합니다.

응답 봉투

필드타입있음설명
env_scopestring항상요청한 환경입니다. 요청 헤더 X-Env-Scope 의 값과 같습니다.
providerstring항상요청한 은행 상품의 provider 입니다.
actionstring항상요청한 액션입니다.
submitted_atstring항상호출을 받은 시각입니다(ISO 8601, KST +09:00). 계좌마다 작업을 접수한 시각은 GET /v1/jobs/{job_id} 에 있습니다.
account_countnumber항상이 호출이 다룬 계좌 수입니다.
submitted_countnumber항상작업을 접수한 줄 수입니다. 같은 Idempotency-Key 로 다시 보내 기존 작업이 돌아온 줄도 셉니다.
accountsarray항상계좌마다 한 줄입니다. 최근 등록한 연동의 계좌부터, 같은 연동 안에서는 최근 등록한 계좌부터 나옵니다.
nextobject항상(값이 없으면 null)아직 접수하지 못한 계좌가 있을 때만 값이 있고, 없으면 null 입니다. 이어받는 방법은 아래 「이어받기」 를 봅니다.
interruptionobject없을 수 있음호출이 중간에 멈췄을 때만 있습니다. 멈추지 않았으면 이 칸 자체가 없습니다.

accounts 의 한 항목(계좌 한 줄)

필드타입있음설명
account_link_idstring항상계좌가 속한 연동의 식별자입니다.
bank_account_credential_idstring항상계좌의 식별자입니다. 은행의 계좌 목록 액션이 돌려주는 값과 같습니다.
masked_account_nostring항상가려진 계좌번호입니다(예: 110-***-123456).
job_idstring항상(값이 없으면 null)접수한 작업 번호입니다. 이 번호로 GET /v1/jobs/{job_id} 와 GET /v1/jobs/{job_id}/result 를 부릅니다. 접수하지 못한 줄은 null 입니다.
statusstring항상그 작업의 지금 상태이거나 접수하지 못했다는 NOT_SUBMITTED 입니다. 아래 「줄의 상태」 를 봅니다.
reasonstring항상(값이 없으면 null)NOT_SUBMITTED 줄에만 값이 있습니다. 접수하지 못한 까닭이며 아래 표의 두 값 중 하나입니다.

줄의 상태

status 는 그 작업의 지금 상태이거나 NOT_SUBMITTED 입니다.

접수하지 못한 까닭

reason뜻다음에 할 일
over_concurrency_limit같은 은행 상품으로 동시에 처리할 수 있는 작업 수에 막혀 아직 접수하지 못했습니다. 이 호출이 낸 작업뿐 아니라 같은 상품으로 최근 접수해 아직 끝나지 않은 다른 작업도 셉니다.다른 줄의 접수에는 영향이 없습니다. 응답의 next 가 이 계좌들을 줍니다. 앞선 작업이 끝난 뒤 같은 호출을 그 목록으로 다시 보냅니다.
interrupted앞선 줄을 접수한 뒤 동시 작업 상한이 아닌 다른 거절로 호출이 멈춰, 이 줄과 그 뒤 줄을 접수하지 못했습니다.응답의 interruption 이 멈춘 까닭을 말합니다. 까닭을 해결한 뒤 next 의 목록으로 다시 보냅니다.

next 블록

필드타입있음설명
retry_after_secondsnumber항상다시 부르기 전에 기다릴 만한 시간(초)입니다. 동시 작업 상한에 막힌 줄을 이어받을 때 뜻이 있습니다. 호출이 멈춘 경우(interruption 이 있을 때)에는 기다려도 풀리지 않을 수 있습니다.
bank_account_credential_idsarray항상아직 접수하지 못한 계좌의 식별자입니다. 같은 이름의 요청 칸에 그대로 넣어 다시 보냅니다.
messagestring항상아직 접수하지 못한 계좌 수와 다시 보내라는 안내입니다. 까닭과 관계없이 같은 문장이라, interruption 이 있으면 interruption.message 를 먼저 보여 줍니다.

interruption 블록

필드타입있음설명
codestring항상표준 오류 코드입니다. 같은 거절이 계좌 하나로 부른 호출에서 났다면 오류 응답이 싣는 코드와 같습니다.
messagestring항상고객에게 보여 줄 수 있는 문장입니다.
reasonstring항상(값이 없으면 null)세부 까닭 코드입니다. 거절이 싣고 있을 때만 값이 있고, 없으면 null 입니다.

모두 접수된 응답

모든 줄이 새로 접수되면 HTTP 202 이고 next 는 null 입니다.

json
{
  "env_scope": "real_test",
  "provider": "kb_fast_account",
  "action": "kb_fast_account.accounts.transaction_history.search",
  "submitted_at": "2026-08-25T09:30:00+09:00",
  "account_count": 3,
  "submitted_count": 3,
  "accounts": [
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "3f2b8c1e-6a4d-4e7b-9c15-0a1b2c3d4e51",
      "masked_account_no": "110-***-123456",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN01",
      "status": "QUEUED",
      "reason": null
    },
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "9a7d4e22-1c8f-4b36-a5d0-6e2f81b3c704",
      "masked_account_no": "110-***-654321",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN02",
      "status": "QUEUED",
      "reason": null
    },
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "c41e6b97-5d02-4a8f-b3e9-17f0d2a68c35",
      "masked_account_no": "110-***-246810",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN03",
      "status": "QUEUED",
      "reason": null
    }
  ],
  "next": null
}

일부를 아직 접수하지 못한 응답

은행에서 동시에 처리할 수 있는 작업 수에 막힌 줄은 오류가 아니라 NOT_SUBMITTED 줄로 남습니다.

json
{
  "env_scope": "real_test",
  "provider": "kb_fast_account",
  "action": "kb_fast_account.accounts.transaction_history.search",
  "submitted_at": "2026-08-25T09:30:00+09:00",
  "account_count": 3,
  "submitted_count": 2,
  "accounts": [
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "3f2b8c1e-6a4d-4e7b-9c15-0a1b2c3d4e51",
      "masked_account_no": "110-***-123456",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN01",
      "status": "QUEUED",
      "reason": null
    },
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "9a7d4e22-1c8f-4b36-a5d0-6e2f81b3c704",
      "masked_account_no": "110-***-654321",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN02",
      "status": "QUEUED",
      "reason": null
    },
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "c41e6b97-5d02-4a8f-b3e9-17f0d2a68c35",
      "masked_account_no": "110-***-246810",
      "job_id": null,
      "status": "NOT_SUBMITTED",
      "reason": "over_concurrency_limit"
    }
  ],
  "next": {
    "retry_after_seconds": 10,
    "bank_account_credential_ids": [
      "c41e6b97-5d02-4a8f-b3e9-17f0d2a68c35"
    ],
    "message": "아직 접수하지 못한 계좌가 1개 있습니다. 앞선 작업이 끝난 뒤 같은 호출을 이 계좌들로 다시 하세요."
  }
}

이어받기

next 가 null 이 아니면 접수하지 못한 계좌가 남은 것입니다. 앞선 작업이 끝난 뒤 같은 호출을 그 계좌들로 다시 보내 이어서 접수합니다.

  1. next 를 확인합니다

    next 가 null 이면 모든 계좌를 접수한 것입니다. 값이 있으면 아래로 이어갑니다.

  2. 잠시 기다립니다

    next.retry_after_seconds 만큼 기다린 뒤 다시 보냅니다.

  3. 같은 호출을 목록으로 다시 보냅니다

    본문은 그대로 두고 bank_account_credential_ids 에 next.bank_account_credential_ids 를 넣습니다.

같은 요청을 다시 보낼 때

응답을 받지 못했다면 같은 Idempotency-Key 와 같은 본문으로 다시 보냅니다.

json
{
  "env_scope": "real_test",
  "provider": "kb_fast_account",
  "action": "kb_fast_account.accounts.transaction_history.search",
  "submitted_at": "2026-08-25T09:30:00+09:00",
  "account_count": 3,
  "submitted_count": 3,
  "accounts": [
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "3f2b8c1e-6a4d-4e7b-9c15-0a1b2c3d4e51",
      "masked_account_no": "110-***-123456",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN01",
      "status": "SUCCEEDED",
      "reason": null
    },
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "9a7d4e22-1c8f-4b36-a5d0-6e2f81b3c704",
      "masked_account_no": "110-***-654321",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN02",
      "status": "RUNNING",
      "reason": null
    },
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "c41e6b97-5d02-4a8f-b3e9-17f0d2a68c35",
      "masked_account_no": "110-***-246810",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN03",
      "status": "QUEUED",
      "reason": null
    }
  ],
  "next": null
}

호출이 중간에 멈췄을 때

줄을 몇 개 접수한 뒤 동시 작업 상한이 아닌 다른 거절로 호출이 멈추면 오류로 돌려주지 않습니다.

json
{
  "env_scope": "real_test",
  "provider": "kb_fast_account",
  "action": "kb_fast_account.accounts.transaction_history.search",
  "submitted_at": "2026-08-25T09:30:00+09:00",
  "account_count": 3,
  "submitted_count": 1,
  "accounts": [
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "3f2b8c1e-6a4d-4e7b-9c15-0a1b2c3d4e51",
      "masked_account_no": "110-***-123456",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN01",
      "status": "QUEUED",
      "reason": null
    },
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "9a7d4e22-1c8f-4b36-a5d0-6e2f81b3c704",
      "masked_account_no": "110-***-654321",
      "job_id": null,
      "status": "NOT_SUBMITTED",
      "reason": "interrupted"
    },
    {
      "account_link_id": "7d9e5a40-3b1c-4f62-8e07-52c1a9d4b630",
      "bank_account_credential_id": "c41e6b97-5d02-4a8f-b3e9-17f0d2a68c35",
      "masked_account_no": "110-***-246810",
      "job_id": null,
      "status": "NOT_SUBMITTED",
      "reason": "interrupted"
    }
  ],
  "next": {
    "retry_after_seconds": 10,
    "bank_account_credential_ids": [
      "9a7d4e22-1c8f-4b36-a5d0-6e2f81b3c704",
      "c41e6b97-5d02-4a8f-b3e9-17f0d2a68c35"
    ],
    "message": "아직 접수하지 못한 계좌가 2개 있습니다. 앞선 작업이 끝난 뒤 같은 호출을 이 계좌들로 다시 하세요."
  },
  "interruption": {
    "code": "POLICY_QUOTA_EXCEEDED",
    "message": "오늘 쓸 수 있는 사용 토큰을 모두 썼습니다. 내일 0시(한국 시간)에 다시 채워지며, 기다리는 데 비용은 들지 않습니다.",
    "reason": null
  }
}

계좌마다 결과 읽기

job_id 가 있는 줄마다 GET /v1/jobs/{job_id} 로 상태를 보고, 상태가 SUCCEEDED 가 되면 GET /v1/jobs/{job_id}/result 로 결과를 가져옵니다.

MCP 로 부르기

계좌를 고르는 은행 도구에 입력 두 개가 더해집니다.

더해지는 도구 입력

입력타입필수설명
all_accountsboolean선택true 이면 이 은행에 연결된 모든 계좌로 부릅니다. true 나 false 만 받으며, false 이거나 보내지 않으면 청하지 않은 것과 같습니다.
bank_account_credential_idsarray선택부를 계좌의 식별자 목록입니다. 1개 이상이어야 합니다.

bank_account_credential_id(한 계좌), bank_account_credential_ids(고른 계좌 여럿), all_accounts(모든 계좌) 중 정확히 하나만 보냅니다.

도구 호출

bash
# 모든 계좌로 한 번에 부르기
# echo -n "<client_id>:<client_secret>" | base64
export MCP_URL="https://api.xdata.kr/mcp"
export MCP_AUTH_B64="<base64_결과_붙여넣기>"
curl -sS -X POST "$MCP_URL" \
  -H "Authorization: Basic $MCP_AUTH_B64" \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "tools/call",
    "params": {
      "name": "kb_fast_account__accounts__transaction_history__search",
      "arguments": {
        "start_date": "2026-09-01",
        "end_date": "2026-09-30",
        "all_accounts": true
      }
    }
  }'

MCP 결과 읽기

호출 하나가 결과 한 덩어리를 돌려줍니다. 낸 작업들을 함께 기다리며(최대 60초), 하나가 끝나 은행의 동시 작업 수에 여유가 생기면 접수하지 못한 줄을 이어서 냅니다.

결과 본문

필드타입있음설명
all_accountsboolean항상「모든 계좌」 호출의 결과라는 표지입니다. 늘 true 입니다.
summarystring항상계좌 수와 상태별 개수를 한 줄로 요약한 문장입니다.
accountsarray항상계좌마다 한 줄입니다. 한 줄이 싣는 칸은 상태에 따라 달라집니다(아래 표).
nextobject항상(값이 없으면 null)아직 접수하지 못한 계좌가 있을 때만 값이 있고, 없으면 null 입니다. tool_call 이 그 계좌들로 같은 도구를 다시 부르는 호출입니다.
interruptionobject없을 수 있음호출이 중간에 멈췄을 때만 있습니다. 칸의 뜻은 REST 응답의 interruption 과 같고, 같은 거절이면 code 와 reason 도 REST 와 같은 값입니다. message 는 MCP 쪽에 AI 가 읽는 안내가 덧붙을 수 있어 REST 의 문장과 다를 수 있습니다.

줄의 상태마다 싣는 칸

status더해지는 칸뜻
CANCELED, FAILED, TIMEOUTerror끝났지만 성공하지 못한 줄에 있습니다. 오류 코드와 문장, 다시 불러도 되는지(safe_to_retry), 먼저 부를 로그인 도구(next_action)가 담깁니다.
NOT_SUBMITTEDreason접수하지 못한 까닭입니다. over_concurrency_limit 또는 interrupted 입니다.
QUEUED, RUNNINGresult_tool_call아직 끝나지 않은 줄에 있습니다. 이 호출을 그대로 부르면 그 작업의 결과를 읽습니다.
SUCCEEDEDresult단건 호출과 같은 모양의 결과가 들어갑니다. 끝나서 성공한 줄에만 있습니다.

모든 줄이 싣는 칸은 bank_account_credential_id, masked_account_no, job_id, status 넷입니다. 아래는 네 상태가 섞인 결과입니다.

json
{
  "all_accounts": true,
  "summary": "계좌 4개: 완료 1 · 실패 1 · 진행 중 1 · 아직 접수 못 함 1",
  "accounts": [
    {
      "bank_account_credential_id": "3f2b8c1e-6a4d-4e7b-9c15-0a1b2c3d4e51",
      "masked_account_no": "110-***-123456",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN01",
      "status": "SUCCEEDED",
      "result": {
        "...": "같은 액션을 계좌 하나로 불렀을 때 받는 결과가 그대로 들어갑니다"
      }
    },
    {
      "bank_account_credential_id": "9a7d4e22-1c8f-4b36-a5d0-6e2f81b3c704",
      "masked_account_no": "110-***-654321",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN02",
      "status": "FAILED",
      "error": {
        "code": "PROVIDER_SESSION_EXPIRED",
        "message": "세션이 만료됐거나 없습니다(오류 PROVIDER_SESSION_EXPIRED). 같은 account_link_id 로 'kb_fast_account__session__login' 을 먼저 불러 세션을 연 뒤 이 도구를 다시 부르세요.",
        "safe_to_retry": true,
        "next_action": "kb_fast_account__session__login"
      }
    },
    {
      "bank_account_credential_id": "c41e6b97-5d02-4a8f-b3e9-17f0d2a68c35",
      "masked_account_no": "110-***-246810",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN03",
      "status": "RUNNING",
      "result_tool_call": {
        "name": "xdata__job__result",
        "arguments": {
          "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN03"
        }
      }
    },
    {
      "bank_account_credential_id": "e8b05d13-72a9-4c46-9f1d-3a6b0c85e2f7",
      "masked_account_no": "110-***-135790",
      "job_id": null,
      "status": "NOT_SUBMITTED",
      "reason": "over_concurrency_limit"
    }
  ],
  "next": {
    "tool_call": {
      "name": "kb_fast_account__accounts__transaction_history__search",
      "arguments": {
        "start_date": "2026-09-01",
        "end_date": "2026-09-30",
        "bank_account_credential_ids": [
          "e8b05d13-72a9-4c46-9f1d-3a6b0c85e2f7"
        ]
      }
    },
    "message": "아직 접수하지 못한 계좌가 1개 있습니다. 앞선 작업이 끝난 뒤 같은 호출을 이 계좌들로 다시 하세요."
  }
}

isError 는 언제 참인가

도구 결과의 isError 는 모든 줄이 끝났고 하나도 성공하지 못했을 때만 참입니다.

상황isError맨 위 문장
성공한 줄, 실패한 줄, 진행 중인 줄, 접수하지 못한 줄이 섞여 있을 때false-
모든 줄이 성공했을 때false-
모든 줄이 끝났고 하나도 성공하지 못했을 때(줄마다 오류가 있음)true첫 오류 줄의 message
모든 줄이 끝났고 하나도 성공하지 못했을 때(오류 문장이 없는 줄뿐)true계좌 {count}개의 작업이 모두 실패했습니다.
실패한 줄이 있어도 진행 중인 줄이 하나라도 있을 때false-

MCP 에서 호출이 중간에 멈췄을 때

MCP 도 같은 규칙입니다. 줄을 접수한 뒤 멈춘 호출은 오류가 아니라 결과이고 isError 는 거짓입니다.

json
{
  "all_accounts": true,
  "summary": "계좌 3개: 완료 1 · 아직 접수 못 함 2",
  "accounts": [
    {
      "bank_account_credential_id": "3f2b8c1e-6a4d-4e7b-9c15-0a1b2c3d4e51",
      "masked_account_no": "110-***-123456",
      "job_id": "job_01JC8YH8R0E1ABCDEFGHJKMN01",
      "status": "SUCCEEDED",
      "result": {
        "...": "같은 액션을 계좌 하나로 불렀을 때 받는 결과가 그대로 들어갑니다"
      }
    },
    {
      "bank_account_credential_id": "9a7d4e22-1c8f-4b36-a5d0-6e2f81b3c704",
      "masked_account_no": "110-***-654321",
      "job_id": null,
      "status": "NOT_SUBMITTED",
      "reason": "interrupted"
    },
    {
      "bank_account_credential_id": "c41e6b97-5d02-4a8f-b3e9-17f0d2a68c35",
      "masked_account_no": "110-***-246810",
      "job_id": null,
      "status": "NOT_SUBMITTED",
      "reason": "interrupted"
    }
  ],
  "next": {
    "tool_call": {
      "name": "kb_fast_account__accounts__transaction_history__search",
      "arguments": {
        "start_date": "2026-09-01",
        "end_date": "2026-09-30",
        "bank_account_credential_ids": [
          "9a7d4e22-1c8f-4b36-a5d0-6e2f81b3c704",
          "c41e6b97-5d02-4a8f-b3e9-17f0d2a68c35"
        ]
      }
    },
    "message": "아직 접수하지 못한 계좌가 2개 있습니다. 앞선 작업이 끝난 뒤 같은 호출을 이 계좌들로 다시 하세요."
  },
  "interruption": {
    "code": "POLICY_QUOTA_EXCEEDED",
    "message": "오늘 쓸 수 있는 사용 토큰을 모두 썼습니다. 다음 날 0시(한국 시간) 에 다시 채워지며, 기다리는 데 비용은 들지 않습니다. 정식 환경은 이 한도를 세지 않습니다. 더 필요하시면 콘솔 결제 화면에서 요금제를 올릴 수 있습니다(https://console.xdata.kr/billing). 사용자에게 두 방법을 모두 알려 주세요.",
    "reason": null
  }
}

요청 단계의 오류

아무것도 접수하기 전에 거절하는 경우입니다.

REST

HTTPerror.code언제
429POLICY_QUOTA_EXCEEDED접수 전에 확인한, 새로 접수할 계좌 수만큼 필요한 사용 토큰이 남은 토큰보다 많을 때입니다. 일부만 접수하지 않고 하나도 접수하지 않으며 요청 전체를 거절합니다. 문장에 계좌 수, 필요한 토큰, 남은 토큰이 있습니다.
404BANK_ACCOUNT_CREDENTIAL_NOT_FOUND연동에 사용 중인 계좌가 하나도 없거나, bank_account_credential_ids 에 적은 계좌 중 내 사용 중인 계좌가 아닌 것이 하나라도 있을 때입니다. account_link_id 를 함께 보냈으면 그 연동의 사용 중인 계좌만 인정합니다. 일부만 접수하지 않고 전부 거절합니다.
404BANK_ACCOUNT_PARENT_NOT_FOUNDaccount_link_id 로 고른 연동이 내 사용 중인 연동이 아니거나, 이 은행에 연동이 하나도 없을 때입니다. 연동이 없으면 계정을 먼저 연동한 뒤 계좌를 등록합니다.
400VALIDATION_INVALID_FIELD계좌를 고르지 않는 액션(로그인, 계좌 목록 등)으로 불렀을 때입니다.
400VALIDATION_INVALID_FIELDparams 에 bank_account_credential_id 의 값을 넣었을 때입니다. 이 호출이 계좌마다 채우므로 넣지 않습니다.
400VALIDATION_INVALID_FIELDparams 안에 bank_account_credential_ids 를 넣었을 때입니다. 값이 비어 있어도 거절하며, 계좌 목록은 요청 맨 위의 같은 이름 칸에 둡니다.
422VALIDATION_INVALID_FIELD본문에 모르는 이름이 있을 때입니다. error.fields 의 loc 가 그 이름을 알려 줍니다.
422VALIDATION_INVALID_FIELDbank_account_credential_ids 가 빈 목록일 때입니다.
422VALIDATION_INVALID_FIELDprovider 나 action 이 없을 때입니다.
400IDEMPOTENCY_KEY_REQUIREDIdempotency-Key 헤더가 없을 때입니다.

아래는 모르는 이름 bank_account_credential_id 를 본문에 보냈을 때의 실제 응답입니다.

json
{
  "error": {
    "code": "VALIDATION_INVALID_FIELD",
    "message": "요청 값이 올바르지 않습니다.",
    "retryable": false,
    "provider_error_ref": null,
    "trace_id": "trc_9f2c4d1e0b6a4f7c8d3e5a1b2c4d6e8f",
    "fields": [
      {
        "loc": [
          "body",
          "bank_account_credential_id"
        ],
        "msg": "Extra inputs are not permitted",
        "type": "extra_forbidden"
      }
    ]
  }
}

아래는 사용 토큰이 모자랄 때의 실제 응답입니다. 문장에 계좌 수, 필요한 토큰, 남은 토큰이 들어 있습니다.

json
{
  "error": {
    "code": "POLICY_QUOTA_EXCEEDED",
    "message": "계좌 5개를 처리하려면 사용 토큰 5개가 필요한데 지금 3개 남아 있어 하나도 제출하지 않았습니다. 토큰이 채워진 뒤 다시 시도하거나 계좌 수를 줄여 주세요.",
    "retryable": false,
    "provider_error_ref": null,
    "trace_id": "trc_9f2c4d1e0b6a4f7c8d3e5a1b2c4d6e8f"
  }
}

MCP

상황JSON-RPC codedata.error_code
접수 전 확인에서 필요한 사용 토큰이 남은 토큰보다 많을 때(하나도 접수하지 않음)-32001POLICY_QUOTA_EXCEEDED
사용 중인 계좌가 없거나 목록에 내 계좌가 아닌 것이 있을 때-32602BANK_ACCOUNT_CREDENTIAL_NOT_FOUND
account_link_id 로 고른 연동이 내 사용 중인 연동이 아니거나 이 은행에 연동이 하나도 없을 때-32602BANK_ACCOUNT_PARENT_NOT_FOUND
bank_account_credential_id 의 값과 bank_account_credential_ids 또는 all_accounts: true 를 함께 보냈을 때-32602-
all_accounts: true 와 bank_account_credential_ids 를 함께 보냈을 때-32602-
계좌를 고르지 않는 도구(로그인, 계좌 목록 등)에 「모든 계좌」 를 청했을 때-32602-

접수부터 결과까지

all-accounts.mjs
javascript
const api = process.env.XBOSS_API_URL ?? 'https://api.xdata.kr';
const auth = 'Basic ' + Buffer.from(`${process.env.XBOSS_CLIENT_ID}:${process.env.XBOSS_CLIENT_SECRET}`).toString('base64');
const headers = { Authorization: auth, 'X-Env-Scope': 'real_test' };
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

const body = {
  "provider": "kb_fast_account",
  "action": "kb_fast_account.accounts.transaction_history.search",
  "params": {
    "start_date": "2026-09-01",
    "end_date": "2026-09-30"
  }
};

// 1. 모든 계좌를 접수합니다. 접수하지 못한 계좌가 남으면 응답의 next 로 이어서 접수합니다.
//    회차마다 Idempotency-Key 를 따로 씁니다. 응답을 받지 못해 같은 회차를 다시 보낼 때만 같은 키를 씁니다.
//    호출이 멈췄거나(interruption) 이어받는 회차가 거절되면, 이미 접수한 작업을 잃지 않도록 여기서 멈추고 2단계로 갑니다.
//    남은 계좌는 까닭을 해결한 뒤 새 Idempotency-Key 와 next 의 목록으로 다시 보냅니다.
const lines = [];
let request = body;
for (let round = 1; ; round += 1) {
  const submitted = await fetch(`${api}/v1/jobs/all-accounts`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json', 'Idempotency-Key': `monthly-2026-09-r${round}` },
    body: JSON.stringify(request),
  });
  if (submitted.status !== 202 && submitted.status !== 200) {
    if (lines.length === 0) throw new Error(`접수 실패 ${submitted.status} ${await submitted.text()}`);
    console.warn('이어받기가 거절되어 멈춥니다:', submitted.status, await submitted.text());
    console.warn('접수하지 못한 계좌:', request.bank_account_credential_ids);
    break;
  }
  const batch = await submitted.json();
  lines.push(...batch.accounts.filter((line) => line.job_id !== null));
  if (batch.interruption) {
    console.warn('호출이 중간에 멈췄습니다:', batch.interruption.message);
    console.warn('접수하지 못한 계좌:', batch.next?.bank_account_credential_ids);
    break;
  }
  if (batch.next === null) break;
  if (round >= 20) {
    console.warn('이어받기를 20번 했는데도 접수하지 못한 계좌가 남았습니다:', batch.next.bank_account_credential_ids);
    break;
  }
  await sleep(batch.next.retry_after_seconds);
  request = { ...body, bank_account_credential_ids: batch.next.bank_account_credential_ids };
}

// 2. 접수한 작업마다 끝날 때까지 상태를 보고 결과를 읽습니다.
async function readResult(jobId) {
  for (;;) {
    const response = await fetch(`${api}/v1/jobs/${jobId}`, { headers });
    if (!response.ok) throw new Error(`상태 조회 실패 ${response.status} ${await response.text()}`);
    const job = await response.json();
    if (job.status === 'SUCCEEDED') {
      const result = await fetch(`${api}/v1/jobs/${jobId}/result`, { headers });
      if (!result.ok) throw new Error(`결과 조회 실패 ${result.status} ${await result.text()}`);
      return result.json();
    }
    if (job.status !== 'QUEUED' && job.status !== 'RUNNING') return { status: job.status, error: job.error };
    await sleep(job.retry_after_seconds ?? 2);
  }
}
for (const line of lines) {
  console.log(line.masked_account_no, await readResult(line.job_id));
}