Your first API call: from app registration to listing transactions

quickstart OAuth PKCE access token transactions Node.js

This quickstart takes a server-side app from nothing to its first successful ReceiptRoller API call. A business account owner approves your app through the OAuth 2.0 authorization code flow with PKCE, you exchange the authorization code for an access token, confirm which business account the token belongs to, and then fetch the transaction feed (POS sales and OMS orders in one list). Finally you refresh the token. Every HTTP call is shown with curl, and a single Node.js 20 script at the end runs the whole flow.

Before you start

  • Plan: the business account you connect to must be on the Starter plan or above. A business account on a lower plan is refused a token. There is no sandbox yet, so you need a real business account with transaction data in it (test sales you rang up yourself are fine).
  • Who approves: only an owner or admin of the business account can approve the connection on the consent screen.
  • App registration: register an app in the developer portal and note its client ID and client secret. See Creating a new app for the screens. This article assumes these settings:
    • Redirect URI: http://localhost:3000/callback (compared character for character with the redirect_uri in the authorize request)
    • Scope: store.orders.read (the transaction feed accepts either store.orders.read or sales.read)
    • "Bind the access token to one business account at authorize time": on (the default)
    • "Public client (mobile app)": off (the default). A server-side app sends its client secret.
  • Tools: curl and Node.js 20 or later. No extra packages.

What the token type changes

The "Bind the access token to one business account at authorize time" setting decides what kind of token you get. This article assumes it is on, but if your app has it off, steps 4 and 5 look different:

On (default)Off
Consent screenThe approver picks one business accountNo business account is picked
TokenBound to that business account; carries no userBound to the user; names no business account
GET /api/v1/me/organizations403 no_user_contextThe business accounts the user belongs to
organizationId parameterOptional (a different ID returns 403)Required on every call

For apps that switch between business accounts in-app (setting off), see Guide for native mobile apps: multi-business-account access and the OAuth flow.

Step 1: Create the PKCE values and open the authorize URL

Generate a code_verifier (a random string) and the code_challenge, which is its SHA-256 hash in base64url. Keep the code_verifier on your server (for example in the session); you need it in step 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'))"

Send the business account owner's browser to this URL (line breaks added for reading; it is one line):

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
  • Always send code_challenge_method=S256. If you leave it out, the challenge is treated as plain and compared with the code_verifier as is.
  • Separate multiple scopes with spaces (%20 in the URL). Only scopes you selected when registering the app can be granted.
  • Make state an unguessable random value, store it, and check that the callback returns the same value.

Step 2: Get approval and receive the authorization code

If the owner is not signed in, ReceiptRoller shows its sign-in page first. On the consent screen they choose the business account to connect and approve; the browser then returns to your redirect URI:

http://localhost:3000/callback?code={code}&state={state}

If they decline, it returns with ?error=access_denied&state={state}. An authorization code is valid for 10 minutes and can be used once.

Step 3: Exchange the code for an access token

The token endpoint is POST https://receiptroller.io/api/v1/auth/token. Send application/x-www-form-urlencoded with snake_case field names, as the OAuth 2.0 spec describes (JSON is also accepted; this article uses the form). A server-side app (not a public client) must send 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}"

Response (trimmed):

{
  "access_token": "{access_token}",
  "refresh_token": "{refresh_token}",
  "token_type": "Bearer",
  "expires_in": 28800,
  "scope": "store.orders.read"
}
  • The access token lasts 8 hours (expires_in is in seconds); the refresh token lasts 30 days.
  • For compatibility, the same values are also returned under accessToken, refreshToken, tokenType and expiresIn. New code should read the snake_case keys.
  • Store tokens on the server only and keep them out of logs.

Step 4: Confirm which business account the token belongs to

A token bound to a business account carries no user, so GET /api/v1/me/organizations does not work with it. Instead, check the token with POST /api/v1/auth/verify; the bound business account comes back as organizationId.

curl -X POST https://receiptroller.io/api/v1/auth/verify \
  -H "Content-Type: application/json" \
  -d '{"accessToken":"{access_token}"}'

