はじめてのAPI呼び出し:アプリ登録から取引一覧の取得まで
このクイックスタートでは、サーバーで動くアプリからレシートローラーAPIをはじめて呼び出すまでを通して行います。OAuth 2.0 の認可コードフロー(PKCE付き)でビジネスアカウントのオーナーに連携を許可してもらい、認可コードをアクセストークンに交換し、そのトークンがどのビジネスアカウントのものかを確かめてから、取引一覧(POSの売上とOMSの注文をまとめたフィード)を取得します。最後にリフレッシュトークンでトークンを更新します。手順ごとに curl の例を示し、最後に同じ流れを1本で実行する Node.js 20 のスクリプトを載せています。
始める前に
- プラン:連携先のビジネスアカウントがスタータープラン以上であること。それ未満のプランのビジネスアカウントには、トークンが発行されません。テスト用の環境(サンドボックス)はまだないため、実際のビジネスアカウントと、そこに入っている取引データ(テスト用に打った取引でも構いません)を使います。
- 許可する人:同意画面で連携を許可できるのは、そのビジネスアカウントのオーナーまたは管理者だけです。
- アプリの登録:開発者ポータルでアプリを登録し、クライアントIDとクライアントシークレットを控えておきます。操作はアプリを新規作成するを参照してください。この記事では次の設定を前提にします。
- リダイレクトURI:
http://localhost:3000/callback(認可リクエストのredirect_uriと完全一致で比較されます) - スコープ:
store.orders.read(取引一覧はstore.orders.readとsales.readのどちらでも呼び出せます) - 「認可時に1つのビジネスアカウントへトークンを紐付ける」:オン(初期値)
- 「公開クライアント (モバイルアプリ)」:オフ(初期値)。サーバーのアプリはクライアントシークレットを送ります。
- リダイレクトURI:
- 実行環境:
curlと Node.js 20 以上。追加パッケージは不要です。
トークンの種類で変わること
「認可時に1つのビジネスアカウントへトークンを紐付ける」の設定によって、発行されるトークンの性質が変わります。この記事はオン(初期値)を前提にしていますが、オフにしたアプリで手順4・5の見え方が変わる点だけ、ここで押さえておきます。
| オン(初期値) | オフ | |
|---|---|---|
| 同意画面 | 連携するビジネスアカウントを1つ選ぶ | ビジネスアカウントは選ばない |
| トークン | そのビジネスアカウントに紐付き、ユーザーは含まない | ユーザーに紐付き、ビジネスアカウントは含まない |
GET /api/v1/me/organizations | 403 no_user_context | ユーザーが所属するビジネスアカウントの一覧 |
organizationId の指定 | 省略可(違うIDを指定すると403) | API呼び出しごとに必須 |
オフの使い方(アプリ内でビジネスアカウントを切り替える場合)はネイティブモバイルアプリ向けガイド: 複数ビジネスアカウントアクセスと OAuth フローで説明しています。
ステップ1:PKCE の値を作り、認可URLを開く
まず code_verifier(ランダムな文字列)と、その SHA-256 を base64url にした code_challenge を作ります。code_verifier はステップ3で使うので、セッションなどサーバー側に保存しておきます。
node -e "const c=require('crypto');const v=c.randomBytes(32).toString('base64url');console.log('code_verifier =',v);console.log('code_challenge =',c.createHash('sha256').update(v).digest('base64url'))"
ビジネスアカウントのオーナーのブラウザを次のURLに送ります(改行は読みやすさのためで、実際は1行です)。
https://receiptroller.io/oauth/authorize
?response_type=code
&client_id={client_id}
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback
&scope=store.orders.read
&state={state}
&code_challenge={code_challenge}
&code_challenge_method=S256
code_challenge_methodは必ずS256を付けてください。省略するとplainとして扱われ、code_verifierがそのまま比較されます。- 複数のスコープを求める場合はスペース区切りにします(URLでは
%20)。同意画面で許可されるのは、アプリ登録時に選んだスコープだけです。 stateは推測できないランダムな値にして保存し、コールバックで一致するか確かめます。
ステップ2:同意してもらい、認可コードを受け取る
未ログインの場合はレシートローラーのログイン画面が表示されます。ログイン後の同意画面で、連携するビジネスアカウントを選んで許可すると、ブラウザがリダイレクトURIに戻ってきます。
http://localhost:3000/callback?code={code}&state={state}
拒否された場合は ?error=access_denied&state={state} で戻ります。認可コードの有効期限は10分で、使えるのは1回だけです。
ステップ3:認可コードをアクセストークンに交換する
トークンエンドポイントは POST https://receiptroller.io/api/v1/auth/token です。OAuth 2.0 の仕様どおり application/x-www-form-urlencoded で、snake_case のフィールド名を送ります(JSON も受け付けますが、この記事ではフォーム形式を使います)。サーバーのアプリ(公開クライアントでないアプリ)は client_secret が必須です。
curl -X POST https://receiptroller.io/api/v1/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code={code}" \
--data-urlencode "redirect_uri=http://localhost:3000/callback" \
--data-urlencode "client_id={client_id}" \
--data-urlencode "client_secret={client_secret}" \
--data-urlencode "code_verifier={code_verifier}"
レスポンス(抜粋):
{
"access_token": "{access_token}",
"refresh_token": "{refresh_token}",
"token_type": "Bearer",
"expires_in": 28800,
"scope": "store.orders.read"
}
- アクセストークンの有効期限は8時間(
expires_inは秒)、リフレッシュトークンは30日です。 - 互換性のため、同じ値が
accessToken・refreshToken・tokenType・expiresInというキーでも返ります。新しく書くコードでは snake_case のキーを読んでください。 - トークンはサーバー側だけに保存し、ログに出さないでください。
ステップ4:どのビジネスアカウントのトークンかを確かめる
ビジネスアカウントに紐付いたトークンにはユーザーが含まれないため、GET /api/v1/me/organizations は使えません。代わりに POST /api/v1/auth/verify でトークンを確認すると、紐付いたビジネスアカウントのIDが organizationId で返ります。
curl -X POST https://receiptroller.io/api/v1/auth/verify \
-H "Content-Type: application/json" \
-d '{"accessToken":"{access_token}"}'
レスポンス:
{
"active": true,
"clientId": "{client_id}",
"scope": "store.orders.read",
"expiresAt": "2026-10-26T09:00:00Z",
"organizationId": "{organizationId}"
}
organizationId が null なら、それはユーザーに紐付いたトークン(紐付け設定がオフのアプリ)です。その場合は所属するビジネスアカウントを一覧で取得し、使うものを選びます。
curl https://receiptroller.io/api/v1/me/organizations \
-H "Authorization: Bearer {access_token}"
{
"organizations": [
{ "id": "{organizationId}", "name": "サンプル商店", "role": "SuperUser", "roles": ["SuperUser"] }
],
"page": 1,
"pageSize": 100,
"totalCount": 1,
"totalPages": 1
}
一覧は1ページ100件(pageSize は最大1000)です。totalPages を見て、残りのページも取得してください。role は廃止予定なので、roles を読んでください。
ステップ5:取引一覧を取得する
GET /api/v1/transactions は、POSの売上とOMSの注文を1つのフィードにまとめて新しい順に返します。ビジネスアカウントに紐付いたトークンなら organizationId は省略できます。
curl -G https://receiptroller.io/api/v1/transactions \
-H "Authorization: Bearer {access_token}" \
--data-urlencode "since=2026-10-01T00:00:00" \
--data-urlencode "until=2026-10-26T00:00:00" \
--data-urlencode "pageSize=50"
| パラメータ | 内容 |
|---|---|
organizationId | ビジネスアカウントのID。紐付いたトークンでは省略可。ユーザーに紐付いたトークンでは必須。 |
storeId | 店舗で絞り込む(任意)。そのビジネスアカウントにない店舗なら404。 |
since / until | 期間の開始と終了(ISO 8601)。UTCとして解釈されるので、UTCの日時を渡します。省略時は直近30日。期間は最大365日。 |
source | pos・oms・all(既定) |
page / pageSize | 既定は1ページ目・100件。pageSize の上限は500。 |
レスポンス(抜粋):
{
"organizationId": "{organizationId}",
"window": { "since": "2026-10-01T00:00:00Z", "until": "2026-10-26T00:00:00Z" },
"transactions": [
{
"id": "pos:{terminalId}:{transactionId}",
"source": "pos",
"occurredAt": "2026-10-25T05:12:44Z",
"storeId": "{storeId}",
"channel": "POS",
"status": "Completed",
"amounts": { "subtotal": 1200, "tax": 120, "shipping": 0, "discount": 0, "total": 1320 },
"itemCount": 2,
"customer": null,
"links": { "posTransactionId": "{transactionId}", "posTerminalId": "{terminalId}", "omsOrderId": null }
}
],
"page": 1,
"pageSize": 50,
"totalCount": 1,
"totalPages": 1
}
明細(商品行や支払い)は一覧には含まれません。必要な行だけ、id をそのまま使って GET /api/v1/transactions/{id} で取得します。各フィールドの詳しい意味は取引一覧API(Transactions API:POS+OMS統合フィード)の使い方を参照してください。
ステップ6:トークンを更新する
アクセストークンの期限が切れたら(APIが401を返したら)、同じトークンエンドポイントに grant_type=refresh_token を送ります。
curl -X POST https://receiptroller.io/api/v1/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=refresh_token" \
--data-urlencode "refresh_token={refresh_token}" \
--data-urlencode "client_id={client_id}" \
--data-urlencode "client_secret={client_secret}"
レスポンスの形はステップ3と同じです。更新すると古いアクセストークンとリフレッシュトークンはどちらも無効になり、新しい組が返ります。新しい refresh_token を必ず保存し直してください。複数のプロセスが同時に更新すると、遅れた側は無効になったリフレッシュトークンを送ることになるため、更新は1か所にまとめます。
Node.js で通して動かす
ここまでの流れを1本にしたスクリプトです。localhost:3000 でコールバックを受け、トークン交換、ビジネスアカウントの確認、取引一覧の取得、トークンの更新を順に行います。quickstart.mjs として保存してください。
// quickstart.mjs — Node.js 20+
import crypto from "node:crypto";
import http from "node:http";
const BASE = "https://receiptroller.io";
const CLIENT_ID = process.env.RR_CLIENT_ID;
const CLIENT_SECRET = process.env.RR_CLIENT_SECRET;
const REDIRECT_URI = process.env.RR_REDIRECT_URI ?? "http://localhost:3000/callback";
const SCOPE = "store.orders.read";
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url");
const state = crypto.randomBytes(16).toString("base64url");
const authorizeUrl = `${BASE}/oauth/authorize?` + new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: SCOPE,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
function waitForCode() {
const redirect = new URL(REDIRECT_URI);
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, redirect.origin);
if (url.pathname !== redirect.pathname) { res.writeHead(404).end(); return; }
res.end("Done. You can close this tab.");
server.close();
if (url.searchParams.get("state") !== state) return reject(new Error("state mismatch"));
if (url.searchParams.get("error")) return reject(new Error(url.searchParams.get("error")));
resolve(url.searchParams.get("code"));
});
server.listen(Number(redirect.port || 80));
});
}
async function postToken(fields) {
const res = await fetch(`${BASE}/api/v1/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, ...fields }),
});
const body = await res.json();
if (!res.ok) throw new Error(`token ${res.status}: ${body.error} ${body.error_description ?? ""}`);
return body;
}
async function getJson(path, accessToken) {
const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${accessToken}` } });
if (!res.ok) throw new Error(`GET ${path} -> ${res.status} ${await res.text()}`);
return res.json();
}
async function findOrganizationId(accessToken) {
const res = await fetch(`${BASE}/api/v1/auth/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessToken }),
});
const info = await res.json();
if (info.organizationId) return { organizationId: info.organizationId, boundToAccount: true };
// A user token names no business account: list them and take the first.
const list = await getJson("/api/v1/me/organizations", accessToken);
return { organizationId: list.organizations[0]?.id, boundToAccount: false };
}
console.log("Open this URL in a browser and approve:\n" + authorizeUrl);
const code = await waitForCode();
const token = await postToken({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier,
});
console.log(`Got a token for "${token.scope}", valid for ${token.expires_in}s`);
const { organizationId, boundToAccount } = await findOrganizationId(token.access_token);
console.log("Business account:", organizationId);
const query = new URLSearchParams({ pageSize: "10" });
if (!boundToAccount) query.set("organizationId", organizationId);
const feed = await getJson(`/api/v1/transactions?${query}`, token.access_token);
console.log(`${feed.totalCount} transactions from ${feed.window.since} to ${feed.window.until}`);
for (const tx of feed.transactions) {
console.log(tx.occurredAt, tx.source, tx.id, tx.amounts.total);
}
// The old pair stops working once this succeeds: store the new refresh_token.
const refreshed = await postToken({ grant_type: "refresh_token", refresh_token: token.refresh_token });
console.log("Refreshed; new token valid for", refreshed.expires_in, "s");
実行方法:
RR_CLIENT_ID={client_id} RR_CLIENT_SECRET={client_secret} node quickstart.mjs
表示されたURLをブラウザで開いて許可すると、取引の一覧がターミナルに表示されます。
よくあるエラーと対処
| 場面 | 応答 | 原因と対処 |
|---|---|---|
| 認可URL | 400 invalid_redirect_uri | redirect_uri が登録したURIと1文字でも違います(末尾のスラッシュ、ポート番号も含む)。 |
| 認可URL | 400 invalid_client | client_id が見つかりません。コピーし間違いがないか確認します。 |
| 認可URL | 400 no_organization | ログインした人が、どのビジネスアカウントでもオーナー・管理者ではありません。オーナーか管理者に許可してもらいます。 |
| 認可URL | 400 unsupported_response_type | response_type=code が付いていません。 |
| コールバック | ?error=access_denied | 同意画面で拒否されたか、許可されたスコープが1つもありません。 |
| トークン交換 | 401 invalid_client | client_secret が違うか未送信、またはアプリが無効です。 |
| トークン交換 | 401 invalid_grant | error_description を見てください。コードの期限切れ・使用済み、redirect_uri の不一致、code_verifier の不足・不一致、またはビジネスアカウントのプランがAPIに対応していない(スタータープラン以上が必要)場合に返ります。期限切れ・使用済みならステップ1からやり直します。 |
| トークン交換 | 400 invalid_request / unsupported_grant_type | code がない、または grant_type の綴りが違います。フォームのフィールド名は snake_case です。 |
/me/organizations | 403 no_user_context | ビジネスアカウントに紐付いたトークンです。ステップ4の POST /api/v1/auth/verify を使います。 |
| 取引一覧 | 401 | トークンがない・無効・期限切れです。ステップ6で更新します。 |
| 取引一覧 | 403 | トークンに store.orders.read も sales.read もない、トークンと違う organizationId を指定した、またはユーザーに紐付いたトークンで organizationId を省略した場合です。 |
| 取引一覧 | 400 | until must be after since、window exceeds 365 days、source must be 'pos', 'oms', or 'all' のいずれかです。 |
| トークン更新 | 401 invalid_grant | リフレッシュトークンが期限切れ・使用済み・取り消し済みです。連携をやり直してもらいます(ステップ1から)。 |
エラー全般の考え方はエラーコードとリトライ指針、トークン取得で詰まったときはトークンが取得できない(認証エラー)も参照してください。
次のステップ
-
MCPでAIエージェントをレシートローラーに接続するClaudeをMCPコネクタとして接続する手順と、動的クライアント登録・PKCE・JSON-RPCで自作のMCPクライアントから呼び出す方法を解説します。
-
店舗の予約を自社のカレンダーに表示する予約管理APIで店舗の7日分の予約を取得し、Node.jsでiCalendar(.ics)ファイルに書き出してカレンダーに表示する手順です。
-
Webhookで新しい売上を受け取り、Slackに通知するreceipt.created Webhookを登録し、Node.jsで署名検証と重複除去をしてSlackに売上を投稿するまでを手順で説明します。
-
商品マスタを自社システムと同期する手元のCSV・JSONを読み、SKUで照合して商品を作成・差分更新するNode.jsスクリプトを、商品APIで作ります。