# OpenRows: the open-source Airtable alternative Source: https://openrows.io/. OpenRows is an open-source (AGPL-3.0) alternative to Airtable: relational tables with grid, kanban, calendar, gallery, timeline, list and form views, linked records, lookups, rollups, formulas, automations, webhooks and a REST API in Airtable's shape. Self-host it with Docker, or use the hosted service at https://app.openrows.io. ## Features - **Every view you need.** Grid, kanban, calendar, gallery, timeline and list views, plus forms. Filter, sort, group and colour any of them, and save personal or locked views. - **Fields that relate.** More than 20 field types, linked records, lookups, rollups, counts and an Airtable-style formula language, recomputed as your data changes. - **Work together, live.** Changes appear for everyone at once, with presence, comments and @mentions, notifications, revision history and a trash you can restore from. - **Automate the busywork.** Automations run on record changes, schedules, form submissions, buttons and incoming webhooks, and send email, Slack messages, HTTP requests or run scripts. - **Share on your terms.** Read-only share links and embeds for a view or a whole base, public forms, passwords and email-domain limits, and edit permissions per table and field. - **Built for builders.** A REST API in Airtable’s shape, webhooks, a typed JavaScript SDK, n8n nodes and an MCP server, so AI agents can read and write your bases. ## OpenRows and Airtable | | OpenRows | Airtable | | --- | --- | --- | | Source code | Open source, AGPL-3.0 | Proprietary | | Where it runs | Your own server, or our hosted service | Airtable’s cloud only | | Self-hosted limits | None: every core feature, no record caps | Not available | | API | REST API in Airtable’s shape, JavaScript SDK, MCP server, n8n nodes | REST API | | Moving your data | Airtable importer, CSV import and export, ZIP export of a base or account | CSV export per view | | Your content and AI | Never used to train AI models | Set by Airtable’s own policies | Airtable is a trademark of Formagrid, Inc. OpenRows is not affiliated with or endorsed by Airtable. ## Questions ### What is OpenRows? OpenRows is an open-source alternative to Airtable: a spreadsheet-style database with linked records, grid, kanban, calendar, gallery, timeline, list and form views, formulas, automations and an API. You can self-host it under the AGPL-3.0 or use the hosted service at app.openrows.io. ### Is OpenRows free? Self-hosting is free: the code is AGPL-3.0, with every core feature and no record limits. The hosted service has a free plan with 1,000 records per base and up to 5 editors per workspace. Pro is $10 per editor per month, or $100 a year (2 months free); Business is $24 per editor per month, or $240 a year (2 months free); Enterprise is priced with you. ### Can I move my bases from Airtable? Yes. The Airtable importer copies whole bases with an Airtable personal access token: tables, fields, linked records, lookups, rollups, formulas, records and attachments. The REST API follows Airtable’s shape, so scripts and integrations port with few changes. ### Does OpenRows work with AI agents? Yes. The MCP server lets AI agents list bases and read and write records with a personal access token, either remotely at app.openrows.io/mcp or locally with npx @openrows/mcp-server. Documentation for agents is at app.openrows.io/llms.txt. ### Where is my data stored? In the European Union. The hosted service runs on our own server in the EU, and file attachments and encrypted backups are stored in Cloudflare R2 in the EU, so your records, files and backups stay there. Enterprise plans will add a choice of region per workspace (a United States region is planned). Self-hosted, your data stays on your own server. ### Does this site use cookies or trackers? No. openrows.io sets no cookies and loads no trackers, analytics or third-party scripts. The app uses only the sign-in cookies it needs to keep you signed in. # Pricing Source: https://openrows.io/pricing | | Free | Pro | Business | Enterprise | | --- | --- | --- | --- | --- | | Price | $0 | $10 per editor per month, or $100 a year (2 months free) | $24 per editor per month, or $240 a year (2 months free) | custom pricing | | Records per base | 1,000 | 50,000 | 250,000 | 500,000 | | Attachment storage | 1 GB | 20 GB | 100 GB | Unlimited | | Automation runs per month | 250 | 25,000 | 100,000 | Unlimited | | API calls per month | 2,000 | 100,000 | Unlimited | Unlimited | | Editors per workspace | 5 | Unlimited | Unlimited | Unlimited | | Revision history | 14 days | 1 year | 3 years | Unlimited | | Audit log retention | — | — | — | 1 year | | AI credits per month | 100 | 1,000 per editor | 3,000 per editor | 3,000 per editor | | Personal views | no | yes | yes | yes | | Locked views | no | yes | yes | yes | | Table and field edit permissions | no | yes | yes | yes | | Remove OpenRows branding | no | yes | yes | yes | | Single sign-on (SSO) | no | no | yes | yes | | SCIM provisioning | no | no | yes | yes | | Audit log | no | no | no | yes | | Admin console and policies | no | no | no | yes | | Data residency (choice of region) | no | no | no | yes | Self-hosted OpenRows is free under the AGPL-3.0, with no limits. ## Pricing questions ### What does the free plan include? Every view, field type, formula, automation and the API, within these limits: Records per base: 1,000; Attachment storage: 1 GB; Automation runs per month: 250; API calls per month: 2,000; Editors per workspace: 5; Revision history: 14 days; Audit log retention: —; AI credits per month: 100. ### How much do the paid plans cost? Pro is $10 per editor per month, or $100 a year (2 months free); Business is $24 per editor per month, or $240 a year (2 months free); Enterprise is priced with you. Paid plans are billed per editor, monthly or yearly. ### What happens when a workspace reaches a limit? You can still read and edit everything, but new data that would go over the limit (new records, files, automation runs or API calls) waits until you upgrade, free up room or the month resets. We never delete your data because you went over a limit, and owners get an email at 80% and 100%. ### Is self-hosting really free? Yes. OpenRows is open source under the AGPL-3.0. A self-hosted instance has every core feature and none of these limits. Hosted-only features, such as billing, live in a separately licensed part of the code. ### Who handles payments? Polar, our merchant of record, will sell and bill the paid plans, including invoices and sales tax. # Privacy Policy Source: https://openrows.io/privacy Effective October 26, 2026 This policy explains how Draggable ("we", "us") handles personal information when you use the hosted OpenRows service at [app.openrows.io](https://app.openrows.io), our sign-in service at auth.openrows.io, and this website (together, the "Service"). OpenRows is an open-source alternative to Airtable, released under the AGPL-3.0. ## The short version - Your workspace data is yours. We store it to run the Service and for nothing else. - We don't sell your data, we don't show ads, and we don't use ad trackers. - We don't use your content to train AI models. - You can ask us for a copy of your data or to delete it at any time. ## What we collect - **Account information.** When you sign up with an email and password, we collect your name, email address and a securely hashed password. When you sign in with GitHub or Google, we receive your name, email address and profile picture from that provider. We don't get your GitHub or Google password. - **Workspace content.** The bases, tables, records, files, comments and automations you and your collaborators create or upload. - **Billing information.** If you subscribe to a paid plan, payment is handled by Polar, our merchant of record. We receive your plan, billing status, and limited billing details such as your name, email and country, but not your full card number. - **Usage and technical data.** Server logs that include your IP address, browser type, the pages and API endpoints you request, timestamps and error reports. We also count usage (records, storage, automation runs, API calls) to apply plan limits. - **Cookies.** We use only sign-in cookies: the cookies needed to keep you signed in and secure your account. They last up to 30 days, and expire sooner if you are inactive for 7 days. They are strictly necessary, so we don't ask for consent. We don't use advertising or cross-site tracking cookies. On app.openrows.io these are `openrows_session` (your sign-in), `openrows_login` (a sign-in in progress, 30 minutes) and `openrows_share` (a password-protected share link you unlocked, 12 hours); our sign-in service at auth.openrows.io (Keycloak) sets its own sign-in cookies, and Cloudflare, which protects both, may set security cookies such as `__cf_bm`. The full list, with each cookie's purpose and lifetime, is in our [cookie inventory](https://github.com/Draggable/openrows/blob/main/docs/legal/cookies.md). ## How we use it - To provide, maintain and secure the Service, including signing you in and syncing your data with collaborators. - To enforce plan limits and process subscriptions. - To send essential emails such as invitations, password resets and important account or service notices. - To diagnose problems, prevent abuse and improve performance. - To comply with legal obligations. Where data protection laws such as the GDPR apply, we rely on performing our contract with you, our legitimate interest in running a secure and reliable service, and compliance with legal obligations. ## Who we share it with We share personal information only with service providers that help us run the Service, and only as needed: | Provider | Purpose | | --- | --- | | Servers we operate | Hosting the application and database (European Union) | | Cloudflare | DNS, network security, secure connections to our servers, and storage of file attachments and encrypted backups (Cloudflare R2, Western Europe) | | Polar | Payments, invoicing and tax as merchant of record | | GitHub and Google | Sign-in, only if you choose to sign in with them | If you use the Service for an organization, our [Data Processing Agreement](/dpa) (a draft under legal review) covers the personal data we process on your behalf and lists our subprocessors in full. We may also disclose information if required by law, to protect the rights and safety of our users or the public, or as part of a merger or acquisition, in which case this policy will continue to apply to your data. We never sell your personal information. ## Your workspace content We access your content only to provide the Service, to respond to your support requests, to investigate abuse or security issues, or when required by law. We don't use your content to train artificial intelligence or machine learning models. ## Retention We keep your account and workspace content for as long as your account is active. Items you delete go to the trash and are then permanently removed. When you delete your account, or ask us to, we delete your personal information and workspace content within 30 days, except where we must keep certain records (for example, billing records) to meet legal obligations. Encrypted backups are overwritten on a rolling basis within a further 30 days. Server logs are kept for up to 90 days. ## Your rights Wherever you live, you can ask us to: - give you access to the personal information we hold about you; - export your data in a portable format; - correct inaccurate information; - delete your account and data; - restrict or object to certain processing. Email [support@openrows.io](mailto:support@openrows.io) and we'll respond within 30 days. You also have the right to complain to your local data protection authority. ## Security We encrypt data in transit with TLS, hash passwords, isolate each workspace's data, and limit access to our systems. No system is perfectly secure, but we work hard to protect your information and will notify affected users of a breach as required by law. ## International transfers The hosted service stores your account and workspace content in the European Union: the application and database on our server there, and attachments and backups in Cloudflare R2 in the EU. Our providers may process data in countries other than your own. Where required, we rely on appropriate safeguards such as standard contractual clauses. ## Self-hosted OpenRows This policy covers only the hosted Service run by Draggable. If you use an OpenRows instance run by someone else, including one you host yourself, that operator is responsible for how your data is handled. ## Children The Service isn't intended for anyone under 16, and we don't knowingly collect information from them. If you believe a child has given us personal information, contact us and we'll delete it. ## Changes to this policy We may update this policy from time to time. If we make significant changes, we'll notify you by email or in the app before they take effect. The effective date at the top shows when it last changed. ## Contact Questions or requests about privacy: [support@openrows.io](mailto:support@openrows.io). # Terms of Service Source: https://openrows.io/terms Effective October 25, 2026 These terms govern your use of the hosted OpenRows service at [app.openrows.io](https://app.openrows.io), our sign-in service at auth.openrows.io, and this website (together, the "Service"), operated by Draggable ("we", "us"). By creating an account or using the Service, you agree to these terms and to our [Privacy Policy](/privacy). If you use the Service on behalf of an organization, you confirm you're authorized to accept these terms for it. ## Open source and the hosted Service The OpenRows source code is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). Your rights to use, modify and distribute the code come from that license, not these terms. These terms cover only the hosted Service that we run. Some hosted-only features may be offered under a separate commercial license. ## Your account - You must be at least 16 years old to use the Service. - Provide accurate information and keep your sign-in details secure. You're responsible for activity under your account. - If you sign in with GitHub or Google, your use of those providers is also subject to their terms. - Tell us promptly at [support@openrows.io](mailto:support@openrows.io) if you suspect unauthorized access. ## Your content You own the data, files and other content you put into the Service. You give us a limited license to host, store, copy, process and display it only as needed to run the Service for you and your collaborators. This license ends when you delete the content or your account, apart from the time it takes to clear backups. You're responsible for having the rights to the content you upload and for who you share it with. ## Acceptable use Don't use the Service to: - break the law or infringe anyone's rights, including privacy and intellectual property rights; - store or share malware, or content that is abusive, harassing, or exploits minors; - send spam or run phishing or fraud; - probe, overload or disrupt the Service or other users, or bypass plan limits or security measures; - access the Service through automated means except through our documented API, within its rate limits; - resell the hosted Service without our written permission. We may remove content or suspend accounts that break these rules. ## Plans and billing - **Free plan.** The free plan includes usage limits listed on our [pricing page](/pricing). We may change free plan limits and features, and we'll give reasonable notice of changes that affect existing workspaces. - **Paid plans.** Paid subscriptions are sold and billed by Polar, our merchant of record, and are also subject to Polar's terms. Subscriptions renew automatically each billing period until cancelled. Prices are shown before you buy and may include applicable taxes. - **Price changes.** We'll notify you at least 30 days before a price change affects your subscription. - **Cancellation.** You can cancel at any time. Your plan stays active until the end of the current billing period, and then your workspace moves to the free plan. Except where the law requires otherwise, payments are non-refundable. - **Going over limits.** If you exceed your plan's limits, we may restrict some actions until you upgrade or reduce usage. We won't delete your data because you went over a limit. ## Availability and changes We work to keep the Service reliable, but it's provided "as is" and "as available" without warranties of any kind, to the fullest extent permitted by law. We may add, change or remove features. Keep your own exports or backups of important data. ## Termination You can stop using the Service and delete your account at any time. We may suspend or end your access if you seriously or repeatedly break these terms, if your use creates risk or legal exposure for us or others, or if we discontinue the Service. Unless the termination is for abuse or a legal reason, we'll give you notice and a reasonable chance to export your data. ## Limitation of liability To the fullest extent permitted by law, Draggable won't be liable for any indirect, incidental, special, consequential or punitive damages, or for lost profits, revenue or data. Our total liability for any claim related to the Service is limited to the greater of the amount you paid us in the 12 months before the claim or US$50. Nothing in these terms limits liability that can't be limited under applicable law. ## Indemnity If you use the Service for business, you agree to cover our reasonable costs from third-party claims arising from your content or your breach of these terms. ## Changes to these terms We may update these terms. If we make significant changes, we'll notify you by email or in the app at least 14 days before they take effect. Continuing to use the Service after that means you accept the updated terms. ## General These terms are governed by applicable law. If any part is found unenforceable, the rest stays in effect. Not enforcing a provision isn't a waiver. You may not transfer these terms without our consent; we may transfer them as part of a merger, acquisition or sale of assets. ## Contact Questions about these terms: [support@openrows.io](mailto:support@openrows.io). # Data Processing Agreement Source: https://openrows.io/dpa Effective when accepted; draft of October 28, 2026 > **Draft.** This agreement is published for transparency and is still under legal review. It isn't in force yet and may change before it is. If your organization needs a signed data processing agreement now, email [support@openrows.io](mailto:support@openrows.io). This Data Processing Agreement ("DPA") forms part of the [Terms of Service](/terms) between Draggable ("we", "us", the "Processor") and the customer that uses the hosted OpenRows service at [app.openrows.io](https://app.openrows.io) for an organization ("you", the "Controller"). It applies when we process personal data on your behalf in providing the Service, and where data protection laws such as the EU and UK GDPR apply to that processing. Our [Privacy Policy](/privacy) describes the personal data we handle as a controller ourselves, such as your account and billing details. ## 1. Subject matter, duration and purpose - **Subject matter:** hosting, storing and processing the content you and your collaborators put into the Service (bases, tables, records, files, comments, forms and automations) so that the Service works. - **Duration:** for as long as you use the Service, then until the data is deleted as described in section 8. - **Nature and purpose:** storage, retrieval, display, sharing with the people you choose, search, computation (formulas, automations), export, backup and deletion, only to provide, secure and support the Service. - **Categories of data subjects:** your users and collaborators, and anyone whose personal data you put into the Service, such as your customers, contacts or form respondents. - **Types of personal data:** whatever you put into the Service (names, email addresses and any other data in your records, files and comments), and the account details of your users (name, email address, profile picture, sign-in times and IP addresses in logs). - **Special categories:** the Service isn't designed for special categories of personal data or data about criminal convictions. If you store them, you are responsible for having a lawful basis and for deciding that the security measures below are appropriate. ## 2. Our obligations We will: - process personal data only on your documented instructions, which are these terms, your use of the Service's features and settings, and any other written instructions we agree, unless the law requires otherwise (we will then tell you first, unless the law forbids it); - tell you if we think an instruction breaks data protection law; - make sure everyone who can access the personal data is bound by confidentiality; - apply the security measures in Annex 2; - not sell the personal data, not use it for advertising, and not use your content to train artificial intelligence or machine learning models. ## 3. Subprocessors You authorize us to use the subprocessors listed in Annex 1. We have a written agreement with each of them with data protection obligations no less protective than this DPA, and we remain responsible for their work. We will announce a new or replaced subprocessor at least 30 days in advance, by email to workspace owners and in the app. You may object on reasonable data protection grounds within that time; if we can't resolve the objection, you may end the affected part of the Service and we will refund any prepaid fees for the remaining period. ## 4. Data location and international transfers **Data location.** Today the Service has one region, the European Union: the application, its database, file attachments and encrypted database backups are hosted and stored in the European Union (our server, and Cloudflare R2 in the EU). Traffic passes through Cloudflare's global network in transit, and the billing details in Annex 1 are processed by Polar in the United States and the European Union. When we add a region (a United States region is planned), Enterprise workspaces will choose theirs when they are created; a workspace's records, files and backups then stay in that region, and a workspace never moves between regions without your instruction. Where personal data is transferred from the European Economic Area, the United Kingdom or Switzerland to a country without an adequacy decision, the Standard Contractual Clauses adopted by the European Commission in Implementing Decision (EU) 2021/914 apply, Module Two (controller to processor) between you and us, and Module Three (processor to processor) between us and our subprocessors, with the UK International Data Transfer Addendum and the Swiss adjustments where required. For the clauses' options: clause 7 (docking) applies, clause 9 option 2 (general authorization, with the notice in section 3), clause 11's optional language doesn't apply, clauses 17 and 18 choose the law and courts of Ireland, and the annexes are filled in by section 1 and Annexes 1 and 2 of this DPA. If the clauses and this DPA conflict, the clauses prevail. ## 5. Assistance Taking into account the nature of the processing, we will help you respond to requests from data subjects (access, correction, deletion, portability, restriction and objection). Most can be done in the Service itself: you can edit or delete any record, export a base or your account's data, and delete your account or workspace. We will also help you with security, breach notifications, data protection impact assessments and consultations with supervisory authorities, as far as they concern our processing. ## 6. Personal data breaches If we become aware of a breach of security leading to the accidental or unlawful destruction, loss, alteration, unauthorized disclosure of, or access to, personal data we process for you, we will notify the workspace owners without undue delay and **no later than 48 hours** after becoming aware of it. The notice will describe, as far as we know them at the time, the nature of the breach, the categories and approximate number of data subjects and records concerned, its likely consequences, the measures taken or proposed, and a contact for more information. We will update it as we learn more, and take reasonable steps to contain the breach and reduce its effects. ## 7. Audits We will make available the information needed to show that we meet this DPA: this page, our security measures (Annex 2), our operational runbooks and the results of our monthly compliance self-check (log retention, backup expiry, the deletion jobs, and the absence of tracking cookies and third-party trackers). If that isn't enough to meet your obligations or a supervisory authority's request, you may audit our compliance once a year, at your cost, with 30 days' notice, under a confidentiality agreement, in a way that doesn't affect our other customers' data or the Service's security. ## 8. Deletion and return You can export your data at any time (each base as a ZIP of CSV and JSON files with its attachments, and your account's data). When you delete records, tables, bases or your workspace, they go to the trash and are permanently deleted after 30 days, or at once when you delete them permanently. When you delete your account or workspace, or after the Service ends for you, we delete the personal data within 30 days, except where the law requires us to keep it. Encrypted backups are overwritten on a rolling basis within a further 30 days, and server logs are kept for up to 90 days. ## 9. Liability and precedence Each party's liability under this DPA is subject to the limits in the Terms of Service, except where data protection law doesn't allow them. If this DPA and the Terms of Service conflict, this DPA prevails for the processing of personal data. ## Annex 1: Subprocessors | Subprocessor | What they do for the Service | Personal data | Location | | --- | --- | --- | --- | | Ark, our dedicated server, operated by Draggable (hosting provider to be confirmed before this DPA leaves draft) | Runs the application, its database and the sign-in service | All Service data | European Union | | Cloudflare, Inc. | DNS, network security and the encrypted connection to our server; storage of file attachments and of encrypted database backups (Cloudflare R2); the CAPTCHA on public forms and protected share links (Turnstile) | Everything in transit; attachments; encrypted backups; IP addresses | Global network; R2 storage in Western Europe | | Polar | Payments, invoicing and tax for paid plans, as merchant of record | Billing contact's name, email address and country; subscription details | United States and European Union | | Liquid Hosting (liquid.hosting) | The mail server that sends the Service's emails: invitations, notifications, sign-in and password reset emails | Recipient's name and email address; the email's content | To be confirmed | Sign-in with GitHub or Google happens only if a user chooses it; those providers act under their own terms, not as our subprocessors. ## Annex 2: Security measures - **Encryption in transit.** Every connection to the Service uses TLS through Cloudflare, and our server receives traffic only through an outbound, encrypted tunnel: it has no open inbound web ports. Its own services listen on its loopback interface only. - **Encryption at rest.** Database backups are encrypted with [age](https://age-encryption.org) before they leave the server, and the decryption key isn't stored with them. Cloudflare R2 encrypts stored files and backups at rest. - **Tenant isolation.** Each workspace's data is isolated in the database with row-level security, and the application connects with a database role that can't bypass it. Every request is authorized against the user's role, and automated tests check that one workspace can never read another's data. - **Access control.** Roles per workspace and base (owner, creator, editor, commenter, read-only), per-table and per-field edit permissions, personal access tokens limited by scope and by base, and share links with optional passwords, expiry and email-domain limits. Passwords are hashed by our sign-in service (Keycloak), which limits failed sign-in attempts. - **Administrative access.** Only Draggable's operator has access to the server, by SSH key. The sign-in service's administration console isn't reachable from the internet and needs a one-time code. Our support console needs a recent sign-in, and every action in it, including viewing a workspace as its user for support, is recorded in an audit log. - **Application security.** A strict Content-Security-Policy and other security headers, rate limits on the API, sign-in and public forms, request size limits, sanitized rich text, and a hardened client for any request to a URL a user chose (webhooks, automations, imports), which refuses private and internal addresses. - **Logging.** Logs leave out request bodies, cookies, query strings and the secret tokens in links, and are deleted after at most 90 days. - **Backups and recovery.** Nightly encrypted backups of every database, kept for 30 days, with a tested restore procedure. - **Monitoring.** Health, error-rate, backup and job monitoring with alerts to the operator, and external uptime checks at [status.openrows.io](https://status.openrows.io). - **Compliance self-check.** A monthly automated check that log retention, backup expiry and the deletion jobs work as promised, and that our website and app set no non-essential cookies and load no third-party trackers. A failure alerts the operator. - **Development.** Every change must pass the automated tests (including authorization, tenant isolation and security tests) before it is deployed. - **Deletion.** Deleted accounts, workspaces and trash are removed by automated jobs within the periods in section 8. # Getting started Source: https://openrows.io/docs/getting-started Sign in, find your way around workspaces and bases, create your first base and learn the shortcuts that make OpenRows fast. ## Sign in Open OpenRows in your browser and pick one of the buttons on the sign-in page: - **Sign in** if you already have an account. - **Create account** to sign up. - **Continue with Google**, **Continue with GitHub** (or another provider) when the server offers them. OpenRows needs a verified email address. If you see "Please verify your email address first", open the confirmation email, then sign in again. To sign out, open the account menu at the bottom of the sidebar and choose **Sign out**, or use the button in **Settings**. ## Workspaces A workspace holds bases and the people who work on them. The first time you sign in, OpenRows creates a personal workspace for you, named after you (for example "Ada's workspace"), and makes you its owner. The workspace switcher at the top of the sidebar lists every workspace you belong to. From it you can also open: - **Members**: who is in the workspace, pending invitations and **Invite people**. See [Sharing and permissions](https://openrows.io/docs/sharing). - **Usage and plan**: what the workspace uses. On the hosted service, your plan's limits apply (see https://openrows.io/pricing); self-hosted instances have no limits. - **Billing**: owners only, on the hosted service. - **New workspace**: create another workspace, which you own. When someone invites you, the email links to an invitation page. Choose **Accept invitation** to join the workspace or base. If you were invited to a single base, the workspace shows under "Bases shared with you" and you see only that base. ## The home page Home greets you and shows the ways to start a base, then every base you can open, grouped by workspace. Each workspace heading links to that workspace's own page. Creators and owners also see **Trash** and **New base** next to the heading. The top bar has **Search bases** (opens the command palette), **New base** and the notification bell. ![Home: the greeting, the ways to start a base and the templates](images/getting-started-home.webp) ## Create a base A base is a set of related tables, like a spreadsheet file that knows what each column holds. You need the creator or owner role in a workspace to create bases there. ### Start from scratch Choose **Start from scratch** (or **New base**), give the base a name and choose **Create base**. The base opens with one table, "Table 1", that has three fields: Name, Notes and Status (Todo, In progress, Done), and a grid view. ### Import a file Choose **Import a file** and pick a CSV file (comma, semicolon, tab or pipe separated) of up to 50 MB. OpenRows creates a base named after the file and opens the import dialog, where you choose a field type for each column. Details are in [Bases, tables and records](https://openrows.io/docs/bases-and-tables#csv-import). Excel files aren't supported yet. ### Import from Airtable Choose **Import from Airtable**, paste an Airtable personal access token with the `data.records:read` and `schema.bases:read` scopes, choose **Find bases**, tick the bases you want and start the import. Each Airtable base becomes a new base with its tables, fields, records, linked records, attachments, and grid and kanban views. The token is used only for that import and deleted when it ends. See [Moving from Airtable](https://openrows.io/docs/airtable-migration). ### The demo base The "Product Roadmap (demo)" workspace holds a sample base with five tables (Features, Epics, Releases, Customer feedback and Team) and 2,000 records. It is added by the person who runs your OpenRows server, not from the app; see [Self-hosting](https://openrows.io/docs/self-hosting). ## The app shell Inside a base, the sidebar shows: - The base menu (click the base name): **All bases**, **Trash**, **Duplicate base**, **Snapshots**, **Webhooks**, **Export base (ZIP)** and **Delete base**. Which items you see depends on your role. - **Tables**: click to open a table; double-click to rename it. The "..." menu on a table has **Rename**, **Move up**, **Move down**, **Edit permissions** and **Delete table**. **Add table** adds a new one. - **Views**: the views of the open table, grouped by section, with your personal views apart. See [Views](https://openrows.io/docs/views). ![A base: the sidebar with its tables and views, the top bar and a table's grid view](images/getting-started-grid.webp) The top bar shows where you are (base / table / view), the avatars of others on the same view, a live-updates indicator ("Live", "Reconnecting…" or "Offline"), **Automations** and **Share**. Changes made by others appear as they happen. If the connection drops, a banner says so and the view catches up when you are back online. On narrow screens the sidebar becomes a drawer: tap the menu button in the top bar to open it. On medium screens it collapses to a rail of icons. ## Command palette ![The command palette over a grid view, filtered to "view"](images/getting-started-command-palette.webp) Press **Ctrl K** (**Cmd K** on a Mac) anywhere, or click **Search & commands** in the sidebar. Type to filter, then press Enter. The palette lists: - **Tables** of the open base and **Bases** you can open. - **Create view…** (on a table, for editors and up): pick a view type, and turn on **Only me (personal view)** for a personal one. Backspace in the empty search goes back. - **Create a base**, **Go to home**, **Settings**, and **Switch to dark mode** or **Switch to light mode**. ## Keyboard basics | Keys | What they do | | --- | --- | | Ctrl/Cmd K | Open or close the command palette | | Arrow keys, Tab, Shift+Tab | Move between cells in the grid | | Ctrl/Cmd + arrow | Jump to the first or last row or column | | Home, End, Page Up, Page Down | First or last column, one screen up or down | | Shift + arrow or click | Extend the selection | | Enter or F2 | Edit the selected cell (or just start typing) | | Esc | Cancel an edit | | Delete or Backspace | Clear the selected cells | | Space | Expand the selected record | | Ctrl/Cmd C, X, V | Copy, cut and paste cells | | Ctrl/Cmd D | Fill down | | Ctrl/Cmd Z, Ctrl/Cmd Shift Z or Ctrl/Cmd Y | Undo, redo | | Ctrl/Cmd F | Find in the view | | Up/Down or J/K | Previous or next record in the expanded record | | Ctrl/Cmd Enter | Send a comment | More in [Bases, tables and records](https://openrows.io/docs/bases-and-tables#edit-in-the-grid). ## Dark mode OpenRows follows your system's light or dark setting. To choose yourself, open **Settings** and pick **System**, **Light** or **Dark** under **Appearance**. The account menu and the command palette also switch between light and dark mode. The choice is stored in your browser. ## Settings Open **Settings** from the sidebar or the account menu. It has: - **Account**: your name and email, and **Sign out**. - **Appearance**: the theme. - **Email notifications**: what is emailed and when (see below). - **Your data**: **Export my data** downloads a ZIP of your profile, memberships, the comments you wrote and every base of the workspaces you own. - **Developer: personal access tokens**: tokens for scripts and integrations that call the [REST API](https://openrows.io/docs/api) or the [MCP](https://openrows.io/docs/api/mcp) endpoint as you. **Create token** lets you limit a token to scopes and to chosen workspaces and bases; the token is shown once. - **Delete account**. ## Notifications The bell in the top bar shows a count of unread notifications. You get one when someone: - mentions you in a comment, - replies on a record you commented on, - adds you to a user field that notifies. Click a notification to open the record (at the comment, for mentions and replies). **Mark all as read** clears the count. Notifications older than 90 days are removed. Under **Settings**, **Email notifications** sets **What** is emailed (**All notifications**, **Only mentions** or **Nothing**) and **When** (**Right away**, **Hourly digest** or **Daily digest**). Right away sends at most one email per record every 10 minutes. The bell always shows everything, whatever you choose for email. Notification emails have a one-click unsubscribe link. ## Trash Deleting a base, table, field, view or record moves it to the trash, where it stays for 30 days before it is removed for good. - **Deleted bases**: the **Trash** button on Home (next to the workspace) or in the sidebar, for creators and owners. - **Deleted tables, fields, views and records**: **Trash** in the base menu, for editors and up. Choose **Restore** to bring an item back. Creators and owners can also delete items permanently. See [Bases, tables and records](https://openrows.io/docs/bases-and-tables#trash-and-restore). ## Snapshots A snapshot is a point-in-time copy of a whole base. OpenRows takes one every day for bases that changed, and you can take one any time with **Snapshot now** in the base menu's **Snapshots** dialog (creators and owners). Restoring a snapshot makes a new base and leaves the original alone. See [Bases, tables and records](https://openrows.io/docs/bases-and-tables#base-snapshots). ## Install the app and work offline OpenRows can be installed as an app from the browser: **Install OpenRows** in Chrome or Edge's address bar or menu, or **Share → Add to Home Screen** on an iPhone or iPad. It opens in its own window with the rose icon. Pages you open while online are kept on your device, so when the connection drops you can still read the views you visited last. A banner says **Offline, read-only**: editing comes back as soon as you're online again. Only your own data is kept, and signing out deletes it from the device, so sign out on a shared computer. On a phone, swipe right from the left edge of the screen (or tap the menu button) to open the sidebar, and tap a record to open it full screen. ## Next steps - [Bases, tables and records](https://openrows.io/docs/bases-and-tables) - [Fields](https://openrows.io/docs/fields) - [Views](https://openrows.io/docs/views) - [Sharing and permissions](https://openrows.io/docs/sharing) - [Forms](https://openrows.io/docs/forms) - [Automations](https://openrows.io/docs/automations) # Bases, tables and records Source: https://openrows.io/docs/bases-and-tables How to organize data in bases and tables, edit records in the grid, comment, follow a record's history, import, export, restore and snapshot. ## Bases A base is a set of related tables that can link to each other, much like an Airtable base. Bases live in a workspace, and everyone in the workspace gets their workspace role in every base. You can also invite people to a single base; see [Sharing and permissions](https://openrows.io/docs/sharing). To create a base, see [Getting started](https://openrows.io/docs/getting-started#create-a-base). Click the base name at the top of the sidebar for the base menu: | Item | What it does | Who sees it | | --- | --- | --- | | **All bases** | Back to Home | Everyone | | **Trash** | Deleted tables, fields, views and records | Editors and up | | **Duplicate base** | Copy the base, with or without its records; see [Duplicate a base](#duplicate-a-base) | Creators and owners | | **Snapshots** | Point-in-time copies of the base | Creators and owners | | **Webhooks** | Send changes to other services; see [Webhooks](https://openrows.io/docs/api/webhooks) | Creators and owners | | **Save as template** | Download the base as a template file; see [Templates](#templates) | Creators and owners | | **Export base (ZIP)** | Download everything you can read | Everyone | | **Delete base** | Move the base to the workspace's trash | Creators and owners | ### Duplicate a base **Duplicate base** in the base menu makes a copy that works on its own: editing one never changes the other. Pick the copy's name, the workspace it goes to (any workspace where you're a creator or owner) and whether to **Copy records, links and attachments**; without them you get the structure only, like a template. The copy is made in the background and opens when it's ready. The copy has every table, field (links, lookups, rollups and formulas point at the copy's own tables and fields), view (filters, sorts, groups, colours, form settings) and automation. Automations start turned off; secret headers and Slack webhook URLs aren't copied, so enter them again before you turn one on. Share links, webhooks, collaborators, comments, revision history, snapshots and other people's personal views stay with the original. The copy's records and attachments count against the plan of the workspace you copy to. ### Templates **Start from a template** on Home (or in the **New base** dialog) opens the template gallery: a product roadmap, a sales CRM, a project tracker, a content calendar, a bug tracker, event planning, inventory, a hiring pipeline, OKRs and customer feedback. Pick a category, open a template to preview each of its tables with their fields and first records, then **Use template**: you get a new base with the tables, fields, views and sample records, in the workspace you're on (where you're a creator or owner). Edit or delete the sample records as you like. A template's automations come turned off. The same templates are listed at [openrows.io/templates](https://openrows.io/templates). **Save as template** in the base menu downloads the base as a template file (`.openrows-template.json`): its tables, fields, the views everyone sees, automations without their secrets, and up to 1,000 records per table (5,000 in all). Files, collaborator cells and computed values aren't in it; computed fields are worked out again when the template is used. On your own server, add the file to the built-in templates (`packages/shared/src/templates/`) to offer it in the gallery. ### The AI assistant When your workspace has an AI provider (Settings → AI, or the server's own), creators and owners see **Assistant** in a base's top bar. Describe what you want, like "add a Total formula to Orders and a kanban view by status", and **Draft plan**: the assistant proposes new tables, fields (formulas included), views and automations, and lists each one with its fields and settings. Every formula it suggests is checked; one that doesn't work is shown with the problem and left unticked. Untick anything you don't want and click **Apply**: nothing changes in the base until then, and the changes are made with your own permissions, all together or not at all. Automations it creates start turned off. The assistant sees the names and types of the base's tables and fields, not your records. Tick **Include a few records from each table** to send up to three records per table along with the request, which helps it pick formats and options. On Home, **Describe it** does the same for a new base: say what you want to track, check the plan and the base's name, then **Create base**. ## Tables A table is a list of records with the same fields, like a sheet in a spreadsheet. The sidebar lists the base's tables. Creators and owners can: - **Add table**: adds "Table 2" (and so on) with the fields Name, Notes and Status (Todo, In progress, Done) and a "Grid view". - Rename a table: double-click it, or choose **Rename** in its "..." menu. - Reorder tables with **Move up** and **Move down**. - Limit who can add, edit or delete records with **Edit permissions**; see [Sharing and permissions](https://openrows.io/docs/sharing). - **Delete table**: moves it to the base's trash. A base keeps at least one table. Every table has a primary field, the first column, which names each record in links, the trash and notifications. Fields are covered in [Fields](https://openrows.io/docs/fields), views in [Views](https://openrows.io/docs/views). ## Records A record is one row of a table. Editors and up add, edit and delete records; commenters and read-only members can read them, and commenters can also comment. ### Add and delete records - Click **+** (Add record) below the last row of a grid, or just paste rows past the end. - Right-click a selection for **Copy**, **Fill down**, **Clear cells**, **Duplicate** and **Delete** (with the number of records selected). - Deleted records go to the trash. A message with **Undo** appears right after. The bar at the bottom of the grid shows how many records the view holds. ### Edit in the grid Select a cell and start typing to replace its value, or press **Enter** or **F2** to edit it. Double-clicking a cell also opens its editor. | Keys | In the grid | | --- | --- | | Arrow keys | Move one cell | | Ctrl/Cmd + arrow | Move to the first or last row or column | | Tab, Shift+Tab | Next or previous cell, wrapping to the next row | | Home, End | First or last column (with Ctrl/Cmd: first or last record too) | | Page Up, Page Down | Move by a screen | | Shift + arrow, Shift + click | Select a range | | Enter, F2 | Edit the cell | | Enter (while editing) | Save | | Tab (while editing) | Save and move to the next cell | | Esc | Cancel the edit, or shrink a range to one cell | | Delete, Backspace | Clear the selected cells | | Space | Expand the record (on a checkbox: toggle it; Shift+Space expands) | | Ctrl/Cmd D | Fill down | | Ctrl/Cmd F | Find in the view | In a long text cell, Enter adds a line; press **Ctrl Enter** (or Tab) to save. Click a row number to select the whole row, and Shift + click to select several. You can drop files from your computer onto an attachment cell. Computed fields (formulas, lookups, rollups, counts and the like) are read-only. A cell that the server refuses to save shows an error in place. ### Copy, paste and fill - **Copy** (Ctrl/Cmd C) puts the selected cells on the clipboard as text that spreadsheets, Airtable and OpenRows understand. Ctrl/Cmd X cuts. - **Paste** (Ctrl/Cmd V) starts at the top-left cell of your selection. Each pasted value is converted to the target field's type, and values that don't fit are skipped. Read-only and computed columns are skipped too. - If you paste one cell (or a block) into a larger selection that is a whole multiple of it, the paste repeats to fill the selection. - If the paste runs past the last record, OpenRows asks before adding the new records (**Add records**). - Creators and owners can paste options that a single or multiple select field doesn't have yet; they are added to the field. - **Fill down** (Ctrl/Cmd D, the context menu or the fill handle) copies the top row of the selection into the rows below it. Values are copied, not continued as a series. ### Undo and redo Press **Ctrl/Cmd Z** to undo and **Ctrl/Cmd Shift Z** or **Ctrl/Cmd Y** to redo. Undo covers cell edits, pastes, fills, clears, new records, duplicates and deletions, for your last 100 actions. ## Expand a record Press **Space** on a row, or hover the row number and click the expand icon, to open the record with every field. The record's address goes into the page URL, so you can share it. - **Previous record** and **Next record** buttons, or the **Up**/**Down** arrow keys (also **K**/**J**), move through the view's records. - **Copy record link** copies a link that opens this record. - **Show as a side panel** keeps the record beside the grid while you work; **Show as a modal** switches back. - **More record actions** has **Copy record link** and **Delete record**. - Fields hidden in the current view appear under **Hidden in this view**. The record has three tabs, **Fields**, **Comments** and **Activity**. On a wide screen, comments and activity sit beside the fields. ## Comments Commenters and up can comment on a record; read-only members can read the comments. - Write in the box at the bottom and press **Ctrl Enter** (Cmd Enter on a Mac) or **Send**. Enter adds a line. - Type **@** to mention someone. Only people with access to the base are suggested; use the arrow keys and Enter or Tab to pick one. Mentioned people get a notification. - Comments take basic Markdown formatting, up to 10,000 characters and 50 mentions. - React with one of six emoji reactions. - **Comment actions** lets you **Edit comment** (your own) or delete it. You can delete your own comments; creators and owners can delete anyone's. - Each record shows its newest 50 comments; **Load earlier comments** shows more. A badge on the row number counts a record's comments. ## Activity and revision history The **Activity** tab lists the record's history, oldest first: who created it, every change to its cells (before and after) and its comments. Submissions through a public form show "Form" as the author. Several edits to the same cells by the same person within a minute are merged into one entry. Fields hidden from you are left out. - **Restore this value** (next to a changed cell) sets that one field back to the value it had right after the change. - **Restore record to this point** sets the whole record back to how it was right after that change. A restore is a normal edit: it shows up in the history, and table and field edit permissions still apply. On a self-hosted server, history is kept for 365 days unless the server sets another period. On the hosted service, your plan's limits apply (see https://openrows.io/pricing). ## CSV import You can import a CSV file into a new base from Home (**Import a file**), or into an existing table: in a grid view, open the toolbar's **More view actions** menu and choose **Import CSV…** (editors and up). 1. **Choose a CSV file** of up to 50 MB (at most 250,000 rows and 500 columns). Commas, semicolons, tabs and pipes are detected. 2. Tick or untick **First row is the header**. 3. For each column, choose an existing field under **Import into**, **New field** (with a name and a type; creators and owners), or **Don't import**. A preview shows the first values. 4. Creators and owners can keep **Add missing options to select fields** on, so new select values become options. 5. Choose **Import**. A progress bar follows the import. Each row becomes a record. Values that can't be read for their field are left empty and counted in the summary. One import runs per table at a time. ## CSV export In a grid view's **More view actions** menu, choose **Download CSV**. The file holds the view as you see it: its filter, sort and grouping order, its visible fields in column order, and any find term or unsaved toolbar changes. Cells are written as display text, ready for a spreadsheet, and values that start with characters like `=` or `+` are protected so a spreadsheet won't run them as formulas. ## Export a base or your data **Export base (ZIP)** in the base menu downloads everything you can read in the base: - `schema.json`: the base, its tables, fields and the views you can see. - A CSV file per table (re-importable with the CSV import) and a JSON lines file per table with record ids and values. - The attachments, with a list of them. Other people's personal views are left out. The export reads the tables one after the other, so for an exact point-in-time copy use a snapshot. To download all your own data, go to **Settings**, **Your data**, **Export my data**. Its ZIP holds your profile, memberships, the comments you wrote and a base export of every base in the workspaces you own. Each person can start 10 exports every 10 minutes. ## Trash and restore Deleting a base, table, field, view or record moves it to the trash for 30 days. Each item shows when it was deleted and how many days are left. - **Base trash** (base menu, **Trash**): tabs for **Records**, **Fields**, **Views** and **Tables**. On the Records tab, pick the table first. - **Workspace trash** (**Trash** on Home or in the sidebar): deleted bases, with everything in them. Creators and owners. Choose **Restore** to put an item back where it was. Who can restore: | Item | Restore | Delete permanently | | --- | --- | --- | | Records | Editors and up | Creators and owners | | Views | Editors and up | Creators and owners | | Fields, tables | Creators and owners | Creators and owners | | Bases | Workspace creators and owners | Workspace creators and owners | Deleting permanently can't be undone. Other people's personal views stay private in the trash. ## Base snapshots A snapshot is a point-in-time copy of a whole base: its tables, fields, views, records, links and attachments. Open **Snapshots** in the base menu (creators and owners). - OpenRows takes an automatic snapshot every day of each base that changed since its last one. - **Snapshot now** takes a manual one. - **Restore to new base** creates a copy named "Base name (restored)" in the same workspace. The original base stays as it is. - Delete a snapshot with its delete button. Snapshots are kept for 30 days by default. Workspace owners can change this under **Keep for** (1 to 365 days). A base keeps at most 100 snapshots; the oldest automatic ones go first. On the hosted service, your plan's limits apply (see https://openrows.io/pricing). # Fields Source: https://openrows.io/docs/fields Every field type in OpenRows, its options, how type conversions work, links, lookups and rollups, formulas and their functions, and per-field edit permissions. ## Adding and editing fields Click the **Add field** button (the "+" at the right end of the grid's header) to create a field. The field editor opens as a popover: give the field a name, pick a type, set its options, and press **Create field** (or Ctrl/Cmd Enter). The type picker groups types into **Basic**, **Choice**, **Contact**, **People & links** and **Computed**. Type in **Search types…** to filter, use the arrow keys and press Enter to pick one. Owners and creators get these actions in a column header's menu: | Menu item | What it does | | --- | --- | | Edit field | Opens the field editor | | Rename field | Opens the editor with the name selected | | Change field type | Opens the editor on the type picker | | Edit description | Opens the editor on the description | | Edit permissions | Who may edit the field's cells (see below) | | Duplicate field | Opens the editor prefilled with "Name copy", the same type, options and description. Cell values are not copied | | Insert left / Insert right | Creates a new field next to this one | | Delete field | Moves the field to the trash after a confirmation, with an **Undo** toast | Depending on your role, the menu also has Move left/right, Freeze up to this field, Select column, Reset width and Hide field. Hiding is a view setting: see [Views](https://openrows.io/docs/views). The primary field (the first column, which names each record) can't be deleted or hidden, and it can't be a link, formula, button, lookup, rollup or count. You can't delete a field that a lookup, rollup, count or formula reads: change or remove those fields first. Deleted fields can be restored from the trash: see [Bases, tables and records](https://openrows.io/docs/bases-and-tables). ## Field types | Type | Category | What it stores | Options and notes | | --- | --- | --- | --- | | Single line text | Basic | One line of text, up to 100,000 characters | Line breaks become spaces | | Long text | Basic | Multi-line text, up to 100,000 characters | **Enable rich text formatting**: headings, bold, italic, strikethrough, inline code, code blocks, quotes, bulleted, numbered and check lists, and links (http, https and mailto only). Stored as Markdown | | Attachment | Basic | Files | Upload in the cell or the expanded record; images get thumbnails | | Checkbox | Basic | On or off | **Icon** (check, star, heart, thumbs up, flag, dot) and **Color** | | Number | Basic | A decimal number | **Decimal places** (0 to 8), **Allow negative numbers** | | Currency | Basic | An amount in one currency | **Currency code** (ISO code such as USD, EUR), decimal places (default 2), allow negative numbers | | Percent | Basic | A percentage | Decimal places, allow negative numbers. Typing `50` or `50%` stores 50% | | Date | Basic | A calendar day, no time | **Date format**: Local, Friendly (October 25, 2026), US (10/25/2026), European (25/10/2026), ISO (2026-10-25) | | Date and time | Basic | A point in time | Date format, **Time format** (12 hour or 24 hour), **Time zone** (default UTC; times you type are read in it) | | Duration | Basic | A length of time | **Duration format**: h:mm, h:mm:ss, h:mm:ss.s, h:mm:ss.ss, h:mm:ss.sss. A plain number is read as minutes in h:mm, as seconds otherwise | | Rating | Basic | 1 to the maximum | **Maximum** (1 to 10, default 5), **Icon**, **Color** | | Barcode | Basic | A code and its symbology | EAN-13, EAN-8, UPC-A, UPC-E, Code 128, Code 39, ITF, QR and Data Matrix; check digits are verified. The cell shows the code's text | | Single select | Choice | One choice from a list | **Choices**: add, rename, recolor, reorder (drag, or Alt+Up/Down), remove. Up to 1,000 choices | | Multiple select | Choice | Any number of choices | Same choices editor. Pasted text is split on commas | | Email | Contact | An email address | Text that isn't an address is left empty | | URL | Contact | An http or https link, up to 2,048 characters | A bare domain gets `https://`; other schemes are refused | | Phone number | Contact | A phone number as typed | Digits, spaces, `+ - ( ) . /` and an optional extension | | User | People & links | Collaborators of the base | **Allow adding multiple collaborators** (up to 100 per cell), **Notify collaborators when they're added**. Removed members show as "Former member" | | Created by | People & links | Who created the record | Read-only | | Last modified by | People & links | Who last changed the record | Read-only | | Link to another record | People & links | Records of another table (or the same table) | See [Linked records](#linked-records) | | Autonumber | Computed | A unique, increasing number per record | Read-only | | Created time | Computed | When the record was created | Date format, time format, time zone. Read-only | | Last modified time | Computed | When any field of the record last changed | Date format, time format, time zone. Read-only | | Lookup | Computed | Values of a field in the linked records | See [Lookups, rollups and counts](#lookups-rollups-and-counts) | | Rollup | Computed | One value aggregated from the linked records | See [Lookups, rollups and counts](#lookups-rollups-and-counts) | | Count | Computed | How many records a link field links to | 0 when there are none | | Formula | Computed | The result of a formula over the record's fields | See [Formulas](#formulas) | | Button | Computed | A clickable button in each cell | **Button label** (default "Open") and **When clicked**: **Open a URL** (the URL comes from a formula; only http and https results work, and they open in a new tab) or **Run automations** (see [Automations](https://openrows.io/docs/automations)) | Read-only and computed fields can't be typed into; OpenRows fills them for you. ## Converting field types Change a field's type or options from **Change field type** or **Edit field**. Before you save, a conversion preview runs the conversion over the first 100 records and shows: - "All N values in N records convert without loss." when nothing changes, or - a warning when values would be cleared or changed (rounding, dropped choices, a removed choice still in use), ending with "This can't be undone.", and - examples as before → after, with cleared values shown as "Empty". Converting into a computed type (formula, lookup, rollup, count) discards the stored values, because the field fills its own. Things to know: - Values that can't be read as the new type become empty (for example text that isn't a date). - Converting to a select adds a choice for every value that doesn't match an existing one. - Converting to a link matches each value against the primary field of the linked table, ignoring case. Names that match nothing become new records in the linked table. - Converting a link to another type turns the linked records' names into values (text joins them) and moves the inverse link field to the trash. - Converting to text uses the values as they're displayed. ## Linked records A **Link to another record** field connects records across tables of the same base. Options: - **Link to table**: any table of the base. It can't be changed after the field is created. - **Allow linking to multiple records**: on by default; turn it off for one linked record per cell. - **Limit record selection to a view**: only records that a view of the linked table shows can be linked. This is enforced on every write, not only in the picker. Creating a link adds a matching field to the linked table, named after this table (with a number if that name is taken). Both sides always stay in sync. A link to the same table has no inverse field. Deleting either side moves both fields to the trash; restoring one brings back the pair with its links. Cells show the linked records' primary field values ("Unnamed record" when it's empty). You can paste or import names: they're matched against the linked table's primary field. Deleted records disappear from link cells and come back if the record is restored. ## Lookups, rollups and counts These fields read through a link field of the same table. If the table has none, the editor asks you to add a "Link to another record" field first. - **Lookup**: pick the **Linked record field**, then the **Field to look up** in the linked table. The cell lists each linked record's value, in link order. - **Rollup**: pick the linked record field and the **Field to roll up**, then an **Aggregation**: | Aggregation | Function | | --- | --- | | Sum | SUM | | Average | AVG | | Minimum | MIN | | Maximum | MAX | | Count numbers | COUNT | | Count non-empty values | COUNTA | | Count all values | COUNTALL | | All true | AND | | Any true | OR | | An odd number true | XOR | | Join with a separator | ARRAYJOIN (set the **Separator**) | | Unique values | ARRAYUNIQUE | | Non-empty values | ARRAYCOMPACT | | Concatenate | CONCATENATE | Sum, Average, Minimum and Maximum also have **Decimal places** (or Automatic). - **Count**: pick the linked record field. The cell is the number of linked records. Computed values update when the linked records change. A field can't read itself through a chain of lookups, rollups or formulas; OpenRows refuses a circular reference. After a large change, a spinning icon in the column header ("Updating values. Some may be out of date for a moment.") shows that values are still being recomputed. ## Formulas A formula field computes a value for each record from its other fields, like Airtable formulas. The editor highlights the formula, completes field names (type `{`) and functions (Ctrl Space lists everything), shows help for the function under the cursor, marks errors where they are, and previews the **Result type** and the result for the **First record** before you save. You can't save an empty or invalid formula. ### Syntax - Reference a field as `{Field name}`. A single-word name also works without braces (`Price * 2`). Inside braces, write `\}` for a `}` in a name. References survive renames. - Text goes in single or double quotes, with backslash escapes (`\n`, `\t`, `\"`). - `TRUE` and `FALSE` are booleans. Function names ignore case. - Operators, from tightest to loosest: `*` `/`, then `+` `-`, then `&` (joins text), then comparisons `=` `!=` (or `<>`) `<` `<=` `>` `>=`. Use parentheses to group. ``` IF({Status} = "Done", "Closed on " & DATETIME_FORMAT({Closed at}, "YYYY-MM-DD"), "Open") ``` A formula can be up to 10,000 characters, and parentheses and calls can nest up to 64 levels. ### Results | Result type | Shown as | Options | | --- | --- | --- | | Number | A number | **Decimal places** (Automatic or 0 to 8) | | Text | Text | | | Date | A date and time | **Time zone** (default UTC) | | Boolean (1 or 0) | 1 or 0 | | | List | Items joined with ", " | | A formula that fails for a record shows `#ERROR!`. Dates are computed in UTC unless you use `SET_TIMEZONE`. Formulas that call `NOW()` or `TODAY()` are refreshed every hour, not continuously; `TODAY()` is the current day in UTC. ### Functions OpenRows supports 84 functions. | Category | Functions | | --- | --- | | Text | ARRAYJOIN, CONCATENATE, ENCODE_URL_COMPONENT, FIND, LEFT, LEN, LOWER, MID, REPLACE, REPT, RIGHT, SEARCH, SUBSTITUTE, T, TRIM, UPPER, REGEX_MATCH, REGEX_EXTRACT, REGEX_REPLACE | | Logical | AND, OR, XOR, NOT, IF, SWITCH, BLANK, ERROR, ISERROR, TRUE, FALSE | | Numeric | ABS, AVERAGE, CEILING, COUNT, COUNTA, COUNTALL, EVEN, EXP, FLOOR, INT, LOG, MAX, MIN, MOD, ODD, POWER, ROUND, ROUNDDOWN, ROUNDUP, SQRT, SUM, VALUE | | Date and time | CREATED_TIME, DATEADD, DATESTR, DATETIME_DIFF, DATETIME_FORMAT, DATETIME_PARSE, DAY, HOUR, IS_AFTER, IS_BEFORE, IS_SAME, LAST_MODIFIED_TIME, MINUTE, MONTH, NOW, SECOND, SET_LOCALE, SET_TIMEZONE, TIMESTR, TONOW, FROMNOW, TODAY, WEEKDAY, WEEKNUM, WORKDAY, WORKDAY_DIFF, YEAR | | Lists | ARRAYCOMPACT, ARRAYFLATTEN, ARRAYUNIQUE, ARRAYSLICE | | Record | RECORD_ID | Notes: - Date units for DATEADD, DATETIME_DIFF, IS_SAME, TONOW and FROMNOW: `milliseconds` (`ms`), `seconds` (`s`), `minutes` (`m`), `hours` (`h`), `days` (`d`), `weeks` (`w`), `months` (`M`), `quarters` (`Q`), `years` (`y`), singular or plural. - DATETIME_FORMAT and DATETIME_PARSE use Airtable's format tokens, such as `YYYY-MM-DD` or `D MMMM YYYY h:mm A`. - WEEKDAY and WEEKNUM take an optional week start, "Sunday" or "Monday". WORKDAY and WORKDAY_DIFF take optional holidays. - LAST_MODIFIED_TIME() with field arguments gives when those fields last changed. - The REGEX functions don't support backreferences or lookaround. - Aggregates such as SUM and MAX accept lists (lookups, multiple selects) and skip text and blanks. ## Field descriptions Add a description under **Description** in the field editor (up to 2,000 characters), or with **Edit description** in the column menu. It shows as an info icon in the column header, with the description as its tooltip. ## Edit permissions per field Owners and creators can limit who may change a field's values with **Edit permissions** in its column menu. Under **Who can edit this field**, choose: - **Editors and up** (the default) - **Creators and up** - **Specific people**: pick roles and collaborators - **Nobody (read-only)** Everyone who can see the table still sees the field; this only limits editing. A restricted field shows a lock icon in its header. When a field is restricted, **Allow this field to be filled through forms** lets public forms set it on the records they create (see [Forms](https://openrows.io/docs/forms)). Table-level permissions (who may add, delete and edit records) also apply; both must allow a change. See [Sharing and permissions](https://openrows.io/docs/sharing). On the hosted service, edit permissions depend on your plan (see https://openrows.io/pricing); self-hosted instances have no limits. # Views Source: https://openrows.io/docs/views A view is one way of looking at a table: a grid, a board, a calendar, cards, a timeline, a nested list or a form, each with its own filters, sorts and layout. ## Creating and managing views A table's views are listed under **Views** in the base sidebar. To add one, click **Create view** and pick a type: **Grid**, **Kanban**, **Calendar**, **Gallery**, **Timeline**, **List** or **Form**. Tick **Only me** in the same menu to make the new view personal. The new view gets the type's name ("Kanban", "Calendar 2" and so on) and opens straight away. Open a view's **…** menu in the sidebar for: | Action | What it does | | --- | --- | | **Rename** | Renames the view (or double-click its name) | | **Duplicate** | Copies the view with its filters, sorts and layout | | **Share view** / **Share form** | Makes a public link to the view, or to a form view's form (owners and creators) | | **Lock view** / **Unlock view** | Stops others changing the view's setup (owners and creators) | | **Make personal** / **Make collaborative** | Changes who sees the view (only the person who made it) | | **Move up** / **Move down** / **Move to section** | Changes where the view sits in the sidebar; you can also drag it | | **Delete view** | Moves the view to the base's trash, where you can restore it | Editors and above can create, rename, duplicate, reorder and delete views. A table always keeps at least one collaborative view, so the last one can't be deleted or made personal. ### Sections Sections are named folders that group a table's views in the sidebar. Click **New section** under the views, then drag views into it or use **Move to section** in a view's menu. A section's **…** menu has **Rename section**, **Move up**, **Move down** and **Delete section**. Deleting a section keeps its views: they move out of the section. ### Collaborative, personal and locked views | Kind | Who sees it | Who can change its setup | | --- | --- | --- | | Collaborative (the default) | Everyone with access to the table | Editors and above | | Personal (person icon) | Only the person who made it | The person who made it | | Locked (lock icon) | Everyone with access to the table | Owners and creators only | Changes you can't save still work for you: commenters and read-only collaborators, and editors on a locked view, can filter, sort, group, hide fields and search, but the changes last only until you leave the page. They never change the view for anyone else. ## The view toolbar Grid views have the full toolbar. Other layouts have their own controls plus the filter, sort (except the timeline) and find tools described here. ### Filter **Filter** shows a count of active conditions, as in "Filter (2)". Click **Add condition** to pick a field, an operator and a value. Join conditions with **and** or **or**, and click **Add condition group** for one nested level of conditions (for example, "Status is Done or (Priority is High and Owner is Me)"). A view holds up to 50 conditions. A condition without a value is ignored until you fill it in. The operators depend on the field type: | Fields | Operators | | --- | --- | | Text | is, is not, contains, does not contain, is empty, is not empty | | Numbers, currency, percent, duration, rating | =, ≠, <, ≤, >, ≥, is empty, is not empty | | Dates | is, is not, is before, is after, is on or before, is on or after, is within, is empty, is not empty | | Single select | is, is not, is any of, is none of, is empty, is not empty | | Multiple select | has any of, has all of, is exactly, has none of, is empty, is not empty | | User | has any of, has all of, is exactly, has none of, is empty, is not empty | | Created by, last modified by | is, is not, is any of, is none of, is empty, is not empty | | Linked records | contains, does not contain (record names), has any of, has none of (pick specific records, up to 100), is empty, is not empty | | Attachments | contains, does not contain (file names), is empty, is not empty | | Formula, rollup | the number operators, plus contains and does not contain | | Lookup | contains, does not contain, is empty, is not empty | | Checkbox | is checked or unchecked | Date conditions compare with **today**, **tomorrow**, **yesterday** or an **exact date**; **is within** takes the past or next week, month or year. People conditions offer **Me**, which always means whoever is looking at the view, so one shared view can show "my tasks" to everyone. ### Sort and group **Sort** orders records by up to 10 fields: **Sort by** the first, **then by** the others. Directions are worded for the field, such as A → Z, 1 → 9, old → new or first → last for select options. A sort replaces any manual order. **Group** (grid only) groups records by up to 3 fields. Each group starts with a header band showing the value and its record count. ### Hide fields **Hide fields** lists every field with a switch. **Hide all** and **Show all** do what they say. Reorder fields with the arrow buttons or Alt+Up/Down. The primary field is always shown. ### Row height **Row height** has four sizes: **Short**, **Medium**, **Tall** and **Extra tall**. ### Row colours **Color** tints rows in the grid: - **None**: no colours. - **By select field**: each row takes the colour of its option in a single select field. - **By conditions**: add up to 20 colour conditions with **Add color condition**. Each has a colour and conditions that must all be true. A record takes the colour of the first condition it matches. Row colours show only in grid views. ### Find in view **Find in view** (Ctrl+F or Cmd+F in the grid) searches the view's records. The grid highlights matching cells and shows your position, as in "3 of 12". Press Enter for the next match, Shift+Enter for the previous one and Esc to close. Searches aren't saved with the view. ### More view actions The **…** button in the grid toolbar has **Import CSV…** (if you can add records) and **Download CSV**, which exports the view as you see it. See [Bases, tables and records](https://openrows.io/docs/bases-and-tables). ## Grid The grid is the spreadsheet view. For editing, copy and paste, the fill handle and undo, see [Bases, tables and records](https://openrows.io/docs/bases-and-tables). Column headers have a menu with **Freeze up to this field**, **Unfreeze columns**, **Move left**, **Move right**, **Select column**, **Reset width** and **Hide field**, plus field actions for people who can edit the schema. Drag a header to reorder columns and drag its right edge to resize. The summary bar at the bottom shows sums under number fields and counts under checkboxes. ## Kanban A kanban view shows records as cards in stacks, one stack per option of a single select field or per person in a user field (one person per cell). The first stack holds records with no value ("Uncategorized", or "No *field name*" for people). - **Stacked by** chooses the field. A new kanban view asks for it first. - **Customize cards** sets the **Cover image** (an attachment field), **Collapse empty stacks** and the **Fields on cards**. - Drag a card to another stack to change its value. Where you drop it within a stack is saved as the view's manual order, unless the view is sorted. - **New record** at the bottom of a stack creates a record with that stack's value. - **Add stack** at the end of the board adds an option to the single select (owners and creators). Keyboard: arrow keys, Home and End move between cards; Enter or Space opens a card; Alt+Up/Down moves a card within its stack; Alt+Left/Right moves it to the top of the next stack. ## Calendar A calendar view places records on days by a date field. - **Date fields** sets up to 5 pairs of a start field and an optional end field (**No end (single day)**). Records with an end date span several days. Date, date and time, created time and last modified time fields can be used, but only date and date-and-time fields can be moved. - In the same menu, **Colour events by** colours events by a single select field instead of by date field. - Switch between **Month**, **Week** and **Day**, and use **Today** and the arrows to move around. - Drag an event to another day, or drag the end of a range to change its length. The **+** on a day creates a record on that day. - **Undated** opens a panel of records without a date. Drag one onto a day, or click its calendar button to schedule it on the day you're viewing. Keyboard: Enter opens an event; Alt+Left/Right moves it a day; Alt+Up/Down moves it a week (a day in the Day layout); Alt+Shift+Left/Right shortens or lengthens a range. The week starts on Sunday. A calendar shows up to 1,000 records in the visible range; filter the view or switch to a week if you have more. ## Gallery A gallery shows records as cards. **Customize cards** sets: - **Cover image**: an attachment field whose first image tops each card. Tick **Fit the whole image** to show all of it instead of cropping. - **Card size**: **Small**, **Medium** or **Large**. - **Fields on cards**: the fields under the title (the primary field). Arrow keys move between cards and Enter opens one. **New record** adds a card. ## Timeline A timeline draws each record as a bar from its start date to its end date. Open **Timeline settings** to choose: | Setting | What it does | | --- | --- | | **Start date**, **End date** | The date fields the bars span | | **Group by** | Groups rows under the options of a single select | | **Colour by** | Colours bars by a single select | | **Dependencies (Gantt)** | A link field to records in the same table. Arrows run from each record it waits for, and turn red when the record starts before its predecessor ends | Zoom by **Day**, **Week** or **Month**, and use **Today**, **Earlier** and **Later** to move along. Drag a bar to move it, or drag either edge to change its dates. Keyboard: Up/Down moves between bars; Enter opens one; Alt+Left/Right moves the bar a day (a week in the week and month zooms); Alt+Shift+Left/Right changes its end date. The timeline has no sort menu and shows up to 1,000 bars at a time. ## List A list view shows records as a tree. Open **Customize list** and choose a field in **Nest under**, a link to records in the same table (for example, "Parent task"). Each record then sits under the first record that field links to. Pick **Don't nest** for a flat list. **Fields in rows** chooses what shows beside each title. Click a chevron, or press Left or Right, to collapse or expand a branch. Enter opens a record. A list loads up to 5,000 records. ## Form A form view turns a table into a form that adds a record per submission. See [Forms](https://openrows.io/docs/forms). ## Sharing a view Owners and creators can share a grid, kanban, calendar, gallery, timeline or list view with a read-only public link, optionally with a password or an email-domain limit, and embed it on a website. See [Sharing and permissions](https://openrows.io/docs/sharing). # Sharing and permissions Source: https://openrows.io/docs/sharing Invite people to a workspace or a single base, choose their role, narrow who can edit a table or field, and share read-only links to views and bases. ## Roles Everyone in a workspace has one of five roles. From highest to lowest: **Owner**, **Creator**, **Editor**, **Commenter** and **Read only**. Each role can do everything the roles below it can. | What you can do | Read only | Commenter | Editor | Creator | Owner | | --- | --- | --- | --- | --- | --- | | See bases, tables, records, views and comments | Yes | Yes | Yes | Yes | Yes | | Download a view as CSV, export a base as a ZIP | Yes | Yes | Yes | Yes | Yes | | Comment and react, edit or delete your own comments | | Yes | Yes | Yes | Yes | | Add, edit and delete records, upload attachments | | | Yes | Yes | Yes | | Import a CSV into a table, restore values from a record's history | | | Yes | Yes | Yes | | Create, change, duplicate and delete views and view sections | | | Yes | Yes | Yes | | See the base's trash, restore deleted records and views | | | Yes | Yes | Yes | | Create bases, import from Airtable | | | | Yes | Yes | | Create and change tables and fields, lock views | | | | Yes | Yes | | Set table and field edit permissions | | | | Yes | Yes | | Create share links, embeds and public form links | | | | Yes | Yes | | Manage snapshots, webhooks and automations | | | | Yes | Yes | | Delete anyone's comment | | | | Yes | Yes | | Invite people and change roles (up to Creator), share a base | | | | Yes | Yes | | Restore deleted tables, fields and bases, delete permanently from the trash | | | | Yes | Yes | | Grant, change or remove the Owner role | | | | | Yes | | Rename or delete the workspace, manage billing | | | | | Yes | Notes: - Inside a base, your role is the higher of your workspace role and your role on that base. A base role can raise your access, never lower it. Workspace owners are owners of every base. - Edit permissions on a table or field can narrow what editors, creators and owners may change (see below). - A locked view can only be reconfigured by creators and owners. Personal views are seen only by the person who made them. See [Views](https://openrows.io/docs/views). - Personal access tokens never get more than your role allows, and can't manage members, sharing, permissions or snapshots. See [Getting started](https://openrows.io/docs/getting-started) and the [REST API](https://openrows.io/docs/api). ## Workspace members and invitations Open **Members** from Home's sidebar or the workspace switcher. Everyone sees the member list; creators and owners also see **Invite people** and **Pending invitations**. To invite someone: 1. Type their email address and pick a role (Editor is preselected). 2. Click **Invite**. They get an email with a link. It works for 7 days and only for that address. 3. When they open the link, they sign in (or sign up) with that email and click **Accept invitation**. If the email couldn't be sent, the invitation is still saved; use **Resend** to try again. Resending replaces the old link, so only the newest email works. Revoke a pending invitation with the trash icon next to it. Change a member's role with the role menu on their row, or remove them with the remove button. Rules: - Creators can invite and manage people up to Creator. Only owners can grant, change or remove the Owner role. - A workspace always keeps at least one owner. Make someone else an owner before you step down or leave. On the hosted service, your plan's limits apply (see https://openrows.io/pricing); self-hosted instances have no limits. ## Base collaborators To give someone access to one base without adding them to the whole workspace, open the base and click **Share** in the top bar (creators and owners). The **Share** dialog lists everyone who can reach the base under **People with access**, with where their access comes from: "Base only", "Workspace editor", or "Workspace editor, raised on this base". - Invite by email with a role on this base: Creator, Editor, Commenter or Read only. Base invitations work like workspace invitations, and accepting one opens the base. - For workspace members, you can only pick their workspace role or higher. **Reset to workspace role** drops the raise. - For base-only collaborators, **Remove from base** takes away their access. - Base-only collaborators have no workspace role, so they don't see the workspace's Members page. ## Interface-only collaborators To share an interface without its base, open the interface's page (**Interfaces** in the base's top bar) and click **Share** (creators and owners). Invite people by email with **Can view** or **Can edit**. They see the interface's published pages at their own address, and under **Interfaces shared with you** on Home, but never the base, its grid, its other interfaces or the fields a page doesn't show. - **Can view**: read the pages. - **Can edit**: also change the fields a record review page marks as editable (on the records the page lists) and submit its form pages. - A page filter on a user field or **Created by** set to **Me** shows each viewer their own records, interface-only collaborators included. They can be assigned in the base's user fields. - People with access to the base see its interfaces already, so they aren't invited to one. - Buttons that run automations and file downloads aren't available to interface-only collaborators yet. ## Edit permissions Edit permissions narrow who can change data. Everyone with access still sees every record and field; permissions only limit changes. They never give more than a person's role: a commenter or read-only member listed in a permission still can't edit. ### Table permissions In the base's sidebar, open a table's menu and choose **Edit permissions**. Set each of: - **Who can add records** - **Who can delete records** - **Who can edit records** ### Field permissions Open a field's header menu and choose **Edit permissions**, then set **Who can edit this field**. When the table also limits editing records, both apply. When editors can't edit the field, you can tick **Allow this field to be filled through forms** so public forms can still set it on the records they create. See [Forms](https://openrows.io/docs/forms). ### Permission levels | Level | Who passes | | --- | --- | | Editors and up | Everyone who can edit records (the default) | | Creators and up | Creators and owners | | Specific people | The roles you tick (all owners, creators or editors) and up to 100 people you pick | | Nobody (read-only) | No one; the field or action is locked for everybody | As in Airtable, owners and creators are bound by these permissions too; they can change the permission instead. Restricted tables and fields show a lock icon. ## Share links A share link gives anyone who has it a read-only copy of a view or a whole base, without signing in. Creators and owners make them. - **A view:** open the view's menu in the sidebar and choose **Share view**. Grid, kanban, calendar, gallery, timeline and list views can be shared. Visitors see the view's records and visible fields; hidden fields and other views stay private. Form views have **Share form** instead (see [Forms](https://openrows.io/docs/forms)). - **A whole base:** click **Share** in the top bar and use **Share link to the whole base**. Visitors see every table as a tab and can pick among its shared views. Personal views and hidden fields stay private. ### Creating a link Fill in the options, then click **Create link**: | Option | What it does | | --- | --- | | Expires (optional) | The link stops working at the end of that day | | Password (optional) | Visitors type it before they see anything. At least 8 characters | | Only people signed in with an email at (optional) | Visitors must sign in with a verified email at one of these domains (up to 20), for example `example.com, example.org` | | Allow copying data out | On by default. When off, visitors can't select or copy data from the page | | Allow CSV download | Off by default. When on, visitors get a **Download CSV** button for the view they're looking at | | Sites that may embed it (optional) | https sites allowed to show the link in an iframe (see Embeds) | The new link appears once, under "New link (copy it now: it won't be shown again)". Click **Copy link**. OpenRows keeps only a fingerprint of the link, so it can't show it to you again; make a new one if you lose it. ### Managing links Every live link is listed under **Active links** with when it was made, its expiry and short badges such as "Password", "Only example.com", "No copying" or "CSV download". - **Settings** changes the password (or **Remove the password**), domains, toggles and embed sites. The link itself stays the same. - **Revoke** turns the link off at once. Revoked and expired links show "This link isn't available" to visitors. ### What visitors see - A read-only page with the base or view name and a **Read-only** badge. - With a password: a password prompt. After 10 attempts from one place, they wait up to 15 minutes. Once unlocked, the link stays open in that browser for 12 hours, or until it expires if that is sooner. Some servers also show a CAPTCHA. - With email domains: **Sign in to view**. Visitors signed in with another domain see "Your account can't open this link". ## Embeds You can show a share link inside another website, such as an intranet page. 1. In the link's settings, add the site under **Sites that may embed it**, as an https origin like `https://intranet.example.com` (up to 20 sites). Every other site is refused. 2. When you create a link, the **Embed code** box below it holds an iframe snippet. Pick a **Theme** (Light, Dark or Match the visitor) and click **Copy code**. 3. Paste the code into a page on the allowed site. The embed is a compact read-only page with a "View in OpenRows" link. You can add embed sites to a link later; the embed code stays the same. Links with a password or email domains aren't embedded unless you also tick **Allow embedding even with a password or email domains**. Asking for a password or a sign-in inside another site's page invites phishing, and many browsers block the cookies it needs. In an embed, the sign-in opens in a new tab. ## Notifications The bell in the top bar collects your notifications: - someone mentions you in a comment, - someone replies on a record you commented on, - someone adds you to a user field that has **Notify collaborators when they're added** turned on. Click a notification to open the record, or **Mark all as read**. You only see notifications for records you can still read. ### Email preferences In **Settings**, under **Email notifications**, choose: | Setting | Choices | | --- | --- | | What | All notifications, Only mentions, or Nothing | | When | Right away (one email per record, at most every 10 minutes), Hourly digest, or Daily digest | The bell always shows everything, whatever you pick for email. Each notification email also has a one-click unsubscribe link, which offers "Only when I'm mentioned" or turning emails off. ## Related - [Views](https://openrows.io/docs/views): personal, collaborative and locked views - [Forms](https://openrows.io/docs/forms): public form links - [Bases, tables and records](https://openrows.io/docs/bases-and-tables): comments, trash and exports - [Webhooks](https://openrows.io/docs/api/webhooks) and [Automations](https://openrows.io/docs/automations) # Forms Source: https://openrows.io/docs/forms A form view turns a table into a public form. Anyone with the link can fill it in, and each submission adds a record to the table. ## Creating a form In the base sidebar, click **Create view** and pick **Form**. The form builder opens with three parts: - On the left, the **Fields** tab lists the fields on the form, and the **Settings** tab holds the form's own settings. - On the right, a live preview shows the form as submitters will see it. You can try it out, but answers in the preview aren't saved. Editors and above can build forms. Owners and creators can lock a form view so that only they can change it, and only owners and creators can publish a form link. See [Views](https://openrows.io/docs/views) for locking and the other view options. ## Adding and arranging fields Under **Add a field**, click a field to put it on the form, or click **Add all**. To take a field off, click its remove button. Drag fields to reorder them, or focus one and press Alt+Up/Down. Computed, automatic and button fields can't be on a form: formula, lookup, rollup, count, autonumber, created time, last modified time, created by, last modified by and button. Every other field type can be added. Icons next to each field in the list show that it is **Required** (an asterisk), **Shown on conditions**, or **Not on the public form**. Open the field's settings to see why a field stays off the public form. ## Field settings Click a field in the list to open its settings: | Setting | What it does | | --- | --- | | **Label** | The question shown on the form. Leave it empty to use the field's name | | **Help text** | Shown under the label. Takes simple Markdown: bold, italic, links and lists | | **Required** | Submitters must answer before they can submit | | **Allow prefill from the form's link** | Lets the form's URL fill in this answer (see [Prefilling answers](#prefilling-answers)) | | **Show only when conditions match** | Shows the field only when earlier answers match (see [Conditional fields](#conditional-fields)) | | **Records people can pick** | Linked record fields only: the view whose records submitters can choose from | ### Conditional fields Tick **Show only when conditions match** and add conditions on fields above this one on the form, with the same operators as view filters. With more than one condition, choose **all match** or **any matches**. A field can have up to 50 conditions. A hidden field's answer counts as empty, so hiding a field also hides any field that depends on it. Answers to hidden fields aren't sent, and a required field only needs an answer while it is shown. ### Linked record fields A linked record field appears on the public form only after you choose **Records people can pick**: a collaborative view of the linked table. Submitters can search that view's records by their primary field and pick one, or up to 100 when the field allows several. They see nothing but the records' primary field values. ### Fields that stay off the public form Some fields show in the builder but not on the public form: - **User fields**, because anyone with the link would see your collaborators. - **Linked record fields** without a view to pick from. - **Fields editors can't edit**, because of the field's edit permissions. To let forms fill such a field anyway, open the field's **Edit permissions** and tick **Allow this field to be filled through forms**. See [Sharing and permissions](https://openrows.io/docs/sharing). If the table's own permissions stop editors from adding records, the form takes no submissions at all. ## Form settings The **Settings** tab sets: | Setting | What it does | | --- | --- | | **Title** | The form's heading. Defaults to the view's name | | **Description** | Text under the title, in simple Markdown | | **Cover image** | A banner across the top of the form (PNG, JPEG, GIF or WebP) | | **Logo** | A logo above the title (same image types) | | **Submit button** | The button's label. Defaults to "Submit" | | **After a submission** | **Show a message** (your own text, or a default thank-you) or **Redirect to a URL** | | **Show "Made with OpenRows"** | The branding line at the foot of the form | Redirects accept full `https://` addresses only. Until you set one, submitters see the message instead. On the hosted service, removing the branding needs a paid plan (see https://openrows.io/pricing). Self-hosted instances have no limits. ## Publishing the form Open the form view's **…** menu in the sidebar and choose **Share form**, then click **Create form link**. Copy the link straight away: it is shown only once. Anyone with the link can fill in the form, signed in or not. The dialog lists the form's links. Click **Turn off** on a link to stop it working at once. With no links, the form is private. You can create a new link at any time. ## Prefilling answers You can fill in answers through the form's URL, as in Airtable. For each field with **Allow prefill from the form's link** ticked, add `prefill_` and the field's name: ``` https://app.openrows.io/f/
?prefill_Name=Ada%20Lovelace&prefill_Status=New ``` - Values are read the same way as pasted text, so a select option takes its name and a checkbox takes `true`. - For fields that hold several values, such as multiple select, separate values with commas. - Add `hide_=true` to hide a prefilled field. Its value is still submitted. - Attachments and linked records can't be prefilled. - Prefill parameters for fields that don't allow prefill, and values that can't be read, are ignored. Encode spaces and special characters in the URL, as in the example. ## What submitters see Submitters see only the form: its title, description, images and the fields on it. They never see the table, its other records or its other fields. - **Attachments**: submitters upload files directly in the form, up to 10 files per field and 20 MB per file. - **Linked records**: submitters click **Add a record**, then search with **Find a record…**. - **Required answers**: missing ones are marked "An answer is required." and the form isn't sent. - **CAPTCHA**: when the server is set up with Cloudflare Turnstile, submitters complete a check before submitting. - **After submitting**: they see your message (or "Thanks for your answers") or go to your redirect URL. **Submit another response** starts a fresh form. Submitting twice by accident, for example with a double click, still creates one record. Forms are rate-limited per visitor, who then sees "Too many submissions from here. Try again in a few minutes." If the base reaches its record limit on the hosted service, the form says it isn't accepting responses right now. ## Records from forms Each submission creates one record. Values are checked like any other edit, and computed fields, revision history and automations update as usual. Submissions aren't made by a person: the record's activity shows **Form** as the author. To act on new submissions, use an automation with the **When a form is submitted** trigger. It can, for example, send an email, post a Slack message or update other records. See [Automations](https://openrows.io/docs/automations). ## Self-hosting notes In production, public forms need Cloudflare Turnstile. Set both `TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` on the server, or form links won't work. See [Self-hosting](https://openrows.io/docs/self-hosting). # Automations Source: https://openrows.io/docs/automations Automations run actions when something happens in a base: create or update records, send emails, Slack messages or HTTP requests, or run a script. ## Overview Each automation belongs to one base and has one trigger ("When") and up to 25 actions that run in order. Open a base and click **Automations** in the top bar (owners and creators of the base see it). The page lists every automation with its trigger, an on/off switch, its last run and how many runs its history holds. - Click **New automation**, pick a trigger, then use **Add an action…** to add steps. Move steps up or down; each step's number is what tokens use to refer to it. - Click **Save**, then turn the automation on with its switch. New automations are off, and an automation needs at least one action before you can turn it on. - A base can have up to 50 automations. - Automations are managed in the app only; they aren't part of the [REST API](https://openrows.io/docs/api) or personal access tokens yet. To notify another system of changes without building an automation, see [Webhooks](https://openrows.io/docs/api/webhooks). ### Who an automation runs as Actions run with the permissions of the person who last saved the trigger or actions, or turned the automation on ("Whoever saves becomes who it runs as"). Their current role applies, and so do table and field edit permissions (see [Sharing and permissions](https://openrows.io/docs/sharing)). If that person loses access to the base, the automation turns itself off and shows "Turned off: the person it ran as lost access to the base. Turning it on runs it as you." ## Triggers | Trigger | Runs when | Settings | | --- | --- | --- | | When a record is created | A record is added to the table (in the grid, by import, a form, the API or another automation) | Table | | When a record is updated | A record of the table changes | Table, **Fields to watch** (none picked: any field) | | When a record matches conditions | A record enters the conditions: it is created matching them, or changes to match them | Table, **Conditions** (the same filter builder as views) | | When a form is submitted | Someone submits the chosen form view of the table | Table, **Form** | | When a button is clicked | Someone clicks the chosen button field in a record | Table, **Button field** | | At a scheduled time | On a repeating schedule | **Repeat**, time zone | | When a webhook is received | Another system sends a POST to the automation's own URL | None (the URL is generated) | Notes: - Only changes made after the automation is turned on trigger it. - "When a record is updated" with no fields picked runs on any change a person or automation makes, but not when only computed values (formulas, lookups, rollups) recalculate. - "When a record matches conditions" fires once when a record enters the conditions. It fires again only after the record leaves the conditions and comes back. - "When a form is submitted" needs a [form view](https://openrows.io/docs/forms) on the table first. ### Button clicks A [button field](https://openrows.io/docs/fields) normally opens a URL. In the button's field settings, set its action to **Run automations**, then pick that field in a "When a button is clicked" trigger. Clicking the button in the grid runs every automation that is turned on for it ("Automation started", or "No automation is turned on for this button"). Clicking needs permission to edit the record. The automation still runs as its own creator; the person who clicked is available as a token. ### Schedules Pick **Repeat**: - **Every…**: every N minutes, hours, days or weeks (N from 1 to 1000), **Starting** at a date and time. - **Cron expression**: five fields, minute hour day month weekday, for example `0 9 * * 1-5` (weekdays at 09:00). Names (`mon`, `jan`), ranges, lists and steps (`*/15`) work, and so do `@hourly`, `@daily`, `@midnight`, `@weekly`, `@monthly`, `@yearly` and `@annually`. Every schedule has a **Time zone**, and times are wall-clock times there, so a 09:00 run stays at 09:00 across daylight saving changes. A time the clock skips in spring runs the same distance after the change (01:30 in a 01:00 to 02:00 gap runs at 02:30); a time repeated in autumn runs once. Minute and hour intervals count elapsed time; day and week intervals keep the starting time of day. A schedule can run at most once every 5 minutes. If the server was down for several scheduled times, the automation runs once when it is back, not once per missed time. ### Webhook-received triggers The trigger card shows the automation's **Webhook URL**. POST JSON to it to start a run: ```sh curl -X POST "https://app.example.com/hooks/a/" \ -H "Content-Type: application/json" \ -d '{"email": "ada@example.com", "plan": "pro"}' ``` - The body must be JSON (`Content-Type: application/json`), at most 100 KB; an empty body counts as `{}`. The answer is `{"success": true}`. - Anyone with the URL can trigger the automation, so treat it as a secret. **Make a new webhook URL** replaces it; the old one stops working at once. - A URL whose automation is off or deleted answers 404. - Each URL accepts 5 requests a second (bursts of 20), and each client IP 600 requests a minute across all webhook URLs; over that the answer is 429 with `Retry-After`. - The card shows the **Last request received**. Its fields become tokens in the value picker, so send one sample request before you build the actions. ## Actions | Action | What it does | Output for later steps | | --- | --- | --- | | Create record | Creates a record in a table with the fields you set | The created record | | Update record | Updates a record, or every record in a list, with the fields you set | The updated records, their number | | Find records | Finds records of a table matching **Conditions**, **At most** 1 to 100 (no conditions: any records) | The records found, their number | | Send email | Emails people from the server's own address | How many emails were sent | | Send Slack message | Posts a message to a Slack incoming webhook (or a compatible one) | The response status | | Send HTTP request | Sends a GET, POST, PUT, PATCH or DELETE request with headers and a JSON body | The response status and body | | Run a script | Runs your JavaScript in a sandbox | What the script outputs | ### Record actions - **Create record** and **Update record**: pick a table, then **+ Add a field…** for each field to set. Values can hold tokens. - **Update record**: the **Record ID** is a record's ID or a token, such as the trigger's record (`{{trigger.record.id}}`) or a list from a Find records step (`{{steps.1.records}}`). One step updates at most 100 records. - A step writes like a person would: it can't change a table or field the automation's person can't edit. ### Send email - **To**: addresses or tokens, separated by commas. A token can give an email field's value or a user field's people; people must have access to the base, others are skipped. One step sends to at most 50 recipients, and each gets their own copy. - **Subject** (up to 500 characters) and **Message (Markdown)** (up to 20,000 characters). - Each workspace can send a limited number of automation emails per day (UTC), counted per recipient: 100 by default; a self-hosted server's administrator can change it. Past the limit the step fails with a message saying so. ### Send Slack message Paste the **Slack incoming webhook URL** and write the **Message** (up to 4,000 characters). The URL is stored encrypted and never shown again; leave the box empty to keep the saved one. ### Send HTTP request Set the **Method**, **URL**, **Headers** (up to 20) and an optional **JSON body** (not sent with GET; sent as `application/json` unless you set `Content-Type`). - Tick **Secret** on a header that holds a key: its value is stored encrypted and shown as "Saved (hidden)" afterwards. - The request times out after 15 seconds; an answer other than 2xx fails the step. Up to 1 MB of the response is kept for later steps (JSON responses are parsed). - URLs must be public `https` addresses on ports 80 or 443. Self-hosted servers can allow more (see [Self-hosting](https://openrows.io/docs/self-hosting)). ## Tokens Values in actions can include tokens in double braces, filled in when the automation runs. Click the `{}` button next to a box to search and insert one instead of typing it. | Token | Value | | --- | --- | | `{{trigger.record.id}}` | The trigger's record ID (record, form and button triggers) | | `{{trigger.record.fields.Status}}` | A field of the trigger's record, by field name | | `{{trigger.record.fields["Due date"]}}` | Field names with dots or brackets use `["..."]`; spaces work either way | | `{{trigger.user.name}}`, `{{trigger.user.email}}` | Who clicked the button | | `{{trigger.scheduledAt}}` | The scheduled time of the run | | `{{trigger.body.customer.email}}` | A value in the webhook request's JSON body | | `{{steps.1.record.id}}` | The record step 1 created | | `{{steps.2.records}}`, `{{steps.2.count}}` | The records step 2 found or updated, and how many | | `{{steps.2.records[0].fields.Name}}` | A field of the first of them | | `{{steps.3.status}}`, `{{steps.3.body.id}}` | An HTTP step's response status and body | | `{{steps.4.sent}}` | How many emails step 4 sent | | `{{steps.5.output.total}}` | A value a script step set | How tokens fill in: - A box that holds exactly one token keeps the value as it is: a number stays a number, and a list of records can fill a link field. - Inside other text, lists are joined with commas, and linked records and people show their names. - A missing value is empty. A step can only use the trigger and the steps before it, and the trigger itself can't hold tokens. One box can hold up to 50 tokens. - Tokens name fields by name, so renaming a field breaks the tokens that use the old name. Update them after a rename. ## Run a script **Run a script** runs JavaScript in a throwaway sandbox with no network access. Add **Input variables (input.config())** with **Add input** (values can hold tokens), then write the **Script (JavaScript)**. You can use `await` at the top level. ```js const { email } = input.config(); const people = base.getTable('People'); const result = await people.selectRecordsAsync({ filter: { conditions: [{ field: 'Email', operator: 'is', value: email }] }, maxRecords: 1, }); if (result.records.length === 0) { await people.createRecordAsync({ Email: email }); } output.set('found', result.records.length); ``` What a script can use: - `input.config()`: the input variables. `output.set(name, value)`: a value later steps read as `{{steps.N.output.name}}`. - `base.getTable(nameOrId)` (tables of this base only), then `getField`, `selectRecordsAsync({ filter, sorts, fields, maxRecords })`, `selectRecordAsync(id)`, `createRecordAsync`/`createRecordsAsync`, `updateRecordAsync`/`updateRecordsAsync`, `deleteRecordAsync`/`deleteRecordsAsync`. Records have `getCellValue(field)` and `getCellValueAsString(field)`. - `fetch(url, { method, headers, body })`, sent by the server under the same URL rules as HTTP requests, with `ok`, `status`, `text()` and `json()`. - `console.log`, `info`, `warn` and `error`: the lines appear in the run history. There is no `require`, no timers and no access to the server. Reads and writes run as the person the automation runs as, with their permissions. Limits: up to 50 records per create, update or delete call, 20 `fetch` requests (10 seconds and 1 MB each), 500 calls in all, 64 KB of output and 200 log lines; the script itself is up to 50,000 characters, with up to 50 input variables. The step card shows the time, CPU and memory limits of your server (by default 30 seconds, 10 seconds of CPU and 128 MB). Availability: scripts are off unless the server's administrator turns them on with `AUTOMATION_SCRIPTS` (see [Self-hosting](https://openrows.io/docs/self-hosting) and [Run-script automation steps](https://github.com/Draggable/openrows/blob/main/docs/deploy/automation-scripts.md)). On the hosted service, scripts depend on your plan (see https://openrows.io/pricing). When scripts aren't available, the action shows as "Run a script (not available)". ## Testing The panel beside the builder has two tabs, **Test** and **Run history**. A test runs the actions for real, as you: records are created and changed, and emails, messages and requests are sent. - Record, form and button triggers: **Pick a record** to run with. - Webhook triggers: the test uses the last request received; send one to the URL first. - Schedules: the test runs as if the schedule fired now. Click **Run test** (or **Save and test** with unsaved changes). The result shows each step's input, output and error. Tests aren't counted as runs and don't change the automation's last run. ## Run history and retries **Run history** keeps runs from the last 30 days. Use **Show** to list all runs or only succeeded, failed or skipped ones. Open a run to see its trigger data and each step's input, output and error; header values, Slack URLs and query string values are shown as `[redacted]`. Tags mark **Test** and **Retry** runs, and why a run was skipped: **Loop guard** or **Plan limit**. When a step fails, its own changes are undone, the steps after it are skipped, and the changes of earlier steps stay. Click **Retry** on a failed run to run it once more, as you, with the automation's current actions. Record triggers use the record's values as they are now; webhook, schedule and button runs reuse the original request, time or clicker. Each failed run can be retried once, and not after its trigger type changed. Test runs can't be retried. ## Limits - **Loop guard**: automations whose actions trigger other automations can chain at most 3 deep. A deeper run does nothing and shows as skipped with **Loop guard**. - Per base: 50 automations, 25 actions each, 64 KB of configuration. - Find records returns at most 100 records; one Update record step changes at most 100. - Emails: 50 recipients per step and the daily email limit above. - Outgoing requests (HTTP, Slack, email and script `fetch`) share a per-workspace rate; runs over it wait in the queue and nothing is dropped. - On rare occasions, such as a server restart in the middle of a run, an email, message or request may be sent twice. - Automation runs per month and records per base: on the hosted service, your plan's limits apply (see https://openrows.io/pricing); self-hosted instances have no limits. Runs over the monthly limit are skipped with **Plan limit**. # Scripts Source: https://openrows.io/docs/scripts Scripts are small JavaScript programs that work on a base: clean up duplicates, change many records at once or build a report. They run in your browser, in a sandbox, with your own permissions: a script can only read and change what you could read and change by hand. Open a base and click **Scripts** in the top bar. Editors, creators and owners see it; creators and owners write and change scripts, editors run them. ## Write a script Click **+** next to **Scripts**, or pick a tile under **New script**: a blank script or one of the examples (**Remove duplicates**, **Find and replace**, **Summary report**). Each example asks for its table and fields, so it works on any base. The editor completes the script API as you type: type `base.`, `input.` or `output.`, or `getTable('` for the base's table names. Click **Save** (or press Ctrl/⌘ S) to keep your changes. **Run** (Ctrl/⌘ Enter) runs what is in the editor, saved or not. ```js const table = base.getTable('Tasks'); const query = await table.selectRecordsAsync({ fields: ['Name', 'Status'] }); const open = query.records.filter((r) => r.getCellValueAsString('Status') !== 'Done'); output.markdown(`**${open.length}** open tasks`); output.table(open.map((r) => ({ Task: r.name, Status: r.getCellValueAsString('Status') }))); ``` ## The API The API follows Airtable's scripting extension, so most Airtable scripts port with small changes. - `base`: `name`, `tables`, `getTable(nameOrId)`. - A table: `name`, `fields`, `views`, `primaryField`, `getField(nameOrId)`, `getView(nameOrId)`, `selectRecordsAsync({ fields, sorts, recordIds })`, `selectRecordAsync(id)`, `createRecordAsync(fields)`, `createRecordsAsync([{ fields }])`, `updateRecordAsync(record, fields)`, `updateRecordsAsync([{ id, fields }])`, `deleteRecordAsync(record)`, `deleteRecordsAsync(records)`. - A view: `selectRecordsAsync()` gives the view's records in its order, with its filters. - A record: `id`, `name` (the primary field's text), `getCellValue(field)`, `getCellValueAsString(field)`. - `input`: `textAsync(label)`, `buttonsAsync(label, options)`, `tableAsync(label)`, `viewAsync(label, table)`, `fieldAsync(label, table)`, `recordAsync(label, tableOrViewOrQuery)`. The question shows in the output panel and the script waits for your answer. - `output`: `text(value)`, `markdown(source)`, `table(rows)`, `inspect(value)`, `clear()`. - `session.currentUser`, and `console.log` for the output panel. Fields are named by name or id. Cell values are OpenRows' own: a single select's value is its option's id, for example, so use `getCellValueAsString` for display text. Scripts are JavaScript; TypeScript type annotations aren't supported yet. ## Safety and limits A script runs in a sandboxed frame with no access to the app, your session, cookies or storage, and no network: `fetch` and `remoteFetchAsync` aren't available. Everything it does to the base goes through the same API as the app, as you, so table and field edit permissions and plan limits apply. Click **Stop** to end a run at any time. One run may make up to 2,000 calls (20 per second; faster scripts wait), read 50,000 records, change 10,000 and show 1,000 outputs. Create, update and delete take at most 50 records per call, as in Airtable. A run stops after 15 minutes. # Extensions Source: https://openrows.io/docs/extensions Extensions are small apps that sit next to your views in a base's **extension panel**: a chart that updates as records change, a print layout of the record you've selected, a preview of a record's link. They run in your browser, in a sandbox, with your own access, and only with the permissions they list. Open a base and click **Extensions** in the top bar (on a phone, in the **More** menu). The panel opens on the right and stays open on that browser until you close it. ## Add an extension Creators and owners click **Add extension** and pick one of the built-in extensions: - **Chart**: a bar chart of a table's records, counted by a field's values. Pick the table and the field in the chart; it updates live as records change. - **Print view**: lays out the record you've selected (a grid cell, or the record you've opened) as a page. **Choose fields** picks what goes on the page; **Print** prints it. - **URL preview**: previews the selected record's link from a URL field: YouTube, Vimeo and Loom videos and images play or show inline, other sites get an **Open link** button. Each extension's menu (**⋯**) lists what it may do, and has **Reload**. Creators also move extensions up and down, make them small, medium or large, and remove them. A base has at most 20. Everyone on the base sees its extensions, and each person sees them with their own access: a read-only member's chart counts the records they can read, and an extension can't change a record they couldn't change by hand. Editors and up change an extension's settings (the chart's table and field, the print view's fields); everyone else sees them as they are. ## Permissions An extension gets only the permissions in its manifest: | Permission | What the extension can do | | --- | --- | | Read this base's tables and records | See tables, fields and views; read records; follow their changes | | Create, change and delete records | Write records (as you, with your edit permissions) | | See the table, view and record you have selected | Follow your selection | | Change the extension's settings | Save its own settings (editors and up) | | Show images from the web and videos from YouTube, Vimeo and Loom | Embed those players and `https:` images | Whatever it asks for, an extension never sees your cookies or session, can't read or change the OpenRows page around it, has no storage of its own, and has no network access: its frame's security policy blocks requests, and its only way to your data is OpenRows, which checks every call. An extension can still navigate its own frame to another site; OpenRows stops an extension that does, but install third-party extensions only from people you trust. ## Your own extensions Write an extension with [@openrows/blocks](https://www.npmjs.com/package/@openrows/blocks), the typed SDK, and bundle it into one package file: a JSON file with the manifest (name, version and permissions) and the bundle (HTML, CSS and one JavaScript file). The package's README describes the format and the whole API. Creators upload a package in **Add extension** under **Your own extension**: OpenRows shows the permissions it asks for before you install it. On app.openrows.io only workspace owners install their own extensions. Self-hosted instances choose who may with `EXTENSIONS_CUSTOM_BUNDLES` (`creators`, the default; `owners`; or `off`). # Moving from Airtable Source: https://openrows.io/docs/airtable-migration Bring your Airtable bases into OpenRows with the built-in importer, see what changes on the way, and point code written for Airtable's API at OpenRows. ## Before you start - You import into a workspace where you are an **owner** or **creator**. Each Airtable base becomes a new base there; nothing is written to your existing bases. - Invite your team first (see [Sharing and permissions](https://openrows.io/docs/sharing)). Airtable's collaborator cells are matched to workspace members by email; people who aren't members yet are left out of those cells. - An import copies a base once. It doesn't keep the two in sync: importing the same base again creates another new base. ## Import your bases ### Create an Airtable token At [airtable.com/create/tokens](https://airtable.com/create/tokens), create a personal access token with the scopes `data.records:read` and `schema.bases:read`, and give it access to the bases you want to move. It starts with `pat`. OpenRows uses the token only for the import. It is kept encrypted while the import runs and deleted when it ends, or 24 hours after the start at the latest (an import still running then fails). You can revoke it at Airtable once you're done. ### Pick bases and start 1. On **Home**, choose **Import from Airtable**. 2. Paste the token and choose **Find bases**. OpenRows lists the bases the token can read. 3. Tick up to 10 bases and choose **Import**. Each starts at once, in a new base named after the Airtable one. If Airtable refuses the token, or the token can't read a base you picked, the dialog says so before anything is created. ### Progress and cancelling The dialog's **Your imports** list shows each import with a progress bar, the tables, records, files and linked records so far, and an estimate of the time left. An import goes through three steps: "Creating tables, fields and views", "Importing records" and "Linking records". It keeps running if you close the dialog or the tab, and the browser you started it from shows a notice with **Open base** when it ends. Finished imports stay in the list for 7 days. Airtable allows 5 requests a second, and OpenRows reads 100 records per request, so large bases take a while. **Cancel** stops a running import. The new base keeps what was imported so far. A cancelled or failed import leaves its half-filled base in place: delete it from the base menu if you don't want it. ### The import report When an import ends, a summary line says what needed attention, and **Import report** lists it field by field: | Section | What it lists | | --- | --- | | Left out | Fields that weren't imported, with the reason | | Formulas kept as text | Formulas that didn't translate, with the formula and the reason | | Changed type | Fields imported as another type, and how | | Files not copied | Attachments that couldn't be copied | | Views not imported | Views of types the importer doesn't bring over | It also counts links that were skipped and names tables cut short at 100,000 records. ## What maps to what Field names, descriptions and options (number precision, currency, date and time formats, time zones, rating and checkbox icons, select choices and their colours) come along where OpenRows has them. See [Fields](https://openrows.io/docs/fields) for each type. | Airtable field | OpenRows field | | --- | --- | | Single line text, Email, URL | Single line text, Email, URL | | Phone number | Phone number | | Long text | Long text | | Long text with rich text | Long text with rich text on | | Number, Percent | Number, Percent | | Currency | Currency. A symbol OpenRows can't match to a currency code becomes USD (reported) | | Duration | Duration | | Rating | Rating (at most 10; the star icon when Airtable's isn't available) | | Checkbox | Checkbox | | Date, Date with time | Date, Date and time, with Airtable's time zone; a field shown in each collaborator's own time zone becomes UTC | | Single select, Multiple select | Single select, Multiple select, with choices and colours | | User (one or several) | User (one or several), matched by email to workspace members | | Barcode | Barcode | | Attachment | Attachment, with the files copied | | Link to another record | Link to another record, with its inverse field | | Lookup | Lookup | | Count | Count | | Rollup | Rollup when OpenRows can tell its function, otherwise see below | | Formula | Formula when it translates, otherwise see "Formulas" | ### Fields kept with Airtable's values Some fields Airtable computes would get different values if OpenRows computed them (the import's time, new numbers). They become plain fields holding Airtable's values, listed under "Changed type": | Airtable field | Becomes | | --- | --- | | Created time, Last modified time | Date or Date and time, with Airtable's values | | Created by, Last modified by | User, with Airtable's people | | Autonumber | Number, with Airtable's numbers | | Button | URL, with each button's link (the label is lost) | | AI text | Long text, with the generated values | | Rollup whose function Airtable's API doesn't reveal | A plain field of the rollup's result type, with Airtable's values | | Lookup, rollup or count that reads a field that wasn't imported as it is | A plain field with Airtable's values | These values don't update any more. If you want OpenRows to keep them current, add a field of the matching type (for example Created time or Autonumber); for imported records it counts from the import. ### Fields left out - Fields synced from another Airtable base or source: they belong to Airtable's sync. - Field types OpenRows doesn't have yet. - Fields beyond 500 in one table. The primary field always comes along. When its type can't be a primary field here (a formula, a link, a lookup and the like), it becomes single line text with Airtable's values. ## Links, lookups, rollups and counts An Airtable link and its inverse become one OpenRows link field and its inverse, with Airtable's names and the "allow linking to multiple records" setting. Links in OpenRows always go both ways, so a one-way Airtable link gets an inverse field in the other table (reported). A link from a table to itself has no inverse field here. A cell keeps at most 1,000 links, and links to records that weren't imported are skipped (counted in the report). Lookups, rollups and counts become live OpenRows fields once the fields they read exist. Airtable's API reveals a rollup's function only for simple ones such as `SUM(values)` or `ARRAYJOIN(values, ", ")`; OpenRows rollups support SUM, AVG, MIN, MAX, COUNT, COUNTA, COUNTALL, AND, OR, XOR, ARRAYJOIN, ARRAYUNIQUE, ARRAYCOMPACT and CONCATENATE. Any other rollup keeps Airtable's values in a plain field. ## Attachments Files are copied from Airtable while their records are imported, up to 100 MB per file and 100 files per cell, with thumbnails for images. A file whose type doesn't match its contents is stored as a download, not shown as an image. Files that fail (too large, gone at Airtable, no storage left on the workspace's plan) are listed under "Files not copied"; the record is imported without them, so upload them again by hand. ## Views - **Grid views** come over with their visible fields in Airtable's order. The first one replaces the table's default "Grid view". - **Kanban views** come over stacked by the table's first single select field (or, without one, its first one-person User field), which may not be the field Airtable stacked by. - Calendar, gallery, timeline, list, form and other views aren't imported; the report names them. Create them again (see [Views](https://openrows.io/docs/views) and [Forms](https://openrows.io/docs/forms)). Airtable's API doesn't share views' filters, sorts, groups, row colours or manual record order, so set those again. Records arrive in the order Airtable's API lists them. ## Formulas OpenRows formulas use Airtable's syntax and function names, so most formulas translate as they are: field references are rewritten to the imported fields, and the formula is checked like one you type (see [Fields](https://openrows.io/docs/fields)). A formula is kept as text instead when: - it uses a function OpenRows doesn't have; - OpenRows' stricter type check refuses it, for example text in arithmetic, dates with `+` or `-`, or lists in comparisons; - it reads a field that was left out or a formula that was kept as text; - formulas read each other in a loop; - it is the table's primary field. Such a field is single line text holding Airtable's results, with `Airtable formula: …` (by field names) in its description, so you can fix it and convert the field to a formula. Formulas that translate can still behave differently: - `REGEX_MATCH`, `REGEX_EXTRACT` and `REGEX_REPLACE` use RE2 syntax: no backreferences or lookaround, and patterns of at most 1,000 characters. - `LAST_MODIFIED_TIME()` accepts field arguments but returns when the record last changed. - `CREATED_TIME()` and `LAST_MODIFIED_TIME()` count from the import for imported records, and `RECORD_ID()` returns the OpenRows record id. - Dates are in UTC unless the formula uses `SET_TIMEZONE`, as in Airtable. `DATETIME_FORMAT` and `DATETIME_PARSE` take the same format tokens. - A formula gets 100,000 steps and 100 ms per record, results are capped (text at 100,000 characters, lists at 10,000 items, numbers at plus or minus 10 to the 15th), and a formula that fails shows `#ERROR!`. ## What isn't imported - Comments and record revision history. - Interfaces, extensions and Airtable's sync. - Automations: rebuild them in OpenRows (see [Automations](https://openrows.io/docs/automations)). - Collaborators, roles and permissions on the base, tables or fields: set them up again (see [Sharing and permissions](https://openrows.io/docs/sharing)). - Share links and forms (see [Sharing](https://openrows.io/docs/sharing) and [Forms](https://openrows.io/docs/forms)). - View settings beyond the ones above. - More than 100,000 records per table: the rest of a larger table is cut off and the report names it. If your workspace's plan limits records or file storage, an import stops at the record limit and keeps what it imported, and files past the storage limit aren't copied. ## The REST API OpenRows' REST API follows the shape of Airtable's Web API, so most scripts and integrations need only a new address, a new token and new ids. The full reference is in [REST API v1](https://openrows.io/docs/api). - **Address:** `https://api.airtable.com/v0/…` becomes `https:///api/v1/…` (on the hosted service, `https://app.openrows.io/api/v1/…`). - **Token:** create a personal access token in **Settings** → "Developer: personal access tokens" and send it as `Authorization: Bearer or_pat_…`. The scopes have Airtable's names: `schema.bases:read`, `schema.bases:write`, `data.records:read`, `data.records:write`, `data.recordComments:read`, `data.recordComments:write`, `user.email:read`. - **Ids:** bases, tables, fields, views and records have UUIDs without Airtable's `app`, `tbl`, `fld` or `rec` prefixes, and an import gives everything new ids. Replace Airtable ids in your code, or name tables and fields instead. - **Airtable's JavaScript client** works when you point it at OpenRows: ```js import Airtable from 'airtable'; const base = new Airtable({ apiKey: process.env.OPENROWS_TOKEN, endpointUrl: 'https://app.openrows.io/api', apiVersion: '1.0.0', // the client builds /v/ URLs }).base(''); const done = await base('Projects').select({ filterByFormula: "{Status} = 'Done'" }).all(); ``` Differences to know: - Tables are named by id or name, only inside the base in the path. Cells are keyed by field name, or by field id with `returnFieldsByFieldId`. - `filterByFormula` supports a subset: `AND`, `OR`, `NOT`, a field compared with a value, `BLANK()`, `TODAY()`, `FIND` and `SEARCH`, `IS_BEFORE`, `IS_AFTER`, `IS_SAME` and `RECORD_ID()`. Anything else answers 422 `INVALID_FILTER_BY_FORMULA`. - Error bodies keep Airtable's shape and error types, but statuses are OpenRows': 404 for anything outside your access, 422 for invalid input. - Rate limits match Airtable's: 5 requests a second per base (burst 10) and 50 a second per token (burst 100). Pages hold up to 100 records and writes take up to 10 records. - Deleted records go to the base's trash. - Attachments can't be added from a URL through the API yet: upload the file first. - Webhooks have their own format: see [Webhooks](https://openrows.io/docs/api/webhooks). For new code there is also a typed JavaScript SDK, `@openrows/sdk`, and AI agents can use the API through the MCP server (see [MCP](https://openrows.io/docs/api/mcp)). ## CSV import instead If you only need a table's data, or can't create an Airtable token, download a CSV from an Airtable view and import it: - On **Home**, **Import a file** makes a new base from a CSV of up to 50 MB, with field types suggested from the values. - In a table, the view toolbar's **More view actions** (…) → **Import CSV…** adds rows to an existing table: you choose which field each column goes to. Up to 50 MB and 250,000 rows. A CSV carries one table at a time and only cell text: no formulas, links between tables, attachment files or views. See [CSV import](https://openrows.io/docs/bases-and-tables#csv-import). ## After the import: a checklist 1. Read the import report of each base. 2. Check the fields listed under "Changed type", and add live Created time, Autonumber or similar fields where you need them. 3. Fix the formulas kept as text: the original is in each field's description. 4. Recreate rollups and lookups that kept Airtable's values. 5. Set filters, sorts, groups, row colours and kanban stack fields again, and create the calendar, gallery, timeline, list and form views you had. 6. Re-upload the files listed under "Files not copied", and import the rest of any table cut short at 100,000 records. 7. Fill in user cells for people who weren't workspace members during the import. 8. Share the base and set table and field permissions (see [Sharing and permissions](https://openrows.io/docs/sharing)). 9. Rebuild automations, forms and share links. 10. Move integrations to the OpenRows API: new address, token and ids. 11. Delete half-filled bases from cancelled or failed imports, and revoke the Airtable token. Airtable is a trademark of Formagrid, Inc. OpenRows isn't affiliated with or endorsed by Airtable. # Self-hosting Source: https://openrows.io/docs/self-hosting Run your own OpenRows server with Docker Compose or on Kubernetes with the Helm chart: the image, the services, the settings production requires, sign-in, storage, email, backups and upgrades. ## Requirements - A Linux host with Docker Engine and Docker Compose v2, or a Kubernetes cluster (1.29 or later) with Helm 3.8 or later (see "Kubernetes (Helm)"). - An OpenID Connect provider that issues verified email addresses: Keycloak, which the repository ships a realm for, or any other. - An S3-compatible bucket (AWS S3, Cloudflare R2, Versity S3 Gateway, a MinIO you already run, ...). - A public https name for the app, with a reverse proxy or tunnel in front of it. In production the server refuses an http `APP_URL`. - Optional: an SMTP relay for the app's own email, and ClamAV for scanning uploads. The open-source edition has no plans, quotas or billing: every workspace is unlimited. ## The image and the services One image serves three Compose services, built from [docker/Dockerfile](https://github.com/Draggable/openrows/blob/main/docker/Dockerfile). Its default target, `oss`, is the open-source server and holds no hosted-only code: | Service | Command | What it does | | --- | --- | --- | | `migrate` | `node dist/migrate.js` | One-shot: applies the database migrations under an advisory lock, installs the job queue's schema and gives the `openrows_app` role its password. `api` and `worker` start only after it exits 0 | | `api` | `node dist/index.js` | The HTTP API, the realtime WebSocket at `/ws` and the built web client, all on port 3000 | | `worker` | `node dist/worker.js` | Background jobs (see "Background worker") | | `postgres` | Postgres 16 | The database, on the Compose network only | CI publishes the `oss` image to `ghcr.io/draggable/openrows` (tags `main` and `sha-`) on pushes to `main` when publishing succeeds. If you can't pull it, build it from a checkout: ```sh git clone https://github.com/Draggable/openrows.git && cd openrows docker build -f docker/Dockerfile -t openrows:local . ``` ## Quick start To try OpenRows on your own machine, start the development stack. It needs no configuration and runs everything on 127.0.0.1: Postgres, the migrations, the api, the worker, Keycloak with a ready realm, Mailpit and an S3 server. ```sh git clone https://github.com/Draggable/openrows.git && cd openrows docker/compose.sh dev up -d --wait ``` Open http://localhost:8180 and sign in as `dev@openrows.test` with the password `openrows-dev-password`. Mail lands in Mailpit at http://localhost:8025. `docker/compose.sh dev down -v` stops it and drops its data. This stack runs in development mode with well-known secrets, and its Airtable importer talks to a built-in fake Airtable: never expose it. ### A production stack Use the base file [docker/compose.yml](https://github.com/Draggable/openrows/blob/main/docker/compose.yml) (postgres, migrate, api, worker) with the overlay [docker/selfhost.yml](https://github.com/Draggable/openrows/blob/main/docker/selfhost.yml): it publishes only the api, on loopback for your reverse proxy, passes the settings `compose.yml` doesn't, and has optional profiles for Keycloak in the stack (`keycloak`), malware scanning (`clamav`) and Valkey (`valkey`). 1. Copy [docker/.env.selfhost.example](https://github.com/Draggable/openrows/blob/main/docker/.env.selfhost.example) to a file outside the checkout (mode 600) and fill in every empty value that applies. Leave the Keycloak (`KC_*`, `OPENROWS_KC_*`) values empty unless you turn on the `keycloak` profile. 2. If you need more (another port, a setting the overlay doesn't pass), copy `selfhost.yml` and edit your copy: Compose only reads the env file to fill in `${...}`, so a container sees only what its `environment:` lists. 3. Build and start it (empty values count as unset): ```sh c="docker compose -p openrows -f docker/compose.yml -f docker/selfhost.yml --env-file /srv/openrows/.env" $c build $c up -d --wait $c ps ``` To run the GHCR image instead of building, set `IMAGE=ghcr.io/draggable/openrows:sha-` in the env file and use `$c pull` then `$c up -d --wait --no-build`. Compose refuses to start while a required value is empty (`${VAR:?}`), and the server then refuses defaults and placeholders. ## Kubernetes (Helm) The chart in [deploy/helm/openrows](https://github.com/Draggable/openrows/tree/main/deploy/helm/openrows) runs the same `oss` image as the Compose stack: the api (Deployment and Service), the worker (Deployment), the migrations (a Job), and optionally a Postgres 16 StatefulSet, a Valkey and an Ingress. It isn't published to a chart repository yet: install it from a checkout. It never runs the hosted edition. What the chart does for you: - **Migrations first.** On `helm install` the migrate Job starts with everything else and the api and worker pods wait for it (their `wait-for-migrations` init container). On `helm upgrade` it is a `pre-upgrade` hook: the migrations run before any new pod, and a failed migration stops the upgrade with the old pods still serving. - **Secrets from your Secret only.** The chart never creates a Secret and has no default secret. It refuses to render without `existingSecret`, and on install or upgrade it refuses a Secret that lacks a key it needs. - **Hardened pods.** Every pod runs as a non-root user with a read-only root filesystem, no privilege escalation, no Linux capabilities, the runtime's seccomp profile, no service account token and resource requests. The values schema refuses to turn those off. - **Probes.** The api's startup and liveness probes are `/health` (the process serves) and its readiness probe `/ready` (Postgres, S3 and, when set, Redis answer). The worker's are its `/health` (pg-boss runs, Postgres answers). `helm test` checks `/health`, `/ready` and the web client through the Service. ### 1. The image Pick an image of the commit you run. CI pushes `ghcr.io/draggable/openrows:sha-` when the push succeeds; otherwise build the `oss` target yourself and push it to a registry your cluster can pull from: ```sh git clone https://github.com/Draggable/openrows.git && cd openrows docker build -f docker/Dockerfile --target oss -t registry.example.org/openrows:sha-$(git rev-parse HEAD) . docker push registry.example.org/openrows:sha-$(git rev-parse HEAD) ``` The chart needs `image.tag` (or `image.digest`): there is no `latest`, because an image and its migrations belong together. Use the chart from the same checkout as the image. ### 2. The Secret Create one Secret in the release's namespace with these keys. With the bundled Postgres, the two database passwords go into URLs the chart builds, so make them URL-safe (hex): ```sh kubectl create namespace openrows kubectl -n openrows create secret generic openrows-secrets \ --from-literal=ENCRYPTION_KEY="$(openssl rand -base64 32)" \ --from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 24)" \ --from-literal=OPENROWS_APP_DB_PASSWORD="$(openssl rand -hex 24)" \ --from-literal=OIDC_CLIENT_SECRET='' \ --from-literal=S3_ACCESS_KEY='' \ --from-literal=S3_SECRET_KEY='' ``` | Key | When | | --- | --- | | `ENCRYPTION_KEY`, `OIDC_CLIENT_SECRET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | always | | `POSTGRES_PASSWORD`, `OPENROWS_APP_DB_PASSWORD` | the bundled Postgres (`postgresql.enabled`, the default): its superuser, and the `openrows_app` role the migrate Job gives that password | | `DATABASE_URL`, `MIGRATION_DATABASE_URL`; optional `INDEX_DATABASE_URL`, `OPENROWS_APP_DB_PASSWORD` | an external Postgres (`postgresql.enabled: false`): the `openrows_app` URL, the schema owner's URL, the owner's URL for the worker's field index builds, and the password the migrate Job gives `openrows_app` (leave it out when your DBA manages the role; see [docs/deploy/database.md](https://github.com/Draggable/openrows/blob/main/docs/deploy/database.md)) | | `VALKEY_PASSWORD` | the bundled Valkey (`valkey.enabled`); without it, an optional `REDIS_URL` points at yours | | `SMTP_PASSWORD` | with `smtp.user` | | `OIDC_ADMIN_CLIENT_SECRET` | with `oidc.adminClientId` | | `TURNSTILE_SECRET_KEY` | with `turnstile.siteKey` | | `AI_API_KEY` | optional, with `ai.model` | Secrets managed elsewhere (External Secrets, Sealed Secrets, Vault) work the same way: the chart only reads the Secret by name. ### 3. Your values ```yaml # openrows-values.yaml image: repository: registry.example.org/openrows tag: sha- appUrl: https://openrows.example.org existingSecret: openrows-secrets oidc: issuerUrl: https://auth.example.org/realms/openrows s3: endpoint: https://s3.eu-west-1.amazonaws.com region: eu-west-1 bucket: openrows-attachments smtp: host: smtp.example.org user: openrows from: OpenRows trustProxy: enabled: true # the ingress controller sets X-Forwarded-For ingress: enabled: true className: nginx tls: - secretName: openrows-tls hosts: [openrows.example.org] ``` [values.yaml](https://github.com/Draggable/openrows/blob/main/deploy/helm/openrows/values.yaml) documents every value and `values.schema.json` checks them: an http `appUrl`, an unknown key, an image without a digest for Postgres or Valkey, or a secret-looking name in `extraEnv` (more non-secret variables, such as `CLAMAV_URL`) fails the render. The settings behave as in "Configuration" below; the chart sets `NODE_ENV=production`. ### 4. Install ```sh helm install openrows ./deploy/helm/openrows -n openrows -f openrows-values.yaml --wait --timeout 10m helm test openrows -n openrows ``` The api serves everything on one Service port: the web client, `/api`, `/auth`, `/mcp` and the realtime WebSocket at `/ws`. Your ingress or proxy must pass WebSocket upgrades and keep idle connections open; the default `ingress.annotations` do that for ingress-nginx (one-hour read and send timeouts, 64 MB bodies for CSV imports). For another controller, replace them with its equivalents. Without `ingress.enabled`, point your own proxy at the Service. ### Upgrades and operations - **Upgrade:** build or pick the new commit's image, then `helm upgrade openrows ./deploy/helm/openrows -n openrows -f openrows-values.yaml --set image.tag=sha- --wait`. The migrate hook runs first; `kubectl -n openrows logs job/openrows-migrate-` shows its output, and a failed run stays until the next upgrade. - **Rollback:** `helm rollback openrows ` brings back the old pods, but not the database: migrations only move forward, so restore a backup to undo one (see "Backups and restore"). - **Scaling:** `api.replicas` and `worker.replicas` can be more than one. Realtime runs through Postgres, so the api needs no sticky sessions, and the worker's cron jobs run once per occurrence across replicas. - **Backups:** the bundled Postgres keeps its data on a PersistentVolumeClaim (`postgresql.persistence`), which `helm uninstall` leaves behind. Back it up with `pg_dump` (`kubectl -n openrows exec statefulset/openrows-postgresql -- pg_dump -U openrows -Fc openrows > openrows.dump`) or your storage's snapshots, and the bucket with your provider's tools. For production, prefer a managed or operator-run Postgres. - **Not in the chart:** Keycloak (use your own OIDC provider, configured as in "Sign-in"), ClamAV (run clamd and set `extraEnv.CLAMAV_URL`), run-script automation steps (they need a Docker daemon), NetworkPolicies, a PodDisruptionBudget and autoscaling. ## Configuration `compose.yml` builds `DATABASE_URL` (as `openrows_app`), `MIGRATION_DATABASE_URL` and the worker's `INDEX_DATABASE_URL` (as `PG_USER`) from the `PG_*` values, and sets `NODE_ENV=production`. The full annotated list is [.env.example](https://github.com/Draggable/openrows/blob/main/.env.example). To use a Postgres outside the stack, override those URLs in your overlay and create the roles as [docs/deploy/database.md](https://github.com/Draggable/openrows/blob/main/docs/deploy/database.md) describes: an owner that is a superuser or has `BYPASSRLS` for migrations, and `openrows_app` for the app. | Variable | Required | What it does | | --- | --- | --- | | `PG_DB`, `PG_USER`, `PG_PASSWORD` | yes | The stack's Postgres database and superuser; `migrate` and the worker's index builds connect as it | | `OPENROWS_APP_DB_PASSWORD` | yes | The password `migrate` gives `openrows_app`, the role the api and worker use. Use a different one from `PG_PASSWORD` | | `APP_URL` | yes | The app's public https origin. Sign-in redirects to `${APP_URL}/auth/callback`; it is also the CORS origin and the origin state-changing requests must come from | | `ENCRYPTION_KEY` | yes | At least 32 characters (`openssl rand -base64 32`). Encrypts stored OIDC tokens, the login cookie and secrets such as webhook signing secrets. The api and worker need the same key; changing it signs everyone out | | `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` | yes | Sign-in (see "Sign-in"). `compose.yml` defaults `OIDC_CLIENT_ID` to `openrows-app`; `OIDC_SCOPES` defaults to `openid email profile` | | `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY` | yes | Object storage; `S3_REGION` defaults to `us-east-1`, `S3_PUBLIC_URL` is optional (see "Storage") | | `TRUST_PROXY`, `TRUST_PROXY_HOPS` | behind a proxy | `true` and the number of proxies that append to `X-Forwarded-For`. Only when the api is reachable through the proxy alone | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM` | no | The app's email. Without `SMTP_HOST`, mail is logged, not sent | | `OPENROWS_SYSTEM_ADMINS` | no | Comma-separated emails, synced at each sign-in with a verified email. Grants no access to anyone's workspaces; the ops console it unlocks is part of the hosted edition only | | `AUTH_PROVIDERS` | no | Identity-provider aliases (e.g. `github,google`) shown as "Continue with" buttons, passed as `kc_idp_hint` | | `STATUS_PAGE_URL` | no | Your status page (https in production). The sign-in page, the help menu and the command palette link to it. Unset: no link | | `OIDC_ADMIN_CLIENT_ID`, `OIDC_ADMIN_CLIENT_SECRET` | no | Worker only: a Keycloak service account that deletes a deleted account's user at Keycloak | | `TURNSTILE_SECRET_KEY`, `TURNSTILE_SITE_KEY` | for public forms | Cloudflare Turnstile, both or neither. Without them, production refuses to publish public forms or accept submissions | | `CLAMAV_URL` | no | `tcp://host:port` of clamd, on the api and the worker (see "Security notes") | | `AUTH_RATE_LIMIT`, `SHARE_RATE_LIMIT`, `FORM_SUBMIT_RATE_LIMIT` | no | Per-IP limits: `/auth/*` per minute (30), public share link API per minute (600), public form submissions per form in 10 minutes (20) | | `API_RATE_LIMIT_BASE`, `API_RATE_LIMIT_BASE_BURST`, `API_RATE_LIMIT_TOKEN`, `API_RATE_LIMIT_TOKEN_BURST`, `RATE_LIMIT_STORE` | no | REST API buckets: 5/s burst 10 per base, 50/s burst 100 per token; `postgres` (shared) or `memory` | | `OUTBOUND_HTTP_ALLOW_HTTP`, `OUTBOUND_HTTP_PORTS`, `OUTBOUND_HTTP_ALLOW`, `OUTBOUND_HTTP_DENY` | no | Webhooks, automations, the Airtable import and synced tables' CSV URLs may only call https on ports 80 and 443 at public addresses. Allow http, more ports or internal targets (`n8n.lan,10.0.5.0/24`); the deny list wins. A synced table's Postgres host follows the same address rules (any port): allow your database's host or network here to sync from it | | `ATTACHMENT_GC` | no | Worker: `dry-run` (default) counts unreferenced files, `on` deletes them, `off` skips the job | | `AUTOMATION_EMAIL_DAILY_LIMIT`, `OUTBOUND_WORKSPACE_RATE_PER_MINUTE`, `OUTBOUND_WORKSPACE_BURST`, `OUTBOUND_WORKSPACE_CONCURRENCY` | no | Per-workspace limits on automation emails (100 a day) and outbound requests; over them, work waits | | `AUTOMATION_SCRIPTS` and `AUTOMATION_SCRIPT_*` | no | Run-script automation steps, `off` by default (see "Security notes") | | `EXTENSIONS_CUSTOM_BUNDLES` | no | Who may install third-party extension bundles in a base's extension panel: `creators` (and owners), `owners` or `off` (built-in extensions only). Unset: `creators` (`owners` with `EDITION=cloud`) | | `REVISION_RETENTION_DAYS` | no | Worker: how long record history is kept, 1 to 36,500 days (365) | | `COMPUTED_SYNC_LIMIT` | no | Stale records per computed field a write recomputes itself (5000); beyond that the worker does it | | `AIRTABLE_API_URL`, `AIRTABLE_ATTACHMENT_HOSTS` | no | Where the Airtable importer reads from: `https://api.airtable.com` and `dl.airtable.com,*.airtableusercontent.com` by default | | `AI_BASE_URL`, `AI_MODEL`, `AI_API_KEY`, `AI_TIMEOUT_SECONDS` | no | The server's AI provider: any OpenAI-compatible API (`https://api.openai.com/v1` by default; Anthropic's `https://api.anthropic.com/v1`, OpenRouter, or Ollama at `http://ollama:11434/v1` with no key). With `AI_MODEL` set, AI features are on in every workspace. Without it, workspace owners can bring their own key in Settings → AI | | `AI_ALLOWED_HOSTS` | no | The hosts a workspace's own AI base URL may use (`api.openai.com,*.example.com`); unset, any public host the outbound rules allow | | `LOG_LEVEL` | no | `info` by default | | `REDIS_URL`, `EVENT_BUS` | no | Nothing needs Redis yet; `/ready` checks it when set. `EVENT_BUS=redis` isn't implemented and the server refuses to start with it | With `NODE_ENV=production` the api refuses to start, and logs "invalid configuration, refusing to start" with every problem, when: - `APP_URL`, `DATABASE_URL`, `ENCRYPTION_KEY`, any `OIDC_*` value above or any of the four `S3_*` values is missing; - `ENCRYPTION_KEY`, `OIDC_CLIENT_SECRET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `SMTP_PASSWORD`, `OIDC_ADMIN_CLIENT_SECRET`, or the password in `DATABASE_URL` or `REDIS_URL` is a known default or placeholder (`openrows_dev`, `minioadmin`, `secret`, `password`, `changeme`, or anything containing `change-me`, `replace-me` or `example`); `migrate` refuses the same for `OPENROWS_APP_DB_PASSWORD`; - `APP_URL`, `OIDC_ISSUER_URL` or `AIRTABLE_API_URL` isn't https; - `SMTP_USER` is set but `SMTP_SECURE` is neither `true` nor `starttls`; - `DATABASE_URL` connects as a superuser or a role with `BYPASSRLS`. Row-level security is the second layer of tenant isolation, so the server must run as `openrows_app`. ## Sign-in (OIDC) The server is a backend-for-frontend: it runs the authorization code flow with PKCE (S256) itself and gives the browser only an opaque session cookie. Any OpenID Connect provider works if it: - serves discovery at `${OIDC_ISSUER_URL}/.well-known/openid-configuration`, with an issuer that matches `OIDC_ISSUER_URL` exactly and that the api container can reach; - has a confidential client (client secret) for the standard flow, with the redirect URI `${APP_URL}/auth/callback` and post-logout redirects to `${APP_URL}/`; - puts `email` and `email_verified: true` in the ID token. OpenRows refuses any sign-in with an unverified email; - keeps its end-session endpoint on the issuer's origin: the CSP allows sign-out's redirect only to that origin. Users are matched by issuer and subject. While a session is in use the server refreshes the provider's refresh token every 5 minutes, so a user disabled at the provider is signed out. To run Keycloak in your stack, turn on `selfhost.yml`'s `keycloak` profile (`COMPOSE_PROFILES=keycloak`) with `KC_HOSTNAME`, the `KC_*` secrets and the SMTP values: it adds the `keycloak-db` one-shot (Keycloak's own database in the stack's Postgres) and Keycloak on `KEYCLOAK_BIND` (loopback), for your proxy to publish its realm paths (keep `/admin/` off the public name). The realm file [docker/keycloak/openrows-realm.prod.json](https://github.com/Draggable/openrows/blob/main/docker/keycloak/openrows-realm.prod.json) names the hosted service's URLs: change them to yours before the first start (the realm is imported only once), or create the realm and client by hand. [docs/deploy/keycloak.md](https://github.com/Draggable/openrows/blob/main/docs/deploy/keycloak.md) lists every realm and client setting, the optional Google and GitHub providers and the `openrows-admin` service account. Keycloak sends the sign-up and password-reset mail itself; test its SMTP before you turn on email verification. ## Storage Attachments, thumbnails and base snapshots (under `snapshots/`) live in the bucket. Any S3-compatible service works (path-style URLs, SigV4); `/ready` checks the bucket with a signed request. - Browsers upload and download with short-lived presigned URLs, so they must reach the bucket: at `S3_ENDPOINT`, or at `S3_PUBLIC_URL` (the bucket's base URL, bucket included) when they go through another host that keeps the path and Host header. - Give the bucket a CORS rule for `APP_URL`: methods `PUT`, `GET`, `HEAD`, header `content-type`, exposing `ETag`. - Keep it private. Every URL is signed, and everything but raster images downloads as an attachment. Serve it from a registrable domain other than the app's. - The CSP's `img-src` and `connect-src` follow the origin of `S3_PUBLIC_URL`, or of `S3_ENDPOINT`. - For Cloudflare R2, use `S3_REGION=auto` and leave `S3_PUBLIC_URL` empty. For AWS, `https://s3..amazonaws.com` and the bucket's region. ## Email The api and worker send invitations, notification emails and digests, automation emails and notices through `SMTP_*`. Use `SMTP_SECURE=starttls` on 587 (STARTTLS required) or `true` on 465, and set `SMTP_FROM` to your own domain. Send a test from the worker: ```sh $c exec worker node dist/email/send-test-cli.js you@example.com ``` ## Background worker The worker runs pg-boss jobs in Postgres: the outbox relay (which feeds webhooks, automations and background recomputes), webhook deliveries, automations, notification emails and digests, CSV and Airtable imports, snapshot restores, daily base snapshots, computed fields too large to recompute in a request, field indexes for saved views, the trash purge after 30 days, account and workspace deletion, attachment garbage collection, and the hourly prunes. Without it, none of these run. - Health: `GET /health` on port 3001 inside the container (the Compose healthcheck), with Prometheus metrics at `/metrics`. The api serves its metrics on `METRICS_PORT` (9464 in `compose.yml`), never published. - Failed jobs retry 5 times with backoff, then move to the queue's `.dead` queue for 30 days. - `$c up -d --scale worker=2` is safe: scheduled jobs run once across workers. - Queue a scheduled job now: `$c exec worker node dist/jobs/run-cli.js ` (it lists the queues when run without one). - `ATTACHMENT_GC` ships as `dry-run`: files of purged records stay in the bucket until you set it to `on`. ## Backups and restore Back up three things: the Postgres database (`openrows`, plus `keycloak` if Keycloak keeps its data in the same Postgres), the bucket, and your env file. Base snapshots sit in the same bucket, so they don't replace a backup of it. A consistent dump, taken inside the container: ```sh $c exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB"' > openrows-$(date -u +%Y%m%dT%H%M%SZ).dump ``` For a production setup, run that dump on a schedule (a systemd timer or cron job), encrypt it, copy it off the host (an S3 bucket in another region works well), alert when the newest backup is older than a day, and regularly restore one into a throwaway stack to prove it works. To restore: stop `api` and `worker`, make sure the roles exist (`$c run --rm migrate` creates `openrows_app`), recreate the database empty, restore it with `pg_restore --exit-on-error --single-transaction`, then start the stack again with `$c up -d --wait`, which applies any migrations newer than the backup. Users also export their own data: a base from the base menu (**Export base (ZIP)**) and their account from **Settings**. ## Upgrading ```sh git pull $c build $c up -d --wait ``` `up` runs `migrate` first, under an advisory lock, then recreates `api` and `worker` on the new image; the job queue's schema is upgraded the same way. Migrations only go forward: back up before upgrading, because going back across a migration means restoring that backup. ## Security notes - Publish only the api, on loopback, behind your TLS proxy. Never publish Postgres. Set `TRUST_PROXY` only when the proxy is the only way in, or clients choose their own IP for rate limits. - Pass WebSocket upgrades on `/ws` through the proxy, with the `Origin` header and a read timeout above 30 seconds (the gateway pings every 30 s and checks `Origin` against `APP_URL`). - The api sets the CSP, HSTS (when `APP_URL` is https) and the other security headers itself. Don't add a second CSP at the proxy: browsers enforce both. - Malware scanning: turn on `selfhost.yml`'s `clamav` profile and set `CLAMAV_URL=tcp://clamav:3310` (the overlay passes it to the api and the worker). Uploads then wait as "scanning" until the worker's scan passes them; infected files are quarantined, and a scanner that is down never lets a file through. clamd needs about 1.5 GB of memory and downloads its signatures on first start. - Run-script automation steps are off. Turning them on (`AUTOMATION_SCRIPTS=local-container`) runs each script in a locked-down `docker run`, which needs a Docker daemon, and whatever reaches a daemon is root on its host: read [docs/deploy/automation-scripts.md](https://github.com/Draggable/openrows/blob/main/docs/deploy/automation-scripts.md) first. - Extensions run in a sandboxed frame without your app's origin, cookies or network, and reach data only through the scopes their manifest declares, as the person viewing them. A third-party bundle is still code you run in your users' browsers (a frame can always navigate itself away, carrying what it has read in the URL), so `EXTENSIONS_CUSTOM_BUNDLES` limits who may install one: set `owners` or `off` to tighten it. - Outbound requests to user-chosen URLs never reach private addresses unless you allow them with `OUTBOUND_HTTP_ALLOW`, and never the cloud metadata service. ## Troubleshooting | Symptom | Check | | --- | --- | | The api exits right after start | `$c logs api`: "invalid configuration, refusing to start" lists each variable, or "bypasses row-level security" means `DATABASE_URL` isn't `openrows_app` | | `api` and `worker` never start | `$c logs migrate`: it must exit 0. It refuses a default `OPENROWS_APP_DB_PASSWORD` | | `/ready` answers 503 | Its `checks` say which dependency fails: `database`, `s3` or `redis` | | Sign-in fails | The issuer at `${OIDC_ISSUER_URL}/.well-known/openid-configuration` must match exactly and be reachable from the container; the redirect URI must be `${APP_URL}/auth/callback`; the email must be verified | | Sign-out isn't redirected | The provider's end-session endpoint is on another origin than its issuer | | Uploads or thumbnails fail in the browser | The bucket's CORS rule for `APP_URL`, and whether browsers reach `S3_PUBLIC_URL` or `S3_ENDPOINT` | | No live updates, "offline" banner | `/ws` upgrades through the proxy, and the `Origin` header matches `APP_URL` | | Public forms won't publish | `TURNSTILE_SECRET_KEY` and `TURNSTILE_SITE_KEY` must reach the api container | | No email | Without `SMTP_HOST` mail is only logged; send a test with `send-test-cli.js` | | Imports, webhooks or snapshots never finish | The worker's health and logs; "INDEX_DATABASE_URL is not set" means no field indexes are built | | A webhook to your LAN is refused | Allow the target with `OUTBOUND_HTTP_ALLOW` (and `OUTBOUND_HTTP_ALLOW_HTTP`, `OUTBOUND_HTTP_PORTS`) on the api and worker | | "Sync data" from a database or CSV on your LAN says "private or reserved address" | Allow the host with `OUTBOUND_HTTP_ALLOW` on the api and worker (a CSV over http also needs `OUTBOUND_HTTP_ALLOW_HTTP`, and a port besides 80/443 `OUTBOUND_HTTP_PORTS`) | Logs are JSON lines: `$c logs -f api worker`. `GET /health` answers while the process is up and names the edition; `GET /ready` checks the dependencies. # Audit log Source: https://openrows.io/docs/audit-log On the hosted service's Enterprise plan, every workspace keeps an audit log: who did what, when and from where. Owners open it from the workspace menu (**Audit log**). Self-hosted OpenRows doesn't keep one; its activity stays in each record's history and the base's trash. ## What is recorded Each entry has the time, the person (their email when it happened, and whether they used the app, a personal access token, or were OpenRows support), their IP address and browser, what they changed, and a short before and after. Entries never hold passwords, tokens, secrets or the values in your records. | Activity | Entries | | --- | --- | | Sign-ins | Every sign-in of a member, in each of their workspaces | | Workspace | Renaming or deleting the workspace | | Members and invitations | Joining, role changes, removals, invitations sent and revoked, base collaborators added, changed and removed | | Share links | Links to views and bases created, changed (password, domains, copy and download, embeds, expiry) and revoked | | Access tokens and webhooks | Personal access tokens created and revoked (their name, prefix and scopes), webhooks created, changed and deleted (the host only) | | Exports | Table CSV downloads, base exports and audit log exports | | Deletions | Bases, tables, fields and views deleted, bases restored or deleted permanently | | Settings | The workspace's AI provider and key changed or removed (never the key) | | Automations | Automations created, changed (on or off, trigger and steps) and deleted | | Support access | OpenRows support starting and ending a read-only view of a member's account, with the reason | Entries can't be changed or deleted by anyone, owners included. They're kept for the plan's retention (365 days on Enterprise) and then removed automatically. When a workspace leaves the Enterprise plan, recording stops and the existing entries are kept for 30 days. ## Filter and export Filter by activity, by a person's email or an item's name or id, and by date. **Export CSV** and **Export JSON** download what the filters show (up to 100,000 entries, newest first). The JSON export has one entry per line. ## Stream to your SIEM Under **Stream to your SIEM**, enter an HTTPS endpoint. Every minute OpenRows sends the new entries, oldest first and up to 100 at a time, as a `POST` with a JSON body: ```json { "workspaceId": "…", "events": [ { "id": "…", "at": "2026-10-29T10:00:00.000Z", "action": "member.role_change", "actor": { "kind": "user", "userId": "…", "email": "ada@example.com" }, "ip": "203.0.113.9", "userAgent": "Mozilla/5.0 …", "target": { "type": "user", "id": "…", "name": "bob@example.com" }, "summary": { "before": { "role": "editor" }, "after": { "role": "creator" } } } ] } ``` Requests are signed like [webhooks](https://openrows.io/docs/api/webhooks): verify `X-OpenRows-Signature` with the signing secret shown once when you create the stream (or ask for a new one). Answer with a 2xx status. Anything else is retried the next minute with the same batch; after 50 failures in a row the stream turns itself off, and the page shows why. Entry ids sort by time, so a receiver can skip ids it has already stored. # Single sign-on Source: https://openrows.io/docs/single-sign-on On the hosted service's Business and Enterprise plans, a workspace can let its people sign in with your organization's identity provider: anything that speaks SAML 2.0 or OpenID Connect, such as Okta, Microsoft Entra ID, Google Workspace, OneLogin, JumpCloud or Keycloak. Owners set it up from the workspace menu (**Single sign-on**). Self-hosted OpenRows signs in through your own Keycloak, where you add identity providers directly. ## Set it up 1. **Add OpenRows as an app at your identity provider.** The settings page shows what to enter there: - OpenID Connect: the **redirect URI**. Give the app the authorization code grant and note its client ID and secret. - SAML 2.0: the **entity ID** (audience), the **ACS URL** (single sign-on URL) and the metadata URL. Send the email address as the NameID (emailAddress format) and the attributes `email`, `firstName` and `lastName`, and sign the response or the assertion. 2. **Connect it in OpenRows.** Pick the protocol and enter, for OpenID Connect, the issuer URL (OpenRows reads its discovery document), the client ID and the client secret; for SAML, the identity provider's entity ID, its SSO URL and its signing certificate. The secret is never shown again: leave the field empty to keep it. 3. **Verify your email domains.** Add each domain your people's addresses use, then add the TXT record the page shows at your DNS provider (name `_openrows-verification.`, value `openrows-verification=…`) and select **Verify**. DNS changes can take a while to show. A domain can belong to one workspace only. ## How people sign in On the sign-in page they choose **Sign in with SSO** and enter their work email. OpenRows finds your provider by the address's domain and sends them straight to it. - **Join on first sign-in.** With this on (the default), someone whose address is on a verified domain joins the workspace the first time they sign in through your provider, with the role you choose (creator, editor, commenter or read only; never owner). On a plan with an editor limit, they don't join when no editor seat is left; invite them instead. - **Require single sign-on.** With this on, addresses on your verified domains can't sign in with a password, Google or GitHub, only through your provider. The workspace's owners keep every way in, so a broken provider can't lock them out. - Your provider can only sign in addresses on your verified domains. A sign-in through it with any other address is refused. ## Audit log On the Enterprise plan, the audit log records changes to single sign-on and its domains, people joining through it, and each sign-in with its method (`sso` through your provider). ## Turn it off Uncheck **On** to pause it, or **Remove** it to delete the provider and its domains. People who joined keep their membership; remove them on the Members page if needed. # SCIM provisioning Source: https://openrows.io/docs/scim-provisioning On the hosted service's Business and Enterprise plans, your identity provider can manage who is in a workspace with SCIM 2.0: Okta, Microsoft Entra ID, OneLogin, JumpCloud and anything else that provisions apps over SCIM. People you assign to OpenRows join the workspace, changes to their names and groups follow, and when you deactivate or unassign someone they leave the workspace and are signed out. Owners set it up on the workspace's **Single sign-on** page, under **SCIM provisioning**. Self-hosted OpenRows doesn't offer SCIM. ## Before you start Provisioned people need an email address on one of the workspace's **verified domains** (see [Single sign-on](https://openrows.io/docs/single-sign-on), "Verify your email domains"). OpenRows refuses addresses on other domains, so an identity provider can only manage people whose domain the workspace has proved it owns. ## Set it up 1. On **Single sign-on → SCIM provisioning**, select **Generate token** and copy it. It is shown only once; OpenRows keeps only a hash of it. 2. In your identity provider's OpenRows app, turn on SCIM provisioning and enter: - **SCIM base URL** (also called tenant URL or connector base URL): the URL the page shows, `https://app.openrows.io/scim/v2`. - **Authentication**: HTTP header / bearer token, with the token from step 1. - **Unique identifier**: `userName` (the person's email address). 3. Turn on creating, updating and deactivating users, and push the groups you want to map to roles. **Rotate token** makes a new one and stops the old one at once: enter the new one in your identity provider straight away. **Revoke** stops provisioning until you generate another token; people already provisioned stay as they are. ## What provisioning does - **New people** join the workspace with a role (below). Someone who hasn't signed in to OpenRows yet joins at their first sign-in with that address. - **Roles come from groups.** Each group your identity provider pushes is listed on the settings page; pick the role its members get (Creator, Editor, Commenter or Read only). A person in several mapped groups gets the highest of their roles; people in no mapped group get the **Role for people in no mapped group** (Editor unless you change it). Provisioning never makes anyone an owner. - **Deactivating or deleting someone** removes them from the workspace, including their roles on single bases, and ends all their OpenRows sessions. Reactivating them brings them back with their role. - **Owners are never changed.** Provisioning doesn't change the role of, remove or sign out a workspace owner, so a mistake at the identity provider can't lock you out. Manage owners in OpenRows. - **Seats** follow: people who join with an editor role or above count as editors on your plan, and your subscription's seats are updated as they come and go. When the plan has no seat left, the identity provider is told so and can retry later. - **Audit log** (Enterprise): every change is recorded with "Identity provider (SCIM)" as the actor: users and groups created, changed and deleted, people joining, changing role and leaving. Token changes and group role mappings are recorded as the owner who made them. ## What's supported OpenRows implements the parts of RFC 7643 and RFC 7644 identity providers use: `/Users` and `/Groups` with create, read, replace (`PUT`), `PATCH` (add, replace and remove, with paths such as `emails[type eq "work"].value` and `members[value eq "…"]`) and delete; filters (`eq`, `ne`, `co`, `sw`, `ew`, `pr`, `gt`, `ge`, `lt`, `le`, `and`, `or`, `not`); pagination with `startIndex` and `count` (at most 200 per page); and `/ServiceProviderConfig`, `/ResourceTypes` and `/Schemas`. Bulk operations, sorting, ETags and password changes aren't supported. A workspace can have up to 10,000 SCIM users and 1,000 groups. # Admin console and workspace policies Source: https://openrows.io/docs/admin-console On the hosted service's Enterprise plan, a workspace's owners have an **Admin console**: open the workspace menu and select **Admin console**. It shows the workspace's policies, its single sign-on and SCIM status, and everyone with access. Self-hosted OpenRows doesn't offer the console. ## Policies Policies limit what people in the workspace can share and connect, on top of their roles. OpenRows enforces them on the server, so they hold for the app, the REST API and every integration alike. Each change is recorded in the workspace's [audit log](https://openrows.io/docs/audit-log) as "Changed workspace policies", with what was on before and after. - **Allow public share links of views and bases.** Turned off, no one can create a share link, and existing links stop opening. They open again if you turn the policy back on, unless they were revoked in the meantime. - **Allow public forms.** The same for form links: published forms stop accepting answers. - **Allow embedding share links on other sites.** Turned off, links can't be given embed origins and no other site can frame a link. - **Allow personal access tokens.** Turned off, tokens can't reach the workspace's data: the REST API, the SDK, MCP clients and n8n stop working with it, and new tokens can't be granted the workspace or its bases. People signed in to OpenRows aren't affected. - **Invitations only to these email domains.** With domains listed, invitations to the workspace, its bases and its interfaces can only go to addresses on them. Invitations already sent to other addresses can't be accepted. To require single sign-on, verify email domains or connect SCIM, use the workspace's **Single sign-on** page ([Single sign-on](https://openrows.io/docs/single-sign-on), [SCIM provisioning](https://openrows.io/docs/scim-provisioning)). If the workspace leaves the Enterprise plan, its policies keep applying. Owners can still turn them off, but not add new ones. ## People The **People** list shows everyone with access: members with their workspace role, and collaborators on some bases or interfaces only, with the bases they're on. **Last active** is the last time someone used the workspace, to within ten minutes. People managed by your identity provider are marked **SCIM**. # Zapier Source: https://openrows.io/docs/zapier The OpenRows app for Zapier connects your bases to the thousands of apps Zapier works with: start a Zap when a record is added or changed, and create, update, find or delete records from any other app. > **Hosted only.** The Zapier app works with the hosted OpenRows at app.openrows.io. A self-hosted server can't be connected to it; use [webhooks](https://openrows.io/docs/api/webhooks), the [REST API](https://openrows.io/docs/api) or the n8n nodes instead. The app is coming to Zapier's app directory. Until it's listed there, it shows up in Zapier only for people who have been invited to it; once it's listed, this page links to it so you can add it from there. ## Connect your account 1. In a Zap, pick **OpenRows** as the app and select **Sign in**. 2. Sign in to OpenRows if you aren't already. The consent screen shows what Zapier asks for (reading and writing records, reading your bases' tables and fields, managing webhooks for instant triggers, and your email address) and lets you choose the workspaces or single bases it may reach. 3. Select **Allow**. The connection appears in Zapier under your email address. Zapier acts as you: it can only do what you can do in the bases you picked, and it loses access when you do. To disconnect it, open **Settings → Integrations** in OpenRows and revoke Zapier, or remove the connection in Zapier. ## Triggers Both triggers are instant: OpenRows sends each change to Zapier as it happens, through a webhook it registers on the base when you turn the Zap on (and deletes when you turn it off). A base has at most 10 webhooks, so at most 10 Zaps with an OpenRows trigger can watch one base. | Trigger | Starts when | | --- | --- | | **New Record** | A record is created in the table, including public form submissions and records added through the API. | | **New or Updated Record** | A record is created or any of its fields change. Pick **Fields to Watch** to start only when one of those fields changes. | Pick the base and the table. Optionally pick a **View** to start the Zap only for records in that view (its filters, at the moment of the change), and for New or Updated Record the fields to watch. Each trigger gives the record as it is now, with every field by name, plus its **Record ID**, **Created Time**, **Record URL** (the link that opens it in OpenRows), the **Event** (`record.created` or `record.updated`) and the **Changed Fields** of an update. Every delivery is signed by OpenRows and checked by the app, so nothing else can start your Zap. When you test the trigger in the Zap editor, the samples are the table's (or view's) newest records. ## Actions and searches | Action | What it does | | --- | --- | | **Create Record** | Adds a record with the fields you fill in. | | **Update Record** | Changes the fields you fill in on a record; fields you leave empty stay as they are. | | **Delete Record** | Moves a record to the base's trash, where it can be restored for 30 days. | | **Find Record** | Finds records whose field equals a value (text matches ignore case), or that match a [filter formula](https://openrows.io/docs/api#filterbyformula), optionally in a view's order. Turn on Zapier's option to create a record when none is found to add one when nothing matches (Create Record's fields are then shown too). | For Update Record and Delete Record, choose the **Record** from the list of the table's records (by their primary field, newest first when the table has a created time or autonumber field), or map a **Record ID** from an earlier step, such as an OpenRows trigger or Find Record. The field's search button adds a Find Record step for you. Every action and search gives the record's **Record ID**, **Created Time** and **Record URL** too. After you pick the base and table, the action shows one input per field of the table, with its type: numbers, checkboxes, dates, select options to choose from, and lists for multiple selects. Computed fields (formulas, lookups, rollups, counts, AI fields, created and modified times) and attachments can't be set. For a linked record field, enter the IDs of the records to link (from an earlier step); for a user field, the person's OpenRows user ID. **Convert Values** lets OpenRows read text as the field's type where it can, for example a date written out or a select option by name. Adding a new select option this way needs permission to change the base's schema, which the Zapier app doesn't ask for, so pick existing options. ## Limits - Zaps share your account's API rate limits: 5 requests a second per base and 50 per connection. - Edit permissions on tables and fields apply to Zapier as they do to you; a refused write shows OpenRows' reason in the Zap's history. - The app doesn't upload attachments or change a base's tables and fields yet. # Make Source: https://openrows.io/docs/make The OpenRows app for [Make](https://www.make.com) connects your bases to the apps Make works with: start a scenario when a record is added or changed, and create, update, get, search or delete records from any other app. > **Hosted only.** The Make app works with the hosted OpenRows at app.openrows.io. A self-hosted server can't be connected to it; use [webhooks](https://openrows.io/docs/api/webhooks) with Make's own Webhooks and HTTP modules, the [REST API](https://openrows.io/docs/api) or the n8n nodes instead. The app is published and in Make's review: until Make approves it for its app directory, it shows up in Make for people who have opened its invite link. ## Connect your account 1. In a scenario, add an **OpenRows** module and select **Create a connection**. 2. Sign in to OpenRows if you aren't already. The consent screen shows what Make asks for (reading and writing records, reading your bases' tables and fields, managing webhooks for Watch Records, and your email address) and lets you choose the workspaces or single bases it may reach. 3. Select **Allow**. The connection appears in Make under your email address. Make acts as you: it can only do what you can do in the bases you picked, and it loses access when you do. To disconnect it, open **Settings → Integrations** in OpenRows and revoke Make, or delete the connection in Make (which revokes it in OpenRows too). ## Watch Records An instant trigger: OpenRows sends each change to Make as it happens, through a webhook it registers on the base when you create the trigger's webhook in Make (and deletes when you delete that webhook). A base has at most 10 webhooks, so at most 10 Make webhooks (and Zapier triggers) can watch one base. Pick the base and the table, then **Watch**: **New records**, or **New and updated records**. For updates you can pick **Fields to watch** to start the scenario only when one of those fields changes; new records always start it. Public form submissions and records added through the API count as new records. Each bundle is the record as it is when Make reads it, with every field by name, plus its **Record ID**, **Created time**, the **Event** (`record.created` or `record.updated`), the **Event ID**, the **Event time** and, for an update, the **Changed field IDs**. Make reads the record from OpenRows for every delivery, so a scenario only ever gets records you can read. If the record was deleted before Make reads it, the run still succeeds: **Record still exists** is false, **Fields** and **Created time** are empty, and **Event cells** holds the values the event carried, keyed by field ID (raw, so a select holds its option's ID). Filter on **Record still exists** if later modules need the record. ## Modules | Module | What it does | | --- | --- | | **Create a Record** | Adds a record with the fields you fill in. | | **Update a Record** | Changes the fields you fill in on a record (by its ID); fields you leave empty stay as they are. | | **Get a Record** | Gets a record by its ID. | | **Search Records** | Finds the records that match a [filter formula](https://openrows.io/docs/api#filterbyformula), optionally only those in a view, sorted by fields you pick, up to the limit you set. | | **Delete a Record** | Moves a record to the base's trash, where it can be restored for 30 days. | | **Make an API Call** | Calls any route of the [REST API v1](https://openrows.io/docs/api) with your connection: enter the path after `https://app.openrows.io/api/v1`, for example `/meta/bases`. | After you pick the base and table, Create and Update show one input per field of the table, with its type: numbers, yes/no for checkboxes, dates, and lists for multiple selects and linked records. Computed fields (formulas, lookups, rollups, counts, AI fields, created and modified times) and attachments can't be set. For a linked record field, enter the IDs of the records to link; for a user field, the person's OpenRows user ID. A select field's help lists its options. **Convert values** lets OpenRows read text as the field's type where it can, for example a date written out, a select option by name or a linked record by its name. Adding a new select option this way needs permission to change the base's schema, which the Make app doesn't ask for, so pick existing options. ## Limits - Scenarios share your account's API rate limits: 5 requests a second per base and 50 per connection. A scenario that goes over them gets a rate-limit error, which Make can retry. - Edit permissions on tables and fields apply to Make as they do to you; a refused write shows OpenRows' reason in the scenario's history. - The app doesn't upload attachments or change a base's tables and fields yet. - A webhook OpenRows turns off after failing for a day (or because you lost access to the base) stops Watch Records until you turn the scenario off and on again. # REST API v1 Source: https://openrows.io/docs/api OpenRows' public, versioned API follows the shape of [Airtable's Web API](https://airtable.com/developers/web/api/introduction), so integrations and scripts written for Airtable work with few changes. It lives under `/api/v1` and is described, with every other route, in the OpenAPI document at `GET /api/openapi.json`. ## Authentication Send a personal access token (Settings → "Developer: personal access tokens"): ``` Authorization: Bearer or_pat_… ``` A token reaches what its owner can reach, narrowed to its scopes and its workspaces or bases. The signed-in session cookie works too (same origin only). Integrations acting for other people register an OAuth app and get `or_oat_…` access tokens that work the same way: see [oauth.md](https://openrows.io/docs/api/oauth). | Scope | Endpoints | | --- | --- | | `schema.bases:read` | `GET /meta/bases`, `GET /meta/bases/:baseId/tables` | | `schema.bases:write` | `POST /meta/bases/:baseId/tables`, `POST …/tables/:table/fields`, `PATCH …/fields/:fieldId`; new select options with `typecast` | | `data.records:read` | listing and reading records | | `data.records:write` | creating, updating and deleting records | | `data.recordComments:read` / `:write` | listing / posting comments | | `webhook:manage` | a base's webhooks (see [Webhooks](#webhooks)) | | `automations:read` | listing and reading a base's automations and their runs | | `automations:manage` | turning automations on and off | | `automations:run` | triggering webhook-received and button-clicked automations | ## With Airtable's client ```js import Airtable from 'airtable'; const base = new Airtable({ apiKey: process.env.OPENROWS_TOKEN, endpointUrl: 'https://app.openrows.io/api', apiVersion: '1.0.0', // the client builds `/v/…` URLs }).base(''); const records = await base('Projects').select({ filterByFormula: "{Status} = 'Done'" }).all(); ``` `test/integration/v1-airtable.test.ts` runs the official client (0.12) against the server on every CI run. ## With the OpenRows SDK [`@openrows/sdk`](https://openrows.io/docs/api/sdk) is a typed client generated from these endpoints' contracts: batching to 10, paging, `Retry-After` on 429 and errors with their request id. It is on npm with provenance: `npm install @openrows/sdk`. ```js import { OpenRows } from '@openrows/sdk'; const openrows = new OpenRows({ apiKey: process.env.OPENROWS_TOKEN }); const done = await openrows .table('', 'Projects') .select({ filterByFormula: "{Status} = 'Done'" }) .all(); ``` ## From n8n [`@openrows/n8n-nodes-openrows`](https://github.com/Draggable/openrows/blob/main/packages/n8n-nodes-openrows/README.md) adds an OpenRows node (records: get, get many, search, create, update, create or update, delete) and an OpenRows Trigger (a signed webhook it registers on activation) to n8n, with a personal access token. In a self-hosted n8n: Settings → Community nodes → Install, `@openrows/n8n-nodes-openrows` (AGPL-3.0-only, so not an n8n verified node and not on n8n Cloud). ## From AI agents (MCP) AI agents (Claude Code, Cursor, any MCP client) use the API through the MCP server: remote at `POST /mcp` with the token as a bearer header, or locally with `@openrows/mcp-server` over stdio (`npx -y @openrows/mcp-server`, on npm). See [mcp.md](https://openrows.io/docs/api/mcp) (releases too) and the `/docs/api/mcp` page. ## Endpoints | Method and path (under `/api/v1`) | Airtable's | | --- | --- | | `GET /meta/bases` | List bases (`permissionLevel`: `read`, `comment`, `edit`, `create`, `owner`) | | `GET /meta/bases/:baseId/tables` | Get base schema | | `POST /meta/bases/:baseId/tables` | Create table (the first field is the primary field) | | `POST /meta/bases/:baseId/tables/:tableIdOrName/fields` | Create field | | `PATCH /meta/bases/:baseId/tables/:tableIdOrName/fields/:fieldId` | Update field (name, description) | | `GET /:baseId/:tableIdOrName` | List records | | `POST /:baseId/:tableIdOrName/listRecords` | List records, parameters in the body | | `POST /:baseId/:tableIdOrName` | Create records (`fields`, or up to 10 `records`) | | `PATCH` / `PUT /:baseId/:tableIdOrName` | Update / replace up to 10 records, `performUpsert` | | `DELETE /:baseId/:tableIdOrName?records[]=…` | Delete up to 10 records | | `GET` / `PATCH` / `PUT` / `DELETE /:baseId/:tableIdOrName/:recordId` | One record | | `GET` / `POST /:baseId/:tableIdOrName/:recordId/comments` | Comments | Tables are named by id or name, and only inside the base in the path. Cells are keyed by field name, or by field id with `returnFieldsByFieldId`; writes accept either. Empty cells are left out of answers. Deletes move records to the trash. ## Ids Ids are OpenRows' UUIDs: a base, table, field, view, record or comment id is the same UUID the app and the internal API use. Airtable's prefixes (`app…`, `tbl…`, `fld…`, `viw…`, `rec…`, `usr…`) aren't used; anything that only passes ids through works unchanged. ## Cell values (`cellFormat=json`) | Field | Value | | --- | --- | | Single select | the choice name | | Multiple select | choice names | | Link to another record | linked record ids | | User, created by, last modified by | `{ id, name, email }` (email for workspace members only) | | Attachment | `[{ id, filename, size, type, width?, height?, url }]`; `url` needs the same credential and redirects to a short-lived signed URL | | Others | as in the app's API (numbers, ISO dates, text, booleans) | Field types answer with Airtable's name (`multipleRecordLinks`, `multipleSelects`, `multilineText`, `phoneNumber`, `multipleAttachments`, `singleCollaborator`, …) and ours in `openrowsType`. Creating a field takes either name; options are OpenRows' (new select choices may be just `{ name }`). ## `filterByFormula` Formulas run as database queries, never row by row. Supported: - `AND(…)`, `OR(…)`, `NOT(…)`, `TRUE()`, `FALSE()` - a field compared with a value: `=`, `!=`, `<`, `<=`, `>`, `>=` (text, numbers, `TRUE()`, `FALSE()`, `BLANK()` or `''`, and `TODAY()` for dates); text comparisons ignore case - a field on its own: checked (checkbox), or not empty - `FIND('text', {Field})` and `SEARCH('text', {Field})`, as a condition, `> 0` or `= 0` (both ignore case) - `IS_BEFORE({Date}, '2026-01-31')`, `IS_AFTER(…)`, `IS_SAME(…, 'day')` (dates compare by day) - `RECORD_ID() = ''` Anything else (other functions, arithmetic, comparing two fields) answers 422 `INVALID_FILTER_BY_FORMULA`. ## Errors ```json { "error": { "type": "INVALID_FILTER_BY_FORMULA", "message": "…", "code": "INVALID_FILTER_BY_FORMULA", "requestId": "…" } } ``` `type` is Airtable's error type where one fits (`AUTHENTICATION_REQUIRED`, `INVALID_PERMISSIONS`, `NOT_FOUND`, `INVALID_REQUEST_UNKNOWN`, `INVALID_MULTIPLE_CHOICE_OPTIONS`, `TOO_MANY_REQUESTS`, `SERVER_ERROR`), `code` OpenRows' stable code. Statuses are OpenRows': 401, 403 (role, scope, or a table or field edit permission), 404 (missing, or outside your access), 409, 413, 422, 429. ## Limits Pages of at most 100 records, batches of at most 10. Rate limits are token buckets kept in Postgres, so every server instance shares them: per base, 5 requests a second with a burst of 10 (every caller of the base together), and per personal access token (on every route) or signed-in user (on `/api/v1`), 50 a second with a burst of 100. Self-hosters change them with `API_RATE_LIMIT_BASE`, `API_RATE_LIMIT_BASE_BURST`, `API_RATE_LIMIT_TOKEN` and `API_RATE_LIMIT_TOKEN_BURST` (`RATE_LIMIT_STORE=memory` keeps them per process). Answers carry `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and `RateLimit-Policy` for the bucket closest to empty; a refusal is 429 `TOO_MANY_REQUESTS` with `Retry-After` and the bucket in `error.limit` (`base`, `token` or `user`). A base the caller can't reach takes nothing from its bucket. Each token's requests per day are shown in Settings. The rendered reference, with these guides and a "From Airtable" page, is at `/docs/api`. ## Webhooks Push notifications of record, field, table and form events, signed with HMAC-SHA256, managed under `/api/v1/bases/{baseId}/webhooks` with the `webhook:manage` scope: see [webhooks.md](https://openrows.io/docs/api/webhooks). ## Automations The automations a base's creators built in the app, under `/api/v1/bases/{baseId}/automations`. Every route needs the base's creator role and its scope; building and changing automations stays in the app. | Method and path (under `/api/v1/bases/:baseId`) | Scope | What | | --- | --- | --- | | `GET /automations` | `automations:read` | The base's automations: trigger, actions, `enabled`, last run, next scheduled run (never the webhook URL or its last request) | | `GET /automations/:automationId` | `automations:read` | One automation | | `GET /automations/:automationId/runs?limit=&status=` | `automations:read` | Its runs of the last 30 days, newest first, with each step's (redacted) input and output | | `PATCH /automations/:automationId` `{ "enabled": true }` | `automations:manage` | Turns it on or off. Turning it on makes it run as you, as in the app; one without actions can't be turned on (422) | | `POST /automations/:automationId/trigger` | `automations:run` | Queues a run: `{ "body": … }` for a webhook-received automation (any JSON, at most 100 KB, as if POSTed to its URL; `{}` when left out), `{ "recordId": "…" }` for a button-clicked one (a click on that record's button by you: you need to be able to edit the record, the table's edit permission included). Answers `{ "queued": true, "triggerId": "…" }`; the run shows in `/runs` once the worker ran it. 409 for an automation that is off or has another trigger | Runs run as the automation's creator, in the worker, under the workspace's monthly runs and outbound limits, exactly as when its own trigger fires. # Webhooks Source: https://openrows.io/docs/api/webhooks OpenRows sends a base's changes to a URL you choose, as signed JSON. Use them to push records into n8n, Zapier, Make or your own code instead of polling the [REST API](https://openrows.io/docs/api). On the hosted service, the [Zapier app](https://openrows.io/docs/zapier) registers and checks a webhook for each Zap's trigger the same way, and the [Make app](https://openrows.io/docs/make) for Watch Records (Make can't check the signature, so it reads each delivery's record again through the API instead). In n8n, the OpenRows Trigger of [`@openrows/n8n-nodes-openrows`](https://github.com/Draggable/openrows/blob/main/packages/n8n-nodes-openrows/README.md) registers and verifies a webhook for you. ## Managing webhooks A base's creators and owners manage its webhooks under **Webhooks** in the base menu, or through the REST API with a session or a personal access token with the `webhook:manage` scope: | Method and path | What it does | | --- | --- | | `GET /api/v1/bases/{baseId}/webhooks` | The base's webhooks (never their secrets) | | `POST /api/v1/bases/{baseId}/webhooks` | Create one; the answer's `secret` is shown only this once | | `PATCH /api/v1/bases/{baseId}/webhooks/{webhookId}` | Change the URL, description, events or filters; `enabled`; `{ "rotateSecret": true }` returns a new `secret` (the old one still signs for 24 hours); `{ "expirePreviousSecret": true }` ends that grace period now | | `DELETE /api/v1/bases/{baseId}/webhooks/{webhookId}` | Delete it and its delivery log | | `GET /api/v1/bases/{baseId}/webhooks/{webhookId}/deliveries?limit=50` | Its delivery attempts of the last 7 days, newest first | | `POST /api/v1/bases/{baseId}/webhooks/{webhookId}/ping` | Send a signed `ping` now and answer with the attempt | | `POST /api/v1/bases/{baseId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver` | Queue a logged event again, with the same event id (`{ "queued": true, "eventId": "…" }`); 409 for a ping, a webhook that is off, or an event past the 7-day event retention | ```json POST /api/v1/bases/{baseId}/webhooks { "url": "https://example.com/hooks/openrows", "description": "Sync to the CRM", "events": ["record.created", "record.updated", "record.deleted"], "tableIds": [""], "fieldIds": null } ``` - **events**: `record.created`, `record.updated`, `record.deleted`, `record.restored` (out of the trash, or a record revision restore that brings one back), `form.submitted` (a public form's submission; delivered as `record.created` to webhooks that don't ask for it), `field.created`, `field.updated`, `field.deleted`, `field.restored`, `table.created`, `table.updated`, `table.deleted`, `table.restored`, `comment.created`, `comment.updated` (an edit or a reaction) and `comment.deleted`. - **tableIds**: only these tables' events (`null`: every table, including new ones). - **fieldIds**: record updates only when one of these fields changed (`null` or `[]`: any field). - A base has at most 10 webhooks. The URL must be `https` on port 443 at a public address (a self-hosted server can allow more with `OUTBOUND_HTTP_*`, see the deployment docs). It is checked when saved and again before every delivery. ## Deliveries Each event is one `POST` with `Content-Type: application/json` and these headers: | Header | Value | | --- | --- | | `X-OpenRows-Signature` | `t=,v1=` | | `X-OpenRows-Event-Id` | The event's id, the same on every retry: skip ids you already handled | | `X-OpenRows-Event-Type` | `record.updated`, `form.submitted`, `ping`, … | ```json { "id": "4b0c6f2e-…", "type": "record.updated", "timestamp": "2026-10-26T10:00:00.000Z", "webhookId": "…", "base": { "id": "…", "name": "Roadmap" }, "table": { "id": "…", "name": "Tasks" }, "record": { "id": "…", "before": { "": "Draft" }, "after": { "": "Shipped" } }, "fields": { "": { "name": "Status", "type": "singleSelect" } }, "actor": { "id": "…", "name": "Ada" } } ``` - `record.created` (and `form.submitted`): `after` holds every cell, `before` is `null`. - `record.updated`: the changed cells before and after (`before` is `null` for changes made by links on the other side). - `record.deleted`: `before` holds the record's cells, `after` is `null`. - `record.restored`: like `record.created`, `after` holds every cell. - `field.*`: `field` is `{ id, name, type }`. `table.*`: `table` names the table. - `comment.*`: `comment` is `{ id, recordId, body, authorId, mentions, createdAt, editedAt }`; `body` is sanitized Markdown with `@[name](user:)` mention tokens, and `null` (like `authorId` and the dates) for `comment.deleted`. - `actor` is `null` for a public form's submission. - Payloads only hold what the webhook's creator may read. A webhook whose creator lost access to the base is turned off. Answer with any `2xx` within 10 seconds. Redirects are not followed. Anything else (an error status, a timeout, a refused address) is retried 7 times with growing delays: about 7.5 minutes, 15 minutes, 30 minutes, 1, 2, 4 and 8 hours (each up to twice that, at random), about a day in all. Deliveries aren't ordered: use `timestamp` and the event id. A webhook that fails 10 times in a row over at least a day is turned off and its creator gets an email; fix the receiver, then turn it on again (the count starts afresh). Every attempt is in the delivery log for 7 days, with its status, duration and the first 1 KiB of the response. A failed delivery has a **Redeliver** button there (or `POST …/deliveries/{deliveryId}/redeliver`): the worker sends the event again, as it is stored, with the same event id and the next attempt number; it counts like any attempt. ## Verifying signatures Check every request before you trust it: 1. Read the raw body as received (before parsing the JSON). 2. Split `X-OpenRows-Signature` into `t` and its `v1` values: there are two for 24 hours after a secret rotation, one per secret. 3. Refuse a `t` more than 5 minutes from your clock: that is the replay protection (a captured request can't be sent again later), together with skipping event ids you already handled. 4. Compute `HMAC-SHA256(secret, t + "." + rawBody)` as hex and compare it with each `v1` in constant time; accept the request when one matches. ```js import { createHmac, timingSafeEqual } from 'node:crypto'; export function verify(secret, header, rawBody, toleranceSeconds = 300) { const parts = header.split(',').map((p) => p.split('=', 2)); const t = Number(parts.find(([k]) => k === 't')?.[1]); if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest(); return parts .filter(([k]) => k === 'v1') .some(([, v]) => { const given = Buffer.from(v ?? '', 'hex'); return given.length === expected.length && timingSafeEqual(given, expected); }); } // Express: app.post('/hooks/openrows', express.text({ type: 'application/json' }), (req, res) => { // if (!verify(process.env.OPENROWS_WEBHOOK_SECRET, req.get('X-OpenRows-Signature'), req.body)) { // return res.sendStatus(401); // } // const event = JSON.parse(req.body); // res.sendStatus(204); // }); ``` ```python import hashlib, hmac, time def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool: parts = [p.split("=", 1) for p in header.split(",") if "=" in p] try: t = int(next(v for k, v in parts if k == "t")) except (StopIteration, ValueError): return False if abs(time.time() - t) > tolerance: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, v) for k, v in parts if k == "v1") ``` Rotating the secret (`PATCH … { "rotateSecret": true }` or "Rotate secret" in the app) returns the new one once. The old one keeps signing for 24 hours (`previousSecretExpiresAt` on the webhook): every delivery and ping carries a `v1` for each, so a receiver still on the old secret and one already on the new one both pass. Switch your receiver over within the day. If the old secret leaked, end the grace period at once with `{ "expirePreviousSecret": true }` (or rotate with both). Rotating again within the 24 hours keeps only the secret just replaced as the previous one. # OAuth apps Source: https://openrows.io/docs/api/oauth Integrations that act for other people (a Zapier or Make app, a SaaS connector) register an OAuth app instead of asking each person for a personal access token. OpenRows is the authorization server: OAuth 2.0 authorization code with PKCE (S256, required), confidential clients only, refresh tokens rotated on every use. The server describes itself at `GET /.well-known/oauth-authorization-server` (RFC 8414). ## Register an app Settings → "Developer: OAuth apps" → New app: a name, an optional description and homepage (people see them on the consent screen), and up to 10 redirect URIs, matched exactly (`https`, or `http` on `localhost`/`127.0.0.1`, no fragment). The answer shows the client ID (`or_app_…`) and the client secret (`or_secret_…`) once; keep the secret on your server, and rotate it from the same dialog if it may have leaked (the old one stops working at once). Deleting the app ends every person's access to it. A user can register up to 20 apps. ## 1. Send the person to the consent screen ``` GET /oauth/authorize?response_type=code &client_id=or_app_… &redirect_uri=https://your.app/oauth/callback &scope=data.records:read%20data.records:write%20schema.bases:read &state= &code_challenge= &code_challenge_method=S256 ``` `scope` is a space-separated list of the [personal access token scopes](https://openrows.io/docs/api#authentication) (at least one). Signed-out people sign in first. The screen shows your app, what it asks for and where it sends them back, and they pick the workspaces (every current and future base) or single bases it may reach. An unknown `client_id` or a `redirect_uri` that isn't registered is shown as an error and never redirected; other mistakes (`response_type`, `scope`, PKCE) come back to your redirect URI as `error=…&error_description=…&state=…`. Allowing sends the browser to `redirect_uri?code=…&state=…`; declining to `redirect_uri?error=access_denied&state=…`. Authorizing the same app again replaces its scopes and grants. ## 2. Exchange the code ``` POST /oauth/token Authorization: Basic base64(client_id:client_secret) Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=…&redirect_uri=…&code_verifier=… ``` (`client_id` and `client_secret` in the body work too.) The code is good once, for 10 minutes, for the same redirect URI and the verifier of its challenge; a code used twice revokes everything that authorization gave. The answer: ```json { "access_token": "or_oat_…", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "or_ort_…", "refresh_expires_in": 5184000, "scope": "data.records:read data.records:write schema.bases:read" } ``` Errors are RFC 6749's: `{ "error": "invalid_grant", "error_description": "…" }` with 400 (`invalid_request`, `invalid_grant`, `unsupported_grant_type`) or 401 (`invalid_client`). The token endpoint takes 120 requests per minute per IP. ## 3. Call the API `Authorization: Bearer or_oat_…` works wherever a personal access token does (the REST API v1, the SDK's `apiKey`, the MCP endpoint), and the same way: the person's live role ∩ the scopes ∩ the workspaces and bases they picked, the same session-only routes refused, the same rate limits (one bucket per authorization). A suspended account, a workspace whose policy turns tokens off , a revoked authorization or a deleted app end it at once. ## 4. Refresh ``` POST /oauth/token grant_type=refresh_token&refresh_token=or_ort_… ``` answers a new access token and a new refresh token (60 days). Each refresh token is used once: presenting an old one again revokes the authorization (it has leaked), and the person has to authorize the app again. ## Revocation People revoke an app in Settings → Integrations. An app revokes a token itself with RFC 7009's `POST /oauth/revoke` (`token=…`, client authentication as above; always 200): a refresh token ends the whole authorization, an access token only itself. # MCP server Source: https://openrows.io/docs/api/mcp AI agents use OpenRows through the Model Context Protocol, two ways, with the same tools: - **Remote:** `POST /mcp` on the API (Streamable HTTP, stateless, JSON answers), for personal access tokens only (`src/mcp/routes.ts`, the `token` access rule of the route registry). The tools call the REST API v1 through `@openrows/sdk` with the caller's own token, sent back into the app in process (`app.request`, no network hop), so each call passes the same `authorize()`, RLS, edit permissions and rate limits as the token calling `/api/v1` itself (an MCP request costs one request of the token's bucket plus one per API call). Only the tools the token's scopes allow are listed. - **Local:** `packages/mcp-server`, `openrows-mcp-server` over stdio, configured by `OPENROWS_API_KEY` and `OPENROWS_URL`. Published on npm as `@openrows/mcp-server` with provenance, so MCP clients start it with `npx -y @openrows/mcp-server` (`npx -y @openrows/mcp-server --help` prints the settings). ## Releases `.github/workflows/release-packages.yml` publishes `@openrows/sdk`, `@openrows/mcp-server` and `@openrows/n8n-nodes-openrows` on a pushed `sdk-v`, `mcp-server-v` or `n8n-v` tag: the tag must name the package's version and a commit on main, `NPM_TOKEN` must be set, the package's tests and `npm run check:packages` (the tarball holds only `dist/`, README, LICENSE and package.json) must pass, then `npm publish --provenance --access public` from a GitHub-hosted runner. The MCP server depends on the SDK by a caret range, so a release of both tags the SDK first. Nothing is published from a local machine. Users' guide: [packages/mcp-server/README.md](https://github.com/Draggable/openrows/blob/main/packages/mcp-server/README.md) and the `/docs/api/mcp` page (snippets with the server's own URL). `GET /llms.txt` lists the docs for agents. ## Tests - `packages/mcp-server/src/tools.test.ts`: every tool through an in-memory MCP client against a fake fetch (requests, Zod refusals, errors, size caps, scope filtering). - `packages/mcp-server/src/stdio.test.ts`: the built bin over real stdio with the SDK's `StdioClientTransport` (as Claude Code starts it) against a fake API: lists tools and bases, updates records, never prints the token. - `packages/server/test/integration/mcp.test.ts`: real Postgres, the server on a random port; the inspector-style flow (list, describe, create, search, page, update, get, comment, delete) over Streamable HTTP at `/mcp` and through the stdio server's tools; a read-only token (only the read tools over HTTP, 403 over stdio, nothing written); a read-only member's token; another tenant's token (404s); no token, a session cookie or a bad token (401). - `authz-matrix.test.ts`: `POST/GET/DELETE /mcp` and `GET /llms.txt`. ## Manual check: Claude Code lists and updates records Run against the dev stack (`docker/compose.sh dev up -d --wait`, app on http://localhost:8180) or app.openrows.io: 1. Sign in, open a base with a table (or `npm run db:seed -- --email ` for the demo workspace), and create a personal access token in Settings → "Developer: personal access tokens" with `schema.bases:read`, `data.records:read` and `data.records:write`. 2. Remote: `claude mcp add --transport http openrows http://localhost:8180/mcp --header "Authorization: Bearer "`. Local instead, from npm: `claude mcp add openrows --env OPENROWS_API_KEY= --env OPENROWS_URL=http://localhost:8180 -- npx -y @openrows/mcp-server` (or, to try a change from a checkout, `npm run build -w @openrows/mcp-server` and `node packages/mcp-server/dist/bin.js` in place of `npx -y …`). 3. `claude mcp list` shows `openrows` connected; in a session, `/mcp` lists its 8 tools (7 with the remote server and this token: no `add_comment`). 4. Ask: "List my OpenRows bases, then show the first 5 records of the Tasks table" (Claude calls `list_bases`, `describe_base`, `list_records`). Then: "Set the Status of the first one to Done" (`update_records`). The grid shows the change live. 5. With a read-only token (no `data.records:write`), ask for a change: the remote server has no write tools; the local one answers a 403 naming `data.records:write`. 6. `claude mcp remove openrows` and revoke the token. The automated equivalent of steps 2 to 5 is `mcp.test.ts` and `stdio.test.ts` above (the same MCP SDK client Claude Code uses, over the same transports). # @openrows/sdk Source: https://openrows.io/docs/api/sdk A typed JavaScript client for the [OpenRows](https://app.openrows.io) REST API v1. ESM and CommonJS, Node 20+ and browsers, no dependencies beyond `fetch`. Its types are generated from the API's own contracts, so they say what the server checks. ```sh npm install @openrows/sdk ``` ## Quick start Create a personal access token in OpenRows (Settings → "Developer: personal access tokens") with the scopes you need, then: ```ts import { OpenRows } from '@openrows/sdk'; const openrows = new OpenRows({ apiKey: process.env.OPENROWS_TOKEN, // or_pat_… baseUrl: 'https://app.openrows.io', // the default; your own server's origin when self-hosted }); const bases = await openrows.bases.list(); const tables = await openrows.tables(bases[0].id).list(); ``` `require('@openrows/sdk')` works too. ## Records A table is named by id or name. Cells are keyed by field name (or id, with `returnFieldsByFieldId`). Give the table your fields' types to have them checked: ```ts type Task = { Name: string; Status: string; Points: number }; const tasks = openrows.table(baseId, 'Tasks'); // Every record, page by page (100 at most per request). const done = await tasks .select({ view: 'Grid view', filterByFormula: "{Status} = 'Done'", sort: [{ field: 'Points', direction: 'desc' }], fields: ['Name', 'Points'], }) .all(); // One page at a time; answer `false` to stop. await tasks.select({ pageSize: 50 }).eachPage((records) => { for (const r of records) console.log(r.id, r.fields.Name); }); // Or iterate, with one page in memory at a time. for await (const record of tasks.select()) console.log(record.fields.Name); const first = await tasks.select({ maxRecords: 1 }).firstPage(); const one = await tasks.find(recordId); ``` Writes take any number of records and send them 10 per request, in order. A failed request throws; the batches before it are already saved. ```ts const created = await tasks.create([{ fields: { Name: 'Write docs' } }, { fields: { Name: 'Ship' } }]); const single = await tasks.create({ Name: 'Review', Status: 'Todo' }); // typecast: new select options are added, linked records found by name. await tasks.create({ Name: 'Triage', Status: 'Blocked' }, { typecast: true }); await tasks.update(single.id, { Status: 'Done' }); // only the fields given change await tasks.update([{ id: single.id, fields: { Points: 3 } }]); await tasks.replace(single.id, { Name: 'Review' }); // fields left out are cleared await tasks.replace([{ id: single.id, fields: { Name: 'Review' } }]); // Update records matching on up to 3 fields, create the others. const { createdRecords, updatedRecords } = await tasks.upsert( [{ fields: { Name: 'Ship', Points: 5 } }], { fieldsToMergeOn: ['Name'] } ); await tasks.destroy(single.id); // to the trash await tasks.destroy(created.map((r) => r.id)); ``` ## Comments ```ts const comments = tasks.comments(recordId); await comments.create('Looks good to me'); const page = await comments.page({ pageSize: 20 }); // newest first, with `offset` const all = await comments.all(); ``` ## Schema ```ts const table = await openrows.tables(baseId).create({ name: 'Projects', fields: [ { name: 'Name', type: 'singleLineText' }, // the first field is the primary field { name: 'Stage', type: 'singleSelect', options: { choices: [{ name: 'Idea' }] } }, ], }); const projects = openrows.table(baseId, table.id); const field = await projects.fields.create({ name: 'Budget', type: 'number' }); await projects.fields.update(field.id, { description: 'In euros' }); ``` ## Webhooks Needs the `webhook:manage` scope and the creator role on the base. ```ts const hooks = openrows.webhooks(baseId); const hook = await hooks.create({ url: 'https://example.com/hooks/openrows', events: ['record.created', 'record.updated'], }); console.log(hook.secret); // the signing secret, shown only this once await hooks.list(); await hooks.update(hook.id, { enabled: false }); const rotated = await hooks.update(hook.id, { rotateSecret: true }); await hooks.ping(hook.id); // a signed test event, answered with the attempt await hooks.deliveries(hook.id, { limit: 20 }); await hooks.redeliver(hook.id, deliveryId); // queues a logged event again, same event id await hooks.delete(hook.id); ``` ## Automations The automations built in the app: the creator role on the base, with `automations:read` (list, get, runs), `automations:manage` (on and off) and `automations:run` (trigger). ```ts const automations = openrows.automations(baseId); const [first] = await automations.list(); await automations.setEnabled(first.id, true); // it then runs as you // A webhook-received automation, with any JSON body; a button-clicked one with { recordId }. const { triggerId } = await automations.trigger(first.id, { body: { orderId: 42 } }); await automations.runs(first.id, { limit: 10, status: 'failed' }); ``` ## Errors Every refusal throws an `OpenRowsError`: ```ts import { isOpenRowsError } from '@openrows/sdk'; try { await tasks.select({ filterByFormula: 'LOWER({Name})' }).all(); } catch (error) { if (isOpenRowsError(error)) { error.status; // 422 error.type; // 'INVALID_FILTER_BY_FORMULA' (Airtable's error type where one fits) error.code; // OpenRows' stable code error.requestId; // quote it when reporting a problem error.errors; // what failed validation } } ``` ## Rate limits A request answered `429` is retried after its `Retry-After` (3 times by default). A wait longer than `maxRetryDelay` throws at once, with `error.retryAfter` (seconds) and `error.limit` (`base`, `token` or `user`). ## Options | Option | Default | | | --- | --- | --- | | `apiKey` | | A personal access token. Leave it out in a browser signed in to the same app | | `baseUrl` | `https://app.openrows.io` | The app's origin (`…/api` and `…/api/v1` work too) | | `maxRetries` | `3` | Retries of a 429 | | `maxRetryDelay` | `60000` | The longest `Retry-After` to wait, in ms | | `timeout` | `60000` | Per request, in ms (`0`: none) | | `fetch` | `globalThis.fetch` | Your own `fetch` | | `headers` | | Extra headers for every request | Keep tokens out of code that runs in someone else's browser: a token can do whatever its scopes and its owner allow. ## Reference The full reference is at [/docs/api/sdk](https://app.openrows.io/docs/api/sdk), and the HTTP API's at [/docs/api](https://app.openrows.io/docs/api). ## License AGPL-3.0-only, like the rest of the OpenRows core. ## Releasing Pushing an `sdk-v` tag (the version in `package.json`) runs `.github/workflows/release-packages.yml`, which builds, tests, checks the tarball and publishes with provenance. Nothing is published from a local machine. # API reference Source: https://openrows.io/docs/api/reference. OpenAPI document: https://app.openrows.io/api/openapi.json ## REST API v1 - `GET /api/v1/bases/{baseId}/webhooks`: A base's webhooks (never their secrets). Access: `base:manage_webhooks` on the base (see ACTIONS for the minimum role); personal access tokens need `webhook:manage`. - `POST /api/v1/bases/{baseId}/webhooks`: Create a webhook; the answer holds its signing secret, shown only this once. Access: `base:manage_webhooks` on the base (see ACTIONS for the minimum role); personal access tokens need `webhook:manage`. - `PATCH /api/v1/bases/{baseId}/webhooks/{webhookId}`: Change, enable or disable a webhook, or rotate its secret (returned once). Access: `base:manage_webhooks` on the base (see ACTIONS for the minimum role); personal access tokens need `webhook:manage`. - `DELETE /api/v1/bases/{baseId}/webhooks/{webhookId}`: Delete a webhook and its delivery log. Access: `base:manage_webhooks` on the base (see ACTIONS for the minimum role); personal access tokens need `webhook:manage`. - `GET /api/v1/bases/{baseId}/webhooks/{webhookId}/deliveries`: A webhook's delivery attempts of the last 7 days, newest first. Access: `base:manage_webhooks` on the base (see ACTIONS for the minimum role); personal access tokens need `webhook:manage`. - `POST /api/v1/bases/{baseId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver`: Queue a logged delivery's event again, with the same event id. Access: `base:manage_webhooks` on the base (see ACTIONS for the minimum role); personal access tokens need `webhook:manage`. - `POST /api/v1/bases/{baseId}/webhooks/{webhookId}/ping`: Send a signed test event now and answer with the attempt. Access: `base:manage_webhooks` on the base (see ACTIONS for the minimum role); personal access tokens need `webhook:manage`. - `GET /api/v1/bases/{baseId}/automations`: A base's automations (never their webhook URLs). Access: `base:read_automations` on the base (see ACTIONS for the minimum role); personal access tokens need `automations:read`. - `GET /api/v1/bases/{baseId}/automations/{automationId}`: One automation: its trigger, actions and last run. Access: `base:read_automations` on the base (see ACTIONS for the minimum role); personal access tokens need `automations:read`. - `PATCH /api/v1/bases/{baseId}/automations/{automationId}`: Turn an automation on or off; turning it on makes it run as you. Access: `base:toggle_automations` on the base (see ACTIONS for the minimum role); personal access tokens need `automations:manage`. - `GET /api/v1/bases/{baseId}/automations/{automationId}/runs`: An automation's runs of the last 30 days, newest first. Access: `base:read_automations` on the base (see ACTIONS for the minimum role); personal access tokens need `automations:read`. - `POST /api/v1/bases/{baseId}/automations/{automationId}/trigger`: Trigger a webhook-received (`body`) or button-clicked (`recordId`) automation. Access: `base:run_automations` on the base (see ACTIONS for the minimum role); personal access tokens need `automations:run`. - `GET /api/v1/meta/bases`: Bases the caller (and token) can read, with their permission level. Access: any signed-in user, for their own data; personal access tokens need `schema.bases:read`. - `GET /api/v1/meta/bases/{baseId}/tables`: A base's tables with their fields (types and options) and views. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `POST /api/v1/meta/bases/{baseId}/tables`: Create a table with its fields (the first is the primary field). Access: `table:create` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `POST /api/v1/meta/bases/{baseId}/tables/{tableIdOrName}/fields`: Create a field. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `PATCH /api/v1/meta/bases/{baseId}/tables/{tableIdOrName}/fields/{fieldId}`: Rename a field or change its description. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `GET /api/v1/{baseId}/{tableIdOrName}`: List records: pageSize, offset, maxRecords, view, sort, fields, filterByFormula. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `POST /api/v1/{baseId}/{tableIdOrName}`: Create one record (`fields`) or up to 10 (`records`). Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `PUT /api/v1/{baseId}/{tableIdOrName}`: Replace up to 10 records: fields left out are cleared. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `PATCH /api/v1/{baseId}/{tableIdOrName}`: Update up to 10 records (or upsert with performUpsert). Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `DELETE /api/v1/{baseId}/{tableIdOrName}`: Delete up to 10 records (`records[]`), to the trash. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `POST /api/v1/{baseId}/{tableIdOrName}/listRecords`: List records with the parameters in the body (for long formulas). Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `GET /api/v1/{baseId}/{tableIdOrName}/{recordId}`: One record. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `PUT /api/v1/{baseId}/{tableIdOrName}/{recordId}`: Replace a record: fields left out are cleared. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `PATCH /api/v1/{baseId}/{tableIdOrName}/{recordId}`: Update a record: only the fields given change. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `DELETE /api/v1/{baseId}/{tableIdOrName}/{recordId}`: Delete a record (to the trash). Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `GET /api/v1/{baseId}/{tableIdOrName}/{recordId}/comments`: A record's comments, newest first. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:read`. - `POST /api/v1/{baseId}/{tableIdOrName}/{recordId}/comments`: Comment on a record. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:write`. ## Airtable - `POST /api/workspaces/{workspaceId}/airtable/bases`: The Airtable bases an Airtable personal access token can read (workspace creators; the token is not kept). Access: `base:create` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/workspaces/{workspaceId}/airtable/imports`: The Airtable imports the caller started in the workspace (the last 7 days). Access: `base:create` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/airtable/imports`: Import Airtable bases into new bases of the workspace, in the background. Access: `base:create` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/workspaces/{workspaceId}/airtable/imports/{importId}`: An Airtable import's progress and report (only who started it). Access: `base:create` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/airtable/imports/{importId}/cancel`: Stop a running Airtable import (only who started it); the new base keeps what was imported. Access: `base:create` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Attachments - `POST /api/tables/{tableId}/attachments`: Announce a file for an attachment field and get a presigned PUT for it. Access: `attachment:create` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `POST /api/tables/{tableId}/attachments/{attachmentId}/confirm`: Confirm an upload: the stored object must match the announced size. Access: `attachment:create` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `GET /api/workspaces/{workspaceId}/storage`: The workspace's stored files and bytes. Access: `workspace:read` on the workspace (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. ## Auth - `GET /api/me`: The signed-in user and their session. Access: any signed-in user, for their own data; personal access tokens need `user.email:read`. - `GET /api/me/deletion`: What deleting your account would do to the workspaces you own. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/me/delete`: Delete your account: signed out everywhere, data hard-deleted within 30 days. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `GET /api/me/support-access`: When OpenRows staff viewed your account as you, read-only. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `DELETE /api/me/impersonation`: End a system admin's impersonation of this account in this session. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). ## Automations - `GET /api/bases/{baseId}/automations`: A base's automations, oldest first (creators and up). Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/automations`: Create an automation: a trigger and up to 25 actions, run as you. Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/automations/{automationId}`: An automation. Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/bases/{baseId}/automations/{automationId}`: Change an automation, or turn it on or off (turning it on runs it as you). Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/bases/{baseId}/automations/{automationId}`: Delete an automation and its run history. Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/automation-runs/failed`: The base's failed runs of the last 30 days not retried yet, newest first. Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/automations/{automationId}/runs`: An automation's runs of the last 30 days, newest first, with each step. Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/automations/{automationId}/test`: Test an automation: its trigger with a sample record (or webhook request) and its actions for real, as you. Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/automations/{automationId}/runs/{runId}/retry`: Run a failed run again from its trigger data, as you (once per run). Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/automations/{automationId}/webhook-url`: A new URL for a webhook-received trigger; the old one stops working at once. Access: `base:manage_automations` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/tables/{tableId}/records/{recordId}/buttons/{fieldId}`: Click a record's button that runs automations (editors who may edit the record). Access: `record:update` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. ## Bases - `GET /api/bases/{baseId}/usage-warnings`: The plan limits at 80% or more that touch the base: its records and the workspace's other limits (creators and owners). Access: `base:read_usage` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/workspaces/{workspaceId}/bases`: The bases of a workspace the caller can see (a base-only collaborator sees only theirs). Access: any signed-in user, for their own data; personal access tokens need `schema.bases:read`. - `POST /api/workspaces/{workspaceId}/bases`: Create a base (owners and creators). Access: `base:create` on the workspace (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `GET /api/bases/{baseId}`: Read a base. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `PATCH /api/bases/{baseId}`: Update a base. Access: `base:update` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `DELETE /api/bases/{baseId}`: Move a base to the trash. Access: `base:delete` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/duplicate`: Duplicate a base into this or another workspace, with or without its records, in the background. Access: `base:duplicate` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/duplicates/{duplicateId}`: A base duplicate's progress (only who started it). Access: `base:duplicate` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/assistant/plan`: Ask the AI assistant for a change plan (creators): new tables, fields, formulas, views and automations for the base. The model gets the base's schema, and a few records only with includeRecords. Nothing is changed. Access: `base:ai_assistant` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/assistant/apply`: Apply a change plan the user confirmed (creators), in one transaction through the same checks as the API. Access: `base:ai_assistant` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Comments - `GET /api/tables/{tableId}/records/{recordId}/comments`: A page of a record's comments, oldest first (the newest page, or before a cursor). Access: `comment:read` on the table (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:read`. - `POST /api/tables/{tableId}/records/{recordId}/comments`: Comment on a record (Markdown, @mentions of people with access to the base). Access: `comment:create` on the table (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:write`. - `PATCH /api/tables/{tableId}/records/{recordId}/comments/{commentId}`: Edit your own comment. Access: `comment:create` on the table (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:write`. - `DELETE /api/tables/{tableId}/records/{recordId}/comments/{commentId}`: Delete your own comment (creators and owners: any comment). Access: `comment:create` on the table (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:write`. - `PUT /api/tables/{tableId}/records/{recordId}/comments/{commentId}/reactions/{reaction}`: Add your reaction to a comment. Access: `comment:create` on the table (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:write`. - `DELETE /api/tables/{tableId}/records/{recordId}/comments/{commentId}/reactions/{reaction}`: Take back your reaction to a comment. Access: `comment:create` on the table (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:write`. - `GET /api/tables/{tableId}/comment-counts`: How many comments each record of the table has (records with none left out). Access: `comment:read` on the table (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:read`. - `GET /api/tables/{tableId}/mentionable-users`: Who a comment on the table's records may mention: everyone with access to the base. Access: `comment:create` on the table (see ACTIONS for the minimum role); personal access tokens need `data.recordComments:write`. ## Extensions - `GET /api/bases/{baseId}/extensions`: A base's installed extensions in panel order. Access: `base:read_extensions` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/extensions`: Install a built-in extension or a third-party bundle (creators and up). Access: `base:manage_extensions` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/extensions/{extensionId}/bundle`: An installed extension's manifest and bundle. Access: `base:read_extensions` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/bases/{baseId}/extensions/{extensionId}`: Rename an extension or change its size (creators and up). Access: `base:manage_extensions` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/bases/{baseId}/extensions/{extensionId}`: Remove an extension from the base (creators and up). Access: `base:manage_extensions` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PUT /api/bases/{baseId}/extensions/order`: Arrange the base's extension panel (creators and up). Access: `base:manage_extensions` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PUT /api/bases/{baseId}/extensions/{extensionId}/settings`: Replace an extension's settings (editors and up). Access: `base:configure_extensions` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Fields - `POST /api/tables/{tableId}/fields`: Add a field. Access: `field:create` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `GET /api/tables/{tableId}/fields/trash`: Fields of a table in the trash, most recently deleted first. Access: `table:read` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `POST /api/tables/{tableId}/fields/{deletedFieldId}/restore`: Restore a field from the trash. Access: `field:restore` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `PATCH /api/fields/{fieldId}`: Rename, describe, reorder or convert a field. Access: `field:update` on the field (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `DELETE /api/fields/{fieldId}`: Move a field to the trash. Access: `field:delete` on the field (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `GET /api/fields/{linkFieldId}/link-candidates`: Records a link field can link to (the record picker), by primary field. Access: `field:link_candidates` on the field (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `GET /api/fields/{fieldId}/ai`: An AI field's generation state: counts and the records not done. Access: `field:ai_status` on the field (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `POST /api/fields/{fieldId}/ai/generate`: Generate an AI field's value again for records, or for the whole field. Access: `field:ai_generate` on the field (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `POST /api/fields/{fieldId}/ai/cancel`: Stop an AI field's pending generations. Access: `field:ai_generate` on the field (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. ## Forms - `GET /api/forms/{formToken}`: A public form: its title, description, fields, conditions and settings. Access: anyone. - `GET /api/forms/{formToken}/fields/{fieldId}/records`: A linked record field's picker: records of the form's chosen view, by primary field only. Access: anyone. - `POST /api/forms/{formToken}/submissions`: Submit the form: creates one record (once per submissionId). Access: anyone. - `POST /api/forms/{formToken}/attachments`: Announce a file for an attachment field on the form; answers a presigned PUT. Access: anyone. - `POST /api/forms/{formToken}/attachments/{attachmentId}/confirm`: Confirm a form upload: the stored object must match the announced size. Access: anyone. ## Imports - `POST /api/tables/{tableId}/imports`: Import a CSV (up to 50 MB) into the table, in the background. Access: `record:create` on the table (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/tables/{tableId}/imports/{importId}`: An import's progress (only who started it). Access: `record:create` on the table (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Interfaces - `GET /api/bases/{baseId}/interfaces`: A base's interfaces and pages: creators get drafts and unpublished pages, everyone else published pages only. Access: `base:read` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/interfaces`: Create an interface (creators and up). Access: `base:manage_interfaces` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/bases/{baseId}/interfaces/{interfaceId}`: Rename or move an interface. Access: `base:manage_interfaces` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/bases/{baseId}/interfaces/{interfaceId}`: Delete an interface and its pages. Access: `base:manage_interfaces` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/interfaces/{interfaceId}/pages`: Add a page (a record list or record review over one table), unpublished. Access: `base:manage_interfaces` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/interfaces/{interfaceId}/pages/{pageId}`: A page (non-creators: published pages only). Access: `base:read` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/bases/{baseId}/interfaces/{interfaceId}/pages/{pageId}`: Rename or move a page, or save its draft (viewers don't see it until published). Access: `base:manage_interfaces` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/bases/{baseId}/interfaces/{interfaceId}/pages/{pageId}`: Delete a page. Access: `base:manage_interfaces` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/interfaces/{interfaceId}/pages/{pageId}/publish`: Publish a page's draft: what the base's other collaborators see. Access: `base:manage_interfaces` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/interfaces/{interfaceId}/pages/{pageId}/unpublish`: Unpublish a page: only creators and owners see it again. Access: `base:manage_interfaces` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/interfaces/{interfaceId}`: An interface and its pages as the caller reaches it (interface-only collaborators: published pages). Access: `interface:read` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/interfaces/{interfaceId}/pages/{pageId}/records`: A window of a page's records: the page's filter for this viewer, only the fields it shows. Access: `interface:read` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/interfaces/{interfaceId}/pages/{pageId}/records/{recordId}`: Edit the fields a published page makes editable, on one of its records. Access: `interface:write` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/interfaces/{interfaceId}/pages/{pageId}/submissions`: Submit a form page: a new record from the form's fields. Access: `interface:write` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/interfaces/{interfaceId}/pages/{pageId}/data`: A page's numbers and charts, aggregated on the server (the draft's for creators, with `draft`). Access: `interface:read` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/me/interfaces`: The interfaces shared with you alone, without their bases. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `GET /api/interfaces/{interfaceId}/collaborators`: An interface's interface-only collaborators and pending invitations (creators). Access: `interface:manage_members` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/interfaces/{interfaceId}/collaborators`: Invite someone to this interface only, by email (creators). Access: `interface:manage_members` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/interfaces/{interfaceId}/collaborators/{userId}`: Change an interface-only collaborator's role. Access: `interface:manage_members` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/interfaces/{interfaceId}/collaborators/{userId}`: Remove an interface-only collaborator. Access: `interface:manage_members` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/interfaces/{interfaceId}/invitations/{invitationId}`: Revoke a pending invitation to this interface. Access: `interface:manage_members` on the interface (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Invitations - `GET /api/workspaces/{workspaceId}/invitations`: Pending invitations of a workspace (owners and creators). Access: `workspace:manage_members` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/invitations`: Invite someone by email (owners and creators; only owners invite owners). Access: `workspace:manage_members` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/invitations/{invitationId}/resend`: Send an invitation again with a new token and expiry. Access: `workspace:manage_members` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/workspaces/{workspaceId}/invitations/{invitationId}`: Revoke an invitation. Access: `workspace:manage_members` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/invitations/{token}`: Read the invitation for an emailed token. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/invitations/{token}/accept`: Accept an invitation (needs a verified email matching the invitation's). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/invitations`: Pending invitations to a base (creators and up). Access: `base:manage_members` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/invitations`: Invite someone to this base only, by email (creators and up). Access: `base:manage_members` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/invitations/{invitationId}/resend`: Send a base invitation again with a new token and expiry. Access: `base:manage_members` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/bases/{baseId}/invitations/{invitationId}`: Revoke a base invitation. Access: `base:manage_members` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Members - `GET /api/workspaces/{workspaceId}/members`: The members of a workspace. Access: `workspace:read` on the workspace (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `GET /api/workspaces/{workspaceId}/collaborators`: The workspace's collaborator directory: members and base-only collaborators of bases the caller can see (emails only for workspace members). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `PATCH /api/workspaces/{workspaceId}/members/{userId}`: Change a member's role (owners and creators; only owners grant or change owner). Access: `workspace:manage_members` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/workspaces/{workspaceId}/members/{userId}`: Remove a member (their access ends with this request). Access: `workspace:manage_members` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/collaborators`: Everyone with access to a base: workspace members and base collaborators, with their effective role (creators and up). Access: `base:manage_members` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/bases/{baseId}/collaborators/{userId}`: Set someone's role on a base (creators and up); the effective role is the higher of it and their workspace role. Access: `base:manage_members` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/bases/{baseId}/collaborators/{userId}`: Remove someone's base role (a base-only collaborator loses the base at once). Access: `base:manage_members` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Notices - `GET /api/me/notices`: Service notices (Terms, privacy, price and plan changes) the caller hasn't dismissed. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/me/notices/{noticeId}/dismiss`: Dismiss a service notice's banner for the caller. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). ## Notifications - `GET /api/notifications`: Your notifications, newest first (only records you can still read). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `GET /api/notifications/unread`: How many of your notifications are unread. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/notifications/{notificationId}/read`: Mark one of your notifications read. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/notifications/read`: Mark all your notifications read. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `GET /api/me/notification-preferences`: Which notifications you get by email. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `PUT /api/me/notification-preferences`: Choose which notifications you get by email (all, mentions only, none) and when (immediately, hourly or daily digests). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). ## Oauth - `GET /api/me/oauth-apps`: The OAuth apps you registered (never their client secrets). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/me/oauth-apps`: Register an OAuth app; the answer is the only time its client secret is shown. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `PATCH /api/me/oauth-apps/{appId}`: Change an OAuth app, or rotate its client secret (answered once). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `DELETE /api/me/oauth-apps/{appId}`: Delete an OAuth app: every person's authorization and token of it end. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `GET /api/me/authorizations`: The OAuth apps you authorized, with their scopes and grants. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `DELETE /api/me/authorizations/{authorizationId}`: Revoke an app's access: its access and refresh tokens stop working at once. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `GET /api/oauth/authorize`: Check an authorization request for the consent screen. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/oauth/authorize`: The consent screen's answer: where to send the browser (a code, or access_denied). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). ## Realtime - `GET /api/bases/{baseId}/events`: The base's events after `since` (a seq), oldest first, for realtime catch-up; 410 when `since` is older than the 7-day retention. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. ## Records - `GET /api/tables/{tableId}/records`: Live records, oldest first, keyset-paged. Access: `record:read` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `POST /api/tables/{tableId}/records`: Create one record ({ fields }) or up to 1,000 ({ records }). Access: `record:create` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `PATCH /api/tables/{tableId}/records`: Patch up to 1,000 records in one transaction. Access: `record:update` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `POST /api/tables/{tableId}/records/query`: Filter, sort, group, page and aggregate records (a view query). Access: `record:read` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `POST /api/tables/{tableId}/records/delete`: Move up to 1,000 records to the trash. Access: `record:delete` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `POST /api/tables/{tableId}/records/restore`: Restore up to 1,000 records from the trash. Access: `record:delete` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `GET /api/tables/{tableId}/records/{recordId}`: Read a record. Access: `record:read` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `PATCH /api/tables/{tableId}/records/{recordId}`: Patch the given cells of a record. Access: `record:update` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. - `DELETE /api/tables/{tableId}/records/{recordId}`: Move a record to the trash. Access: `record:delete` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. ## Regions - `GET /api/regions`: The regions workspaces can live in, and whether the caller may create one elsewhere. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/regions/{regionId}/workspaces`: Create a workspace in another region's stack, owned by the caller (Enterprise owners). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). ## Revisions - `GET /api/tables/{tableId}/records/{recordId}/revisions`: A record's revision history, newest first (fields the caller can't read left out). Access: `record:read` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `POST /api/tables/{tableId}/records/{recordId}/revisions/{revisionId}/restore`: Restore the record (or one cell) to how it was right after a revision. Access: `record:update` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:write`. ## Scripts - `GET /api/bases/{baseId}/scripts`: A base's saved scripts by name (editors and up). Access: `base:read_scripts` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/scripts`: Save a new script (creators and up). Access: `base:manage_scripts` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/scripts/{scriptId}`: A saved script (editors and up). Access: `base:read_scripts` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/bases/{baseId}/scripts/{scriptId}`: Rename a script or change its code (creators and up). Access: `base:manage_scripts` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/bases/{baseId}/scripts/{scriptId}`: Delete a script (creators and up). Access: `base:manage_scripts` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Shared - `GET /api/shared/{shareToken}`: What the share link shows: its name, the views it opens and its toggles. Access: anyone. - `POST /api/shared/{shareToken}/unlock`: Enter a share link's password; sets a cookie for this link (12 hours at most). Access: anyone. - `GET /api/shared/{shareToken}/views/{viewId}`: A shared view: its name, visible fields and layout. Access: anyone. - `GET /api/shared/{shareToken}/views/{viewId}/records`: A window of a shared view's records, with its filter and order. Access: anyone. ## Shares - `GET /api/views/{viewId}/shares`: The view's share links (not revoked), newest first. Access: `view:share` on the view (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/views/{viewId}/shares`: Make a read-only share link of the view; its token is answered only here. Access: `view:share` on the view (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/views/{viewId}/shares/{shareId}`: Change a view share link's expiry, password, domains or toggles. Access: `view:share` on the view (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/views/{viewId}/shares/{shareId}`: Revoke a share link (it answers 404 from then on). Access: `view:share` on the view (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/shares`: The base's whole-base share links (not revoked), newest first. Access: `base:share` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/shares`: Make a read-only share link of the whole base; its token is answered only here. Access: `base:share` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/bases/{baseId}/shares/{shareId}`: Change a base share link's expiry, password, domains or toggles. Access: `base:share` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/bases/{baseId}/shares/{shareId}`: Revoke a base share link (it answers 404 from then on). Access: `base:share` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Snapshots - `GET /api/bases/{baseId}/snapshots`: A base's snapshots, newest first, and the workspace's retention (creators and up). Access: `base:snapshot` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/snapshots`: Take a snapshot of a base now (schema, records, links, attachment references). Access: `base:snapshot` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/snapshots/{snapshotId}/restore`: Restore a snapshot to a new base of the same workspace (never in place), in the background. Access: `base:snapshot` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/snapshot-restores/{restoreId}`: A snapshot restore's progress (only who started it). Access: `base:snapshot` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/bases/{baseId}/snapshots/{snapshotId}`: Delete a snapshot. Access: `base:snapshot` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/workspaces/{workspaceId}/snapshot-settings`: Change how long the workspace's base snapshots are kept (owners). Access: `workspace:update` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Syncs - `POST /api/bases/{baseId}/syncs`: Add a table synced from a table or view you can read (creators and up). Access: `table:create_sync` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/syncs/preview`: Preview a CSV URL, Postgres or connector source (creators and up). Access: `table:create_sync` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/bases/{baseId}/syncs/browse`: List a sync connector's items with your connected account (creators and up). Access: `table:create_sync` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/tables/{tableId}/sync`: A synced table's source, schedule and recent runs. Access: `table:read_sync` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `PATCH /api/tables/{tableId}/sync`: Change a sync's schedule, pause or resume it (creators and up). Access: `table:manage_sync` on the table (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/tables/{tableId}/sync`: Stop syncing: the table keeps its records as a normal table (creators and up). Access: `table:manage_sync` on the table (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/tables/{tableId}/sync/run`: Sync now: queue a run of the sync (editors and up). Access: `table:run_sync` on the table (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/me/sync-connectors`: The sync connectors and your connected accounts. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `DELETE /api/me/sync-connectors/{connectorId}`: Disconnect your account from a sync connector (its tokens are revoked). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). ## Tables - `GET /api/bases/{baseId}/tables`: The tables of a base. Access: `base:read` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `POST /api/bases/{baseId}/tables`: Create a table with the default fields and a grid view. Access: `table:create` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `GET /api/tables/{tableId}`: Read a table with its live fields. Access: `table:read` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `PATCH /api/tables/{tableId}`: Rename, describe or reorder a table. Access: `table:update` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `DELETE /api/tables/{tableId}`: Move a table to the trash. Access: `table:delete` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `POST /api/tables/{tableId}/duplicate`: Duplicate a table (structure, optionally records). Access: `table:duplicate` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. ## Templates - `GET /api/templates`: The built-in templates of the gallery, without their records. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `GET /api/templates/{slug}`: A built-in template in the template format: its tables, fields, views and records. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/templates`: Create a base from a built-in template (owners and creators). Access: `base:create` on the workspace (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. ## Tokens - `GET /api/me/tokens`: The caller's personal access tokens (never their secrets). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `POST /api/me/tokens`: Create a personal access token; the answer is the only time its secret is shown. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `DELETE /api/me/tokens/{tokenId}`: Revoke a personal access token. Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). ## Trash - `GET /api/workspaces/{workspaceId}/trash`: Bases of a workspace in the trash (creators and up). Access: `workspace:read_trash` on the workspace (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `POST /api/workspaces/{workspaceId}/trash/bases/{trashedBaseId}/restore`: Restore a base from the trash. Access: `base:restore` on the workspace (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `DELETE /api/workspaces/{workspaceId}/trash/bases/{trashedBaseId}`: Delete a base in the trash permanently, with everything in it. Access: `base:purge` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/bases/{baseId}/trash`: A base's deleted tables, and the deleted fields and views of its tables (editors and up). Access: `base:read_trash` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `POST /api/bases/{baseId}/trash/tables/{trashedTableId}/restore`: Restore a table from the trash, with the link fields deleted with it. Access: `table:restore` on the base (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `DELETE /api/bases/{baseId}/trash/tables/{trashedTableId}`: Delete a table in the trash permanently, with its records and links. Access: `table:purge` on the base (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/tables/{tableId}/trash/fields/{trashedFieldId}`: Delete a field in the trash permanently, with its values (and a link pair). Access: `field:purge` on the table (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/tables/{tableId}/trash/views/{trashedViewId}/restore`: Restore a view from the trash. Access: `view:restore` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `DELETE /api/tables/{tableId}/trash/views/{trashedViewId}`: Delete a view in the trash permanently. Access: `view:purge` on the table (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/tables/{tableId}/trash/records`: Records of a table in the trash, most recently deleted first (editors and up). Access: `record:read_trash` on the table (see ACTIONS for the minimum role); personal access tokens need `data.records:read`. - `POST /api/tables/{tableId}/trash/records/purge`: Delete up to 1,000 records in the trash permanently. Access: `record:purge` on the table (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). ## Views - `GET /api/tables/{tableId}/views`: The views of a table, in sidebar order. Access: `table:read` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `POST /api/tables/{tableId}/views`: Create a view. Access: `view:create` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `GET /api/views/{viewId}`: Read a view. Access: `view:read` on the view (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `PATCH /api/views/{viewId}`: Rename, reorder, lock, colour or reconfigure a view. Access: `view:update` on the view (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `DELETE /api/views/{viewId}`: Delete a view (a table keeps at least one). Access: `view:delete` on the view (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `POST /api/views/{viewId}/duplicate`: Duplicate a view with its configuration. Access: `view:duplicate` on the view (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `POST /api/views/{viewId}/order`: Place a record after another in the view's manual order (kanban cards). Access: `view:update` on the view (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `POST /api/tables/{tableId}/view-sections`: Add a section (folder) to the sidebar of a table. Access: `viewSection:create` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `PATCH /api/tables/{tableId}/view-sections/{sectionId}`: Rename or reorder a view section. Access: `viewSection:update` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. - `DELETE /api/tables/{tableId}/view-sections/{sectionId}`: Delete a view section (its views stay, outside any section). Access: `viewSection:delete` on the table (see ACTIONS for the minimum role); personal access tokens need `schema.bases:write`. ## Workspaces - `GET /api/workspaces`: The caller's workspaces, with their role in each (null where they collaborate on some bases only). Access: any signed-in user, for their own data; personal access tokens need `schema.bases:read`. - `POST /api/workspaces`: Create a workspace (the caller becomes its owner). Access: any signed-in user, for their own data; signed-in session only (no personal access tokens). - `GET /api/workspaces/{workspaceId}`: Read a workspace. Access: `workspace:read` on the workspace (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `PATCH /api/workspaces/{workspaceId}`: Rename a workspace (owners). Access: `workspace:update` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/workspaces/{workspaceId}`: Delete a workspace (owners; soft delete, everyone loses access at once). Access: `workspace:delete` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/workspaces/{workspaceId}/usage`: The workspace's plan, its limits and what it uses: records per base, attachment bytes, this month's automation runs and API calls, billable editors (members). Access: `workspace:read` on the workspace (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `GET /api/workspaces/{workspaceId}/billing`: The workspace's subscription and the plans its owners can buy (owners; nothing to buy without billing). Access: `workspace:manage_billing` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/billing/checkout`: Start a checkout for a plan at the billing provider (owners). The plan changes only when the provider's signed event arrives; a workspace with a subscription changes or cancels it in the portal. Access: `workspace:manage_billing` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/billing/portal`: Open the billing provider's customer portal: change plan, cancel, invoices (owners of a workspace with a subscription). Access: `workspace:manage_billing` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PATCH /api/workspaces/{workspaceId}/billing/subscription`: Change the workspace's subscription at the billing provider (owners): another plan or interval (prorated), or cancel at the end of the period or take that back. Applied when the provider's signed event arrives. Access: `workspace:manage_billing` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `GET /api/workspaces/{workspaceId}/ai`: Whether AI features are on in the workspace (members): a provider from the workspace's own key or the instance's. Off: the app hides them. Access: `workspace:read` on the workspace (see ACTIONS for the minimum role); personal access tokens need `schema.bases:read`. - `GET /api/workspaces/{workspaceId}/ai/settings`: The workspace's AI provider settings (owners): the instance's model, the workspace's base URL and model, whether a key is stored (never the key). Access: `workspace:manage_ai` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `PUT /api/workspaces/{workspaceId}/ai/settings`: Bring your own key (owners): an OpenAI-compatible endpoint, model and API key for the workspace's AI features. The key is write-only and encrypted at rest; omit it to keep the stored one. Access: `workspace:manage_ai` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `DELETE /api/workspaces/{workspaceId}/ai/settings`: Remove the workspace's own AI key (owners): AI features fall back to the instance's provider, or turn off. Access: `workspace:manage_ai` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/ai/settings/test`: Send one small structured request to the workspace's AI provider (owners) and report whether it answered. Access: `workspace:manage_ai` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/assistant/plan`: Describe a new base (creators): the AI assistant proposes its tables, fields, views and automations as a change plan. Nothing is created. Access: `base:create` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens). - `POST /api/workspaces/{workspaceId}/assistant/apply`: Create a new base from a change plan the user confirmed (creators). Access: `base:create` on the workspace (see ACTIONS for the minimum role); signed-in session only (no personal access tokens).