Your first API call: from app registration to listing transactions
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 theredirect_uriin the authorize request) - Scope:
store.orders.read(the transaction feed accepts eitherstore.orders.readorsales.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.
- Redirect URI:
- Tools:
curland 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 screen | The approver picks one business account | No business account is picked |
| Token | Bound to that business account; carries no user | Bound to the user; names no business account |
GET /api/v1/me/organizations | 403 no_user_context | The business accounts the user belongs to |
organizationId parameter | Optional (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 asplainand compared with thecode_verifieras is. - Separate multiple scopes with spaces (
%20in the URL). Only scopes you selected when registering the app can be granted. - Make
statean 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_inis in seconds); the refresh token lasts 30 days. - For compatibility, the same values are also returned under
accessToken,refreshToken,tokenTypeandexpiresIn. 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"
| Parameter | Meaning |
|---|---|
organizationId | Business account ID. Optional with a token bound to a business account; required with a user token. |
storeId | Filter to one store (optional). 404 if the store is not in the business account. |
since / until | Start and end of the window (ISO 8601). Read as UTC, so pass UTC times. Defaults to the last 30 days; at most 365 days. |
source | pos, oms or all (default) |
page / pageSize | Default 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
| Where | Response | Cause and fix |
|---|---|---|
| Authorize URL | 400 invalid_redirect_uri | redirect_uri differs from the registered URI, even by a trailing slash or port. |
| Authorize URL | 400 invalid_client | Unknown client_id. Check for a copy-paste mistake. |
| Authorize URL | 400 no_organization | The signed-in person is not an owner or admin of any business account. Ask an owner or admin to approve. |
| Authorize URL | 400 unsupported_response_type | response_type=code is missing. |
| Callback | ?error=access_denied | The approver declined, or approved no scopes. |
| Token exchange | 401 invalid_client | client_secret is wrong or missing, or the app is inactive. |
| Token exchange | 401 invalid_grant | Read 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 exchange | 400 invalid_request / unsupported_grant_type | code is missing, or grant_type is misspelled. Form field names are snake_case. |
/me/organizations | 403 no_user_context | The token is bound to a business account. Use POST /api/v1/auth/verify from step 4. |
| Transactions | 401 | The token is missing, invalid or expired. Refresh it (step 6). |
| Transactions | 403 | The 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. |
| Transactions | 400 | One of until must be after since, window exceeds 365 days, or source must be 'pos', 'oms', or 'all'. |
| Token refresh | 401 invalid_grant | The 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).