はじめてのAPI呼び出し:アプリ登録から取引一覧の取得まで

クイックスタート OAuth PKCE アクセストークン 取引一覧 Node.js

このクイックスタートでは、サーバーで動くアプリからレシートローラー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つのビジネスアカウントへトークンを紐付ける」:オン(初期値)
    • 「公開クライアント (モバイルアプリ)」:オフ(初期値)。サーバーのアプリはクライアントシークレットを送ります。
  • 実行環境:curl と Node.js 20 以上。追加パッケージは不要です。

トークンの種類で変わること

「認可時に1つのビジネスアカウントへトークンを紐付ける」の設定によって、発行されるトークンの性質が変わります。この記事はオン(初期値)を前提にしていますが、オフにしたアプリで手順4・5の見え方が変わる点だけ、ここで押さえておきます。

オン(初期値)オフ
同意画面連携するビジネスアカウントを1つ選ぶビジネスアカウントは選ばない
トークンそのビジネスアカウントに紐付き、ユーザーは含まないユーザーに紐付き、ビジネスアカウントは含まない
GET /api/v1/me/organizations403 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日。
sourcepos・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をブラウザで開いて許可すると、取引の一覧がターミナルに表示されます。

よくあるエラーと対処

場面応答原因と対処
認可URL400 invalid_redirect_uriredirect_uri が登録したURIと1文字でも違います(末尾のスラッシュ、ポート番号も含む)。
認可URL400 invalid_clientclient_id が見つかりません。コピーし間違いがないか確認します。
認可URL400 no_organizationログインした人が、どのビジネスアカウントでもオーナー・管理者ではありません。オーナーか管理者に許可してもらいます。
認可URL400 unsupported_response_typeresponse_type=code が付いていません。
コールバック?error=access_denied同意画面で拒否されたか、許可されたスコープが1つもありません。
トークン交換401 invalid_clientclient_secret が違うか未送信、またはアプリが無効です。
トークン交換401 invalid_granterror_description を見てください。コードの期限切れ・使用済み、redirect_uri の不一致、code_verifier の不足・不一致、またはビジネスアカウントのプランがAPIに対応していない(スタータープラン以上が必要)場合に返ります。期限切れ・使用済みならステップ1からやり直します。
トークン交換400 invalid_request / unsupported_grant_typecode がない、または grant_type の綴りが違います。フォームのフィールド名は snake_case です。
/me/organizations403 no_user_contextビジネスアカウントに紐付いたトークンです。ステップ4の POST /api/v1/auth/verify を使います。
取引一覧401トークンがない・無効・期限切れです。ステップ6で更新します。
取引一覧403トークンに store.orders.read も sales.read もない、トークンと違う organizationId を指定した、またはユーザーに紐付いたトークンで organizationId を省略した場合です。
取引一覧400until must be after since、window exceeds 365 days、source must be 'pos', 'oms', or 'all' のいずれかです。
トークン更新401 invalid_grantリフレッシュトークンが期限切れ・使用済み・取り消し済みです。連携をやり直してもらいます(ステップ1から)。

エラー全般の考え方はエラーコードとリトライ指針、トークン取得で詰まったときはトークンが取得できない(認証エラー)も参照してください。

次のステップ

公開日: 2026-10-26 更新日: 2026-10-26