0.0
The project is in a healthy, maintained state
Ruby client for the Airtable Web API with zero runtime dependencies: persistent Net::HTTP connections, client-side rate limiting, automatic retries with exponential backoff, error classification, batch operations with auto-chunking, and upsert support.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 1.6
~> 5.25
~> 13.0
~> 3.24
 Project Readme

airtable_client

CI

A resilient Ruby client for the Airtable Web API with zero runtime dependencies.

Built on the Ruby standard library (Net::HTTP), with the operational behaviour you need when Airtable sits in a production path:

  • Persistent connections — each table keeps a keep-alive connection, reconnecting transparently when it drops
  • Client-side rate limiting — a thread-safe sliding-window limiter holds you under Airtable's 5 requests/second/base cap instead of bouncing off 429s
  • Automatic retries — HTTP 429/503 responses retry up to 3 times with exponential backoff and full jitter
  • Error classification — every API error raises AirtableClient::Error with a type (matching airtable.js naming) and status_code
  • Batch operations — create/update/delete in bulk with automatic chunking (configurable, default 10 per request) and per-record partial-failure reporting
  • Upsert — find-or-create in one call via Airtable's performUpsert
  • Pluggable observability — bring your own logger and metrics via AirtableClient.configure

Contents

  • Installation
  • Authentication
  • Reading records
  • Writing records
  • Batch operations
  • Error handling
  • Configuration
  • Thread safety
  • Staying in sync with the Airtable API
  • Documentation
  • Development

Installation

Add to your Gemfile:

gem 'airtable_client'

Or install directly:

$ gem install airtable_client

Requires Ruby 3.1+.

Moving from the airtable gem

airtable_client uses its own namespace, so it does not conflict with the old airtable gem. Table and record methods keep their names. Change the entry points:

airtable gem airtable_client
require 'airtable' require 'airtable_client'
Airtable::Client.new(token) AirtableClient.new(token)
Airtable::Error AirtableClient::Error

Authentication

Create a personal access token with the scopes you need (typically data.records:read and data.records:write) and access to your base. Keep it out of source control — read it from the environment:

require 'airtable_client'

client = AirtableClient.new(ENV.fetch('AIRTABLE_ACCESS_TOKEN'))
table  = client.table('appXXXXXXXXXXXXXX', 'Table Name')

Reading records

# One page (up to 100 records), with sorting
records = table.records(sort: ['Name', :asc], limit: 50)
records.first[:name]  # => "Bill Lowry"
records.offset        # => pagination offset for the next page

# Every record in the table (paginates for you)
all = table.all(sort: ['Name', :asc])

# Filter with a formula, select specific fields, scope to a view
active = table.select(
  formula: 'Active = 1',
  fields: %w[Name Email],
  view: 'Main View',
  sort: ['Order', 'asc']
)

# A single record by id
record = table.find('rec02sKGVIzU65eV2')

When interpolating user input into a formula, escape it:

formula = "{Email} = #{AirtableClient.escape_formula_value(user_email)}"
table.select(formula: formula)

Writing records

# Create
record = AirtableClient::Record.new(name: 'Sarah Jaine', email: 'sarah@jaine.com')
table.create(record)
record.id  # => "rec03sKOVIzU65eV4"

# Full replace (PUT)
record[:email] = 'sarahjaine@updated.com'
table.update(record)

# Partial update (PATCH) — only the given fields change
table.update_record_fields('rec03sKOVIzU65eV4', 'Email' => 'new@example.com')

# Delete
table.destroy('rec03sKOVIzU65eV4')

Batch operations

Batch methods chunk into groups of AirtableClient.configuration.batch_size — default 10, Airtable's long-documented per-request maximum — and return an AirtableClient::BatchResult instead of raising on partial failure:

records = names.map { |n| AirtableClient::Record.new(name: n) }
result  = table.create_batch(records)

result.successes  # => [AirtableClient::Record, ...]
result.failures   # => [[record, AirtableClient::Error], ...]

table.update_batch(records)      # PATCH, records need ids
table.destroy_batch(record_ids)  # by id

Upsert

result = table.upsert(records, fields_to_merge_on: ['Email'])
result.created_record_ids  # ids that were newly created rather than updated

Error handling

API failures raise AirtableClient::Error:

begin
  table.find('recDoesNotExist')
rescue AirtableClient::Error => e
  e.type         # => "NOT_FOUND" (Airtable's type when given, else classified from the status)
  e.status_code  # => 404
  e.message      # => "Record not found"
end

Types follow airtable.js: AUTHENTICATION_REQUIRED (401), NOT_AUTHORIZED (403), NOT_FOUND (404), INVALID_REQUEST (422), TOO_MANY_REQUESTS (429), SERVER_ERROR (500), SERVICE_UNAVAILABLE (503). Note that 429/503 are retried automatically before they ever raise.

Configuration

AirtableClient.configure do |config|
  # Anything responding to debug/info/warn. Without one, info/warn lines
  # go to $stderr and debug (connection lifecycle) is suppressed.
  config.logger = Logger.new($stdout)

  # Records per batch request (default 10). Airtable's docs no longer state
  # the cap — verify empirically before raising this.
  config.batch_size = 10

  # Called after every API response — wire up metrics here.
  config.on_request = lambda do |event|
    # event: { status_code:, table:, http_method:, duration_ms:,
    #          request_body_size:, response_body_size:,
    #          error_type:, error_message: }
    StatsD.increment('airtable.request', tags: ["status:#{event[:status_code]}"])
  end
end

Thread safety

  • AirtableClient::Table holds a persistent connection and is not thread-safe — give each thread its own instance.
  • The rate limiter is process-global, thread-safe, and shared across all tables keyed by base, so concurrent threads collectively respect the 5 rps/base limit.

Staying in sync with the Airtable API

Two weekly scheduled workflows guard against silent drift between this gem and the live API:

  • Contract drift re-extracts Airtable's published OpenAPI contract (ruby tools/extract_api_contract.rb) and compares it to the committed snapshot in docs/api/.contract/. Divergence opens an api-drift issue containing the diff.
  • Live smoke (bundle exec rake smoke) runs a real record round-trip — create, find, update, batch, upsert, delete — against a dedicated throwaway base. Failure opens a smoke-failure issue. It is credential-gated and never runs on pull requests.

Green silence on Monday mornings means the documented contract is unchanged and the gem still works against the real thing.

Documentation

Development

$ bundle install
$ bundle exec rake   # runs the minitest suite (WebMock — no live API calls)

See CONTRIBUTING.md for conventions and SECURITY.md for reporting vulnerabilities.

Acknowledgements

Forked from nesquena/airtable-ruby by Nathan Esquenazi and Alexander Sorokin, then substantially reworked: HTTParty replaced with persistent Net::HTTP, rate limiting, retries, error classification, batch/upsert support, and the removal of all runtime dependencies. Released under the MIT License.