Response:

{
  "active": true,
  "clientId": "{client_id}",
  "scope": "store.orders.read",
  "expiresAt": "2026-10-26T09:00:00Z",
  "organizationId": "{organizationId}"
}

If organizationId is null, the token is bound to a user (an app with the binding setting off). In that case, list the user's business accounts and choose one:

curl https://receiptroller.io/api/v1/me/organizations \
  -H "Authorization: Bearer {access_token}"
{
  "organizations": [
    { "id": "{organizationId}", "name": "Sample Shop", "role": "SuperUser", "roles": ["SuperUser"] }
  ],
  "page": 1,
  "pageSize": 100,
  "totalCount": 1,
  "totalPages": 1
}

The list returns 100 per page by default (pageSize up to 1000); check totalPages and fetch the remaining pages. role is deprecated; read roles.

Step 5: List transactions

GET /api/v1/transactions returns POS sales and OMS orders merged into one feed, newest first. With a token bound to a business account you can omit 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"
ParameterMeaning
organizationIdBusiness account ID. Optional with a token bound to a business account; required with a user token.
storeIdFilter to one store (optional). 404 if the store is not in the business account.
since / untilStart and end of the window (ISO 8601). Read as UTC, so pass UTC times. Defaults to the last 30 days; at most 365 days.
sourcepos, oms or all (default)
page / pageSizeDefault page 1, 100 rows. pageSize is capped at 500.

Response (trimmed):

{
  "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
}

The list does not include line items or payments. Fetch those for the rows you need with GET /api/v1/transactions/{id}, passing the id exactly as listed. For what each field means, see Using the Transactions API (POS + OMS unified feed).

Step 6: Refresh the token

When the access token expires (the API returns 401), send grant_type=refresh_token to the same token endpoint:

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}"

The response has the same shape as in step 3. A refresh revokes both the old access token and the old refresh token and returns a new pair, so always store the new refresh_token. If several processes refresh at once, the slower one sends a refresh token that has just been revoked; refresh in one place only.

Run it end to end in Node.js

This script runs the whole flow: it receives the callback on localhost:3000, exchanges the code, confirms the business account, lists transactions and refreshes the token. Save it as 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");

Run it:

RR_CLIENT_ID={client_id} RR_CLIENT_SECRET={client_secret} node quickstart.mjs

Open the URL it prints, approve, and the transactions appear in your terminal.

Common errors

WhereResponseCause and fix
Authorize URL400 invalid_redirect_uriredirect_uri differs from the registered URI, even by a trailing slash or port.
Authorize URL400 invalid_clientUnknown client_id. Check for a copy-paste mistake.
Authorize URL400 no_organizationThe signed-in person is not an owner or admin of any business account. Ask an owner or admin to approve.
Authorize URL400 unsupported_response_typeresponse_type=code is missing.
Callback?error=access_deniedThe approver declined, or approved no scopes.
Token exchange401 invalid_clientclient_secret is wrong or missing, or the app is inactive.
Token exchange401 invalid_grantRead error_description. Returned when the code is expired or already used, redirect_uri does not match, code_verifier is missing or wrong, or the business account's plan does not include the API (Starter or above is needed). For an expired or used code, start again from step 1.
Token exchange400 invalid_request / unsupported_grant_typecode is missing, or grant_type is misspelled. Form field names are snake_case.
/me/organizations403 no_user_contextThe token is bound to a business account. Use POST /api/v1/auth/verify from step 4.
Transactions401The token is missing, invalid or expired. Refresh it (step 6).
Transactions403The token has neither store.orders.read nor sales.read, organizationId names a different business account from the token's, or a user token was sent without organizationId.
Transactions400One of until must be after since, window exceeds 365 days, or source must be 'pos', 'oms', or 'all'.
Token refresh401 invalid_grantThe refresh token is expired, already used or revoked. Ask the business account to connect again (step 1).

For error handling in general, see Error codes and retry guidance; if you cannot get a token, see Cannot obtain a token (authentication error).

Next steps

Published: 2026-10-26 Updated: 2026-10-26