💰

クラウド会計ソフト freee の APIの使い方

に公開

私はクラウド会計ソフトに freee を使用しています。
最近、領収書のファイル・アップロードが面倒になってきましたので、APIで自動化しようと思い、調べてみました。
HTTPプロトコルを使用するだけなので、使用する言語はなんでも良いです。このブログでは、JavaScriptPythonを使用しています。

freeeのAPIキーについて

SaaSのAPIは、管理画面でAPIキーを払い出してAPIを叩くのが一般的かと思います。(例: OpenAI API)
一方で、freeeの場合は、OAuth2のアクセス・トークンを払いだしてAPIを叩く方式になっています。
予備知識として、OAuth2の認可フローを理解しておくと良いでしょう。

https://zenn.dev/tfutada/articles/ce875b34ac23cd

アプリケーションを作成する

最初に、freeeの開発者ページ今すぐアプリを作成から、OAuth2のアプリケーションを作成します。

アプリケーションの作成が完了すると、図のようにアクセス・トークンの取得に必要な情報が表示されます。

Client IDClient SecretWebアプリ認証用URLはあとで使用しますので、メモしてください。もちろん、あとで閲覧することも可能です。

認可コードをゲットする

OAuth2では、いきなりアクセス・トークンは払い出せません。前段階として、いったん、認可コードという、有効期限の短い(10分)エフェメラルなコードを取得します。

Webアプリ認証用URLをWebブラウザーから開きます。
Gmailなどのソーシャル・ログインでお馴染みの、OAuth2の認可フローが開始しますので処理を進めます。最後に認可コードが画面に表示されて終わりです。

アクセス・トークンをゲットする

次に、払い出された認可コードを使用して、アクセス・トークンを取得します。

言語は何でも良いですが、ここではJavaScript(Node.js)を使用します。

.envに必要な環境変数をセットします。

AUTHORIZATION_CODE取得した認可コードをセットします。
CLIENT_IDCLIENT_SECRETは、freeeの開発者ページで作成したアプリケーションのページから確認してください。

.env
TOKEN_URL=https://accounts.secure.freee.co.jp/public_api/token
REDIRECT_URI=urn:ietf:wg:oauth:2.0:oob

# The client ID and secret from your app settings.
CLIENT_ID=6057...580
CLIENT_SECRET=R9Z-...L9Wep5KA

# Authorization Code by OAuth2.0
AUTHORIZATION_CODE=dcf...fd6576c696a

get-token.jsを作成し、実行します。

get-token.js
// ← make sure your package.json has "type": "module"
import {URLSearchParams} from 'url';
import dotenv from 'dotenv';

dotenv.config();

/**
 * 1) Exchange a one-time authorization code for access + refresh tokens
 * @param {string} code
 * @returns {Promise<{ access_token: string, refresh_token: string, expires_in: number }>}
 */
export async function fetchAccessToken(code) {
    const body = new URLSearchParams({
        grant_type: 'authorization_code',
        redirect_uri: process.env.REDIRECT_URI,
        client_id: process.env.CLIENT_ID,
        client_secret: process.env.CLIENT_SECRET,
        code,
    });

    const res = await fetch(process.env.TOKEN_URL, {
        method: 'POST',
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
        body: body.toString(),
    });

    if (!res.ok) {
        const errText = await res.text();
        throw new Error(`Token exchange failed (${res.status}): ${errText}`);
    }
    return res.json();
}


// ——— USAGE EXAMPLE ———
if (import.meta.url === `file://${process.argv[1]}`) {
    (async () => {
        try {
            // Step 1: one-time code → tokens
            const {access_token, refresh_token, expires_in} =
                await fetchAccessToken(process.env.AUTHORIZATION_CODE);
            console.log('▶ Access token:', access_token);
            console.log('▶ Refresh token:', refresh_token);
            console.log(`▶ Expires in: ${expires_in}s`);

        } catch (err) {
            console.error(err);
        }
    })();
}

成功すると、めでたくアクセス・トークンが表示されます。
これで、あとはお好みのAPIをバシバシ叩くだけです。

APIを叩く

freeeが公開するAPI一覧から、使いたいAPIのエンドポイントを探してください。

ファイル・アップロードAPI

ここではレシートファイル(PDF)のアップロードAPIのサンプルをご紹介します。

レシートのアップロードは、Receiptsファイルボックス(証憑ファイル)を使用します。

エンドポイントはこちらになります。

POST https://api.freee.co.jp/api/1/receipts

アプリの権限の設定

お気づきかもしれませんが、先ほど払い出したアクセス・トークンではファイル・アップロードAPIのアクセス権限がありません。

権限を付与するには、freeeのアプリ管理の権限設定タブから、使用したいAPIをチェックする必要があります。
ここでは、ファイルボックスにチェックを入れる必要があります。
変更したら、画面右上の下書き保存ボタンを押すのを忘れないでください。

設定変更が完了したら、認可コードの発行から再度やり直してアクセス・トークンの再発行をしてください。

事業所ID

APIの利用にはアクセス・トークンの他に、事業所IDも必要です。

それにはAPI一覧のページから事業所一覧の取得のAPIを叩いて確認します。

まず、Authorizeボタンをクリックし、アクセス・トークンをセットしてください。Swaggerと思いますが、これでWeb画面からAPIをバシバシと叩くことができます。

Company /api/1/companies 事業所一覧の取得を叩きます。

実行すると、事業所一覧がJSONレスポンスで表示されます。JSONのidが事業所IDになります。
間違えやすいですが、company_numberではありませんidです。

Pythonコーディング

Node.jsでも良いですが、ここではPythonのサンプルをご紹介します。
といっても、ChatGPTに書いていただいたものです。非同期処理で書いてもらいました。

環境変数をセットします。

  • COMPANY_ID 事業所ID (JSONの id)
  • ACCESS_TOKEN アップロード・ファイル

Pythonのコードです。

freee_upload_single.py
#
# export COMPANY_ID=123456
# export ACCESS_TOKEN="your_access_token_here"
#
# python upload_receipt.py ./path/to/file.pdf --description "Sample receipt"
#

import os
import sys
import httpx
import asyncio
import argparse
from typing import Optional, Dict


async def upload_single_pdf(
        file_path: str,
        endpoint_url: str,
        company_id: int,
        description: Optional[str],
        client: httpx.AsyncClient
) -> Dict:
    """
    Upload a single PDF file along with company_id and description.
    """
    filename = os.path.basename(file_path)
    data = {"company_id": str(company_id)}
    if description:
        data["description"] = description

    try:
        with open(file_path, "rb") as f:
            response = await client.post(
                endpoint_url,
                data=data,
                files={"receipt": (filename, f, "application/pdf")}
            )
        response.raise_for_status()
        return {"file": filename, "response": response.json()}
    except FileNotFoundError:
        return {"file": filename, "error": "File not found"}
    except httpx.HTTPStatusError as e:
        return {"file": filename, "error": f"HTTP {e.response.status_code}: {e.response.text}"}
    except httpx.RequestError as e:
        return {"file": filename, "error": str(e)}


def parse_args() -> argparse.Namespace:
    p = argparse.ArgumentParser(
        description="Upload a single PDF file with company_id & optional description."
    )
    p.add_argument(
        "file",
        metavar="PDF",
        help="Path to the PDF file to upload"
    )
    p.add_argument(
        "--description",
        type=str,
        default=None,
        help="メモ (max 255 chars)"
    )
    return p.parse_args()


async def _main_async(
        file_path: str,
        company_id: int,
        description: Optional[str],
        access_token: str
):
    """
    Upload a single PDF file to the freee API asynchronously.
    """
    endpoint_url = "https://api.freee.co.jp/api/1/receipts"
    headers = {"Authorization": f"Bearer {access_token}"}
    async with httpx.AsyncClient(timeout=10.0, headers=headers) as client:
        return await upload_single_pdf(
            file_path, endpoint_url, company_id, description, client
        )


def main():
    """
    Main function to handle the upload process.
    """
    company_id_str = os.getenv("COMPANY_ID")
    if not company_id_str:
        print("❌ COMPANY_ID environment variable is not set.", file=sys.stderr)
        sys.exit(1)
    try:
        company_id = int(company_id_str)
    except ValueError:
        print(f"❌ COMPANY_ID must be an integer (got '{company_id_str}').", file=sys.stderr)
        sys.exit(1)

    # 2) ACCESS_TOKEN check
    access_token = os.getenv("ACCESS_TOKEN")
    if not access_token:
        print("❌ ACCESS_TOKEN environment variable is not set.", file=sys.stderr)
        sys.exit(1)

    # 3) CLI args
    args = parse_args()
    file_path = args.file

    # 4) Run async upload
    result = asyncio.run(
        _main_async(file_path, company_id, args.description, access_token)
    )

    # 5) Report outcome
    if "error" in result:
        print(f"❌ Upload failed for {result['file']}: {result['error']}", file=sys.stderr)
        sys.exit(1)
    else:
        print(f"✅ Uploaded {result['file']}:")
        print(result["response"])


if __name__ == "__main__":
    main()

実行します。
引数にアップロードするPDFファイルを指定します。

freee_upload_single.py my-amazon-receipt.pdf

うまく行きましたでしょうか? freeeのサイトから、ファイルが正しくアップロードされたのか確認してください。

以上で終わりです。

補足: リフレッシュ・トークンの存在

アクセス・トークンは6時間で有効期限が切れます。そのためリフレッシュ・トークンを使用して、アクセス・トークンを再度払い出す必要があります。このとき、認可コードは不要です。

また、リフレッシュ・トークンも一緒に再発行されるので、本番ではリフレッシュ・トークンをローテーションする仕掛けも必要になります。

認可コード < アクセス・トークン < リフレッシュ・トークン、の順に有効期限が長くなるわけです。流出リスクの高いトークンの有効期限を短くしつつ、より流出リスクの低いトークンで発行するという仕組みです。

特に説明は不要かと思いますので、ソースコードだけ載せます。

get-with-refresh.js
// ← make sure your package.json has "type": "module"
import {URLSearchParams} from 'url';
import dotenv from 'dotenv';

dotenv.config();


/**
 * 2) Use your stored refresh token to mint a fresh access token
 * @param {string} refreshToken
 * @returns {Promise<{ access_token: string, refresh_token: string, expires_in: number }>}
 */
export async function refreshAccessToken(refreshToken) {
    const body = new URLSearchParams({
        grant_type: 'refresh_token',
        redirect_uri: REDIRECT_URI,
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        refresh_token: refreshToken,
    });

    const res = await fetch(TOKEN_URL, {
        method: 'POST',
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
        body: body.toString(),
    });

    if (!res.ok) {
        const errText = await res.text();
        throw new Error(`Refresh token failed (${res.status}): ${errText}`);
    }
    return res.json();
}


// ——— USAGE EXAMPLE ———
if (import.meta.url === `file://${process.argv[1]}`) {
    (async () => {
        try {
            // Step 2: later, renew access
            const {access_token: newAt, refresh_token: newRt} =
                await refreshAccessToken(refresh_token);
            console.log('▶ New access token:', newAt);
            console.log('▶ New refresh token:', newRt);

        } catch (err) {
            console.error(err);
        }
    })();
}

次回はLLMを使用して、PDFファイルからメタデータを抽出する方法をご紹介する予定です。

Discussion