Show a store's reservations in your own calendar
In this quickstart you build a Node.js script that reads a store's reservations from ReceiptRoller and writes them to an iCalendar file (.ics) that Google Calendar, Outlook or Apple Calendar can import. It reads the next 7 days and turns each booking into an event with its start and end time, the customer's name and the table, room or staff member it is assigned to. The integration only reads; it never creates or changes a booking.
Before you start
- A registered developer app that can obtain an OAuth access token. See The flow to getting started and Your first API call: from app registration to listing transactions.
- The
store.reservations.readscope ticked in your app's API scopes and included in thescopeof the authorize URL. That one scope covers every call in this guide: reservations and slots. - The business account you connect to must be on a plan that includes the developer API (Starter or above). For a business account on Basic, or still on its trial, the token exchange is refused with
401invalid_grant. - There is no sandbox yet. Use a real business account with reservations set up at a store, and a few test bookings in it.
- Node.js 20 or later. No extra packages are needed.
1. Find the store ID
Every reservation endpoint works on one store and requires storeId. How you find it depends on your app's setting "Bind the access token to one business account at authorize time".
Binding on (the default)
The token acts as the business account that authorized it and can read reservations at every one of its stores. It carries no user, so the /api/v1/me endpoints answer 403 no_user_context and the API cannot list the stores for you. Take the store ID from the dashboard instead: in the URL of the store's reservations screen it is the segment after /stores/.
https://receiptroller.io/en/organizations/{organizationId}/stores/{storeId}/reservations
With this kind of token you do not pass organizationId on any call.
Binding off (a token that acts as the user)
The token acts as the person who signed in. First list the business accounts they belong to:
curl -s "https://receiptroller.io/api/v1/me/organizations" \
-H "Authorization: Bearer {access_token}"
{
"organizations": [
{ "id": "{organizationId}", "name": "Sample Clinic", "role": "OperationsManager", "roles": ["OperationsManager"] }
],
"page": 1,
"pageSize": 100,
"totalCount": 1,
"totalPages": 1
}
Then ask what the user may do in that business account, store by store. The stores whose capabilities include reservations.read are the ones whose reservations you can read.
curl -s "https://receiptroller.io/api/v1/me/permissions?organizationId={organizationId}" \
-H "Authorization: Bearer {access_token}"
{
"organizationId": "{organizationId}",
"roles": [],
"capabilities": [],
"stores": [
{
"storeId": "{storeId}",
"name": "Shibuya",
"storeRoles": ["StoreStaff"],
"capabilities": ["reservations.read"]
}
]
}
If you also need addresses and other store details, GET /api/v1/me/organizations/{organizationId}/stores lists them. It returns every store in the business account, so use /me/permissions to decide which ones the user can read. With a token that acts as the user, add ?organizationId={organizationId} to every reservation call.
Store roles and reservation access
With a token that acts as the user, the user's role decides what they can reach:
| Role | View reservations | Book, change bookings, run slots |
|---|---|---|
Staff at the store (StoreStaff) | That store only | No |
Shift lead (ShiftLead) or store manager (StoreManager) at the store | That store only | That store only |
OperationsManager or SuperUser in the business account | Every store | Every store |
A bound token has no role to check and can reach every store in the business account (writing still needs store.reservations.write).
2. List the reservations
Call GET /api/v1/reservations with the store ID and a date range.
| Parameter | Meaning |
|---|---|
storeId | Required. The store ID. |
from / to | Reservation-date range (yyyy-MM-dd), both days included. Leave them out and the list is not limited by date. |
status | Limits the list to one status. All statuses when omitted. |
organizationId | For a token that acts as the user. |
curl -s "https://receiptroller.io/api/v1/reservations?storeId={storeId}&from=2026-11-02&to=2026-11-08" \
-H "Authorization: Bearer {access_token}"
{
"count": 1,
"reservations": [
{
"id": "{reservationId}",
"storeId": "{storeId}",
"slotId": "{slotId}",
"reservationDate": "2026-11-02",
"startTime": "10:00",
"endTime": "11:00",
"durationMinutes": 60,
"status": "Confirmed",
"customer": { "name": "Hanako Yamada", "phone": "", "email": "" },
"partySize": 1,
"assignedResource": "Room A",
"notes": { "customer": "", "staff": "" },
"bookingSource": "Web",
"createdAt": "2026-10-28T03:15:42.1234567Z",
"updatedAt": "2026-10-28T03:15:42.1234567Z"
}
]
}
Reservations come back earliest first, by date and then start time. There is no paging, so keep the range to what you need.
The fields a calendar needs
| Field | Meaning |
|---|---|
id | Reservation ID. Use it as the event's ID so a re-import updates the same event. |
reservationDate, startTime, endTime | The date (yyyy-MM-dd) and start and end time (HH:mm), in the store's local time (see the next section). |
status | Pending (awaiting confirmation), Confirmed, Arrived (checked in, visit in progress), Completed, Cancelled, NoShow. |
customer.name | The customer's name. |
partySize | Number of people. |
assignedResource | Name (not an ID) of the table, room or staff member the booking is assigned to. Can be empty. |
updatedAt | When the booking last changed, in UTC. |
customer.phone, customer.email and notes are returned too. Calendars tend to be shared beyond the store, so write out only what the people reading it need.
3. Times and time zones
ReceiptRoller stores times in UTC, with one exception: a reservation's reservationDate, startTime and endTime are the store's local time, returned without an offset. ReceiptRoller treats reservation times as Japan time (UTC+9). from and to are local dates as well.
- To write UTC to the calendar, read the date and time as Japan time and subtract 9 hours:
10:00on2026-11-02is2026-11-02T01:00:00Z. - Work out "today" as the store's local date. The UTC date is still the previous day between midnight and 9:00 in Japan.
createdAtandupdatedAtare UTC.
4. List open slots (optional)
To show availability as well, use GET /api/v1/reservations/slots. Without from and to it returns 7 days, but counted from the UTC date, so pass both explicitly.
curl -s "https://receiptroller.io/api/v1/reservations/slots?storeId={storeId}&from=2026-11-02&to=2026-11-08" \
-H "Authorization: Bearer {access_token}"
{
"count": 1,
"slots": [
{
"id": "{slotId}",
"date": "2026-11-02",
"startTime": "11:00",
"endTime": "12:00",
"durationMinutes": 60,
"capacity": 2,
"capacityUnit": "Bookings",
"bookedCount": 1,
"remainingCapacity": 1,
"isAvailable": true,
"assignedResource": "Room A"
}
]
}
Use remainingCapacity and isAvailable to tell whether a slot is open. Slot times are local time, like reservation times.
5. Write 7 days of reservations to an .ics file
Save the script below as reservations-to-ics.mjs. It skips cancelled and no-show bookings, and writes bookings still awaiting confirmation as tentative events.
// reservations-to-ics.mjs: writes the next 7 days of reservations to reservations.ics
import { writeFile } from "node:fs/promises";
const API = "https://receiptroller.io/api/v1";
const token = process.env.RR_ACCESS_TOKEN;
const storeId = process.env.RR_STORE_ID;
const organizationId = process.env.RR_ORGANIZATION_ID; // only for a token that acts as the user
const STORE_UTC_OFFSET_HOURS = 9; // reservation times are Japan time
if (!token || !storeId) {
console.error("Set RR_ACCESS_TOKEN and RR_STORE_ID");
process.exit(1);
}
async function get(path, params = {}) {
const url = new URL(API + path);
const query = { storeId, organizationId, ...params };
for (const [key, value] of Object.entries(query)) {
if (value) url.searchParams.set(key, value);
}
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) {
throw new Error(`${res.status} ${path} ${await res.text()}`);
}
return res.json();
}
function storeToday() {
const storeNow = new Date(Date.now() + STORE_UTC_OFFSET_HOURS * 3600_000);
return storeNow.toISOString().slice(0, 10);
}
function addDays(date, days) {
const d = new Date(`${date}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
// A store-local date plus "HH:mm", as a UTC Date
function storeTimeToUtc(date, time) {
const [year, month, day] = date.split("-").map(Number);
const [hour, minute] = time.split(":").map(Number);
return new Date(Date.UTC(year, month - 1, day, hour - STORE_UTC_OFFSET_HOURS, minute));
}
// In the form 20261102T010000Z
function icsUtc(date) {
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
}
function icsText(value) {
return String(value ?? "")
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r?\n/g, "\\n");
}
// RFC 5545: a line longer than 75 octets continues on the next line, which starts with a space
function fold(line) {
const parts = [];
let current = "";
let octets = 0;
for (const ch of line) {
const size = Buffer.byteLength(ch);
if (octets + size > 75) {
parts.push(current);
current = " ";
octets = 1;
}
current += ch;
octets += size;
}
parts.push(current);
return parts.join("\r\n");
}
const from = storeToday();
const to = addDays(from, 6);
const { reservations } = await get("/reservations", { from, to });
const stamp = icsUtc(new Date());
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//example.com//reservations-to-ics//EN",
"CALSCALE:GREGORIAN",
];
for (const r of reservations) {
if (r.status === "Cancelled" || r.status === "NoShow") continue;
const start = storeTimeToUtc(r.reservationDate, r.startTime);
let end = storeTimeToUtc(r.reservationDate, r.endTime);
if (end <= start) {
end = new Date(start.getTime() + (r.durationMinutes || 60) * 60_000);
}
const party = r.partySize > 0 ? ` (${r.partySize})` : "";
const resource = r.assignedResource;
lines.push(
"BEGIN:VEVENT",
`UID:${r.id}@example.com`, // replace example.com with your own domain
`DTSTAMP:${stamp}`,
`DTSTART:${icsUtc(start)}`,
`DTEND:${icsUtc(end)}`,
`SUMMARY:${icsText(`${r.customer.name}${party}`)}`,
`STATUS:${r.status === "Pending" ? "TENTATIVE" : "CONFIRMED"}`
);
if (resource) lines.push(`LOCATION:${icsText(resource)}`);
lines.push("END:VEVENT");
}
lines.push("END:VCALENDAR");
await writeFile("reservations.ics", lines.map(fold).join("\r\n") + "\r\n");
console.log(`Wrote reservations from ${from} to ${to} to reservations.ics`);
Run it:
RR_ACCESS_TOKEN={access_token} RR_STORE_ID={storeId} node reservations-to-ics.mjs
With a token that acts as the user, also set RR_ORGANIZATION_ID={organizationId}. The resulting reservations.ics looks like this:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//example.com//reservations-to-ics//EN
CALSCALE:GREGORIAN
BEGIN:VEVENT
UID:{reservationId}@example.com
DTSTAMP:20261101T230000Z
DTSTART:20261102T010000Z
DTEND:20261102T020000Z
SUMMARY:Hanako Yamada (1)
STATUS:CONFIRMED
LOCATION:Room A
END:VEVENT
END:VCALENDAR
Imported into a calendar, the event sits at 10:00 to 11:00 Japan time. Because UID comes from the reservation ID, rebuilding the file updates events instead of duplicating them.
6. Keep it up to date
There is no webhook event for reservations being created or changed. To keep the calendar current, run the script on a schedule and rebuild the file.
- Every 15 minutes to an hour is plenty. Each run makes one API call: the reservation list.
- There is no "updated since" filter. Fetch the whole 7-day window each time, and if you need the changes, compare
idandupdatedAtwith the previous run. - Serve the file from a web server and subscribe to it by URL in the calendar app. A cancelled booking then disappears from the calendar once it drops out of the file; a one-off import never sees later changes. The file contains customers' names, so use a hard-to-guess URL and limit who can reach it.
- Access tokens last 8 hours. A scheduled job needs to renew its token with the refresh token (Your first API call: from app registration to listing transactions).
Common errors
| Response | Cause and fix |
|---|---|
401 invalid_grant at token exchange | The business account's plan does not include the developer API. It needs Starter or above. |
401 (no body) | The token is invalid or has expired. Get a new one with the refresh token. |
403 (no body) | The token lacks store.reservations.read, a token that acts as the user was sent without organizationId, or the user has no role at this store. A bound token sent with another business account's organizationId also gets 403. |
403 no_user_context | A bound token called a /api/v1/me endpoint. Take the store ID from the dashboard URL instead. |
403 not_a_member | The user is not a member of that business account. An invitation that has not been accepted does not count. |
400 {"error": "storeId is required"} | Add storeId. |
200 with "count": 0 | There are no bookings in the range, or the store ID is wrong. An unknown store ID is not an error; it returns an empty list. |
401 and 403 from the API have no body. For working out which cause applies, see 403/401 is returned (permissions and scopes).
Next steps
-
Connect an AI agent to ReceiptRoller over MCPConnect Claude as an MCP connector, then build your own MCP client with dynamic client registration, PKCE and JSON-RPC calls over HTTP.
-
Receive new sales by webhook and post them to SlackRegister a receipt.created webhook, verify its signature and drop duplicates in Node.js, and post each new sale to a Slack channel.
-
Sync your product catalogue with ReceiptRollerBuild a Node.js script that reads a local CSV or JSON file, matches products by SKU, and creates or patches them through the Products API.