Skip to content
Skip to main content

Sync email to Airtable with Nylas

Last updated:

Airtable makes a great lightweight email log: a spreadsheet-database where each row is a message and each column is a field you can filter, group, and share with non-engineers. The work is connecting a real mailbox to it. This recipe wires Nylas email into an Airtable base so every incoming message becomes a record, with the sender, subject, date, and body mapped to typed Airtable columns.

The two pieces are independent. Nylas gives you the email side: a unified read API and a message.created webhook that fires the moment mail arrives across Google, Microsoft, Yahoo, iCloud, IMAP, and EWS. Airtable gives you the storage side: a REST API that creates records in a table. You write the glue that maps one to the other.

How does the Airtable email integration work?

Section titled “How does the Airtable email integration work?”

The flow has three stages: Nylas delivers email data, your handler maps each message to an Airtable record shape, and the Airtable REST API writes it into a base. The API covers 6 email providers behind one schema, so the same code works whether the mailbox is Gmail or Exchange. Airtable allows 5 requests per second per base.

Nylas (webhook or list) ──▶ map message → Airtable fields ──▶ POST /v0/{baseId}/{tableId}

Two delivery modes feed the same mapper. Use the message.created webhook for real-time logging, or GET /v3/grants/{grant_id}/messages for a scheduled backfill of older mail. Both hand you the same message object, so you build the field mapping once. Each section below covers one stage in order.

The Nylas list endpoint pulls recent mail for a backfill or a periodic sync. A single GET /v3/grants/{grant_id}/messages request returns up to 50 messages by default and 200 at most, with one schema across all 6 providers. In the SDKs, messages.list takes the grant ID positionally, then a query-params object for filters like limit.

Each message includes from, to, subject, date (a Unix timestamp), snippet, and body. Those are the fields you’ll map to Airtable columns. To log mail the instant it arrives instead of polling, switch to the webhook in the next section.

For real-time logging, subscribe to the message.created webhook trigger instead of polling. Nylas sends an HTTP POST to your endpoint typically within 30 seconds of a message landing in any of the 6 supported providers, so an Airtable row appears almost as fast as the email does. The payload carries the same message object the list endpoint returns, which keeps your mapping code identical.

from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/nylas")
def on_message():
event = request.get_json()
if event.get("type") == "message.created":
message = event["data"]["object"]
record = to_airtable_fields(message) # defined below
create_airtable_record(record) # defined below
return "", 200

Nylas delivers each notification at least once, so the same message.created event can arrive 2 or more times. Deduplicate on the message ID before writing to Airtable, since duplicate events would otherwise create duplicate records. For the verification handshake and signature checks, see Receive webhook notifications in real time and Handle duplicate webhook deliveries.

Airtable stores each value in a typed column, so the mapping step converts the Nylas message into a fields object keyed by your column names. This example maps 5 message fields: singleLineText for the subject, email for the sender address, dateTime for the timestamp, and multilineText or richText for the body. Airtable documents every type in its field model reference.

Here’s a mapping that targets a table with columns named Subject, From, To, Received, and Body:

from datetime import datetime, timezone
def to_airtable_fields(message):
sender = (message.get("from") or [{}])[0]
recipients = ", ".join(p["email"] for p in message.get("to", []))
received = datetime.fromtimestamp(
message["date"], tz=timezone.utc
).isoformat()
return {
"Subject": message.get("subject", ""), # singleLineText
"From": sender.get("email", ""), # email
"To": recipients, # singleLineText
"Received": received, # dateTime (ISO 8601)
"Body": message.get("snippet", ""), # multilineText
}

Two field-type details matter. A dateTime column expects an ISO 8601 (YYYY-MM-DDTHH:MM:SSZ) string, which is why the code converts the Nylas Unix date first. A singleSelect column rejects any value that isn’t already an option unless you send "typecast": true, which tells Airtable to create the option on the fly.

Airtable writes rows through POST https://api.airtable.com/v0/{baseId}/{tableIdOrName}. The request body holds a records array, where each element is a { "fields": {...} } object. Batching keeps you under the rate limit: send up to 10 records per request rather than one call per message. Authenticate with a personal access token in the Authorization header.

import requests
AIRTABLE_TOKEN = "<AIRTABLE_PAT>"
BASE_ID = "<BASE_ID>"
TABLE = "Emails"
def create_airtable_record(fields):
url = f"https://api.airtable.com/v0/{BASE_ID}/{TABLE}"
headers = {
"Authorization": f"Bearer {AIRTABLE_TOKEN}",
"Content-Type": "application/json",
}
body = {"records": [{"fields": fields}], "typecast": True}
resp = requests.post(url, json=body, headers=headers)
resp.raise_for_status()
return resp.json()

The typecast: true option lets Airtable coerce strings into the column’s type and create missing singleSelect options automatically. Drop it if you want strict validation that rejects unexpected values. The endpoint returns each created record with its Airtable record ID, which you can store back in Nylas metadata if you need a two-way link.

Airtable limits each base to 5 requests per second. Exceed it and the API returns a 429 status code, after which, per Airtable’s rate-limits documentation, you “will need to wait 30 seconds before subsequent requests will succeed.” A burst of inbound mail can blow past 5 writes per second fast, so a high-volume mailbox needs a queue, not direct writes from the webhook handler.

import time
from collections import deque
_recent = deque() # timestamps of recent Airtable calls
def throttle(max_per_sec=5):
now = time.monotonic()
while _recent and now - _recent[0] > 1.0:
_recent.popleft()
if len(_recent) >= max_per_sec:
time.sleep(1.0 - (now - _recent[0]))
_recent.append(time.monotonic())

Call throttle() before each create_airtable_record. Two defenses keep a busy inbox healthy: batch up to 10 records per POST to cut request count, and push webhook events onto a queue so a spike of email drains at a steady 5 requests per second instead of hammering the base.

A few constraints shape how the integration behaves over time:

  • Rate limit is per base, not per token. The 5 requests per second cap applies to each base. Two tables in the same base share the budget, so splitting the email log across tables won’t buy you more throughput. Use separate bases or a queue.
  • The 429 penalty is long. A single overage triggers a 30-second cool-down, far longer than the 1-second window that caused it. Throttle proactively rather than catching 429 responses and retrying.
  • Authentication uses personal access tokens. Airtable retired API keys in February 2024. Create a personal access token scoped to data.records:write and the specific base, and store it as a secret.
  • Field names must match exactly. The fields object keys are the column names in your table, and they’re case-sensitive. A typo writes an empty or wrong value without raising an error, so keep the mapping in sync when you rename a column.
  • Attachments need a public URL. Airtable’s multipleAttachments type ingests files by URL, not raw bytes. To log attachments, host them somewhere reachable first, then pass the URL. The body text from snippet or body covers most logging needs without that step.