When two Airtable bases share data through a sync, the data arrives already shaped. Every synced field is a native Airtable field and lands in the destination base as the same field type, and the sync reads from a view, so controlling what enters that view controls what crosses. A third-party system gives you neither. It sends whatever its own data model produces. Through Airtable’s API, it writes straight into a table, and the record is live the moment it lands. Through an automation’s webhook URL, nothing reaches a table until the automation writes it there, and the automation has to set where each value goes. If you want to validate that data before it reaches the tables your automations and reports rely on, the place where the check happens has to be built first.
This guide builds it, using the example from InAir's guide to operating Airtable inside complex, high-volume system landscapes: a support desk that sends each new escalation to Airtable the moment it's created, where a routing automation, a weekly report and a leadership dashboard all treat it as fact. One day the Escalations table gains an Account Owner field the support desk was never set up to fill, and escalations start arriving with it blank. Nothing errors, and they just sit in production, unrouted.
InAir's post on enforcing data contracts in Airtable describes the Schema Validation Gate for syncs between bases. A record is set to Ready_For_Sync, a script audits it against the contract, and on success, "the status changes to Approved, moving it into the locked sync view." On failure, "the status is flagged as Schema Error, the record is excluded from the sync view, and the validation errors are written to an error log."
The principle carries over unchanged: nothing from outside is trusted until a defined check has passed. Microsoft's Azure Architecture Center documents the same idea as the Quarantine pattern: "If the verification process is successful, the artifact is published in a safe store with clear annotations. Otherwise, it remains unavailable to the consumer."
The mechanics don't carry over, because the sync gate leans on three things a third-party system doesn't give you:
How much of a staging layer you need depends on how the data comes in.
Automation webhooks. An automation with the When webhook received trigger gets its own URL, and the sending system posts to it. That makes it a good fit for small, less sensitive updates, such as a status change, and a poor fit for anything closer to a data sync. It also doesn't need a staging table. The automation that receives the request can run the validation itself, in a script action, and write valid data straight to production and invalid data to a review table.
API writes. For anything closer to a data sync, the sending system writes through Airtable's Web API, authenticated with a token. That solves the authentication problem but opens a different one. A personal access token can reach what the user who owns it can reach, narrowed to the scopes and bases it’s granted. It can’t be narrowed to particular tables or fields, so a token owned by a creator on the base can write to every table and field in it. Nothing in the token stops the sending system from writing to production, so the boundary has to come from the endpoint and table design instead: the sending system is given only the endpoint webhook of the staging table, which contains only the fields owned and written by the system. A validation automation then decides what data moves into production.
The support desk's escalations carry ARR figures and drive routing, so the rest of this guide builds the API version. The build is one staging table, one validation automation, and one review interface. A single automation is enough here because the validation runs in a script action, and unlike Airtable's native actions, a script can handle errors, branch on complex conditions, and roll back its own partial work.
![][image1]Alt text: The path of one escalation through the staging layer. The support desk writes the escalation through the Airtable API into the Escalation Intake table, which holds only the fields the support desk owns. The validation automation runs on the new record. A passing record is written to the production Escalations table, linked back to its intake record and marked Approved. A record that fails a check stays in Escalation Intake as Schema Error or Flagged for Review, with its reasons, and appears in the review interface: Schema Errors for the integration owner, Flagged for Review for a Data Ops reviewer. Recheck or Confirm sends it through validation again.
The staging table, here called Escalation Intake, holds the fields the support desk owns and sends, with the same names as their counterparts in the production Escalations table, so validation compares like with like. Fields the support desk doesn't own stay out of it, and so does production's strictness.
Production's ARR at Risk is a currency field, and Priority is a single select, and that's right for production. In the staging table, give every field the support desk fills a text type. The staging table's job is to store what arrived, including wrong values. If ARR at Risk in the staging table were a number field, a request carrying "48k" would be rejected by the API.
If ARR at Risk in the staging table were a number field, a request carrying “48k” would be rejected by the API. Nothing would reach Airtable, and Airtable doesn’t send notifications for rejected API requests, so the only record of the failure would be in the support desk’s own error log, if it keeps one. Adding “typecast”: true to the request, so the API converts values itself, doesn’t solve this. “48k” is still unlikely to convert to a number, and a single select would accept a value it doesn’t have by creating a new option for it, so a bad priority would reach Airtable as a new option instead of being held.
The account owner needs one decision. The support desk doesn't know Airtable record IDs, and an email address is a poor key because emails change and get retyped. Instead, the support desk sends its own user ID for the account owner, stored in the staging table as Account Owner ID (text). The People table in production carries the same ID in a Support Desk User ID field, alongside an Active checkbox. The validation script checks that the ID belongs to exactly one active person, and only then writes the link to that person into production, so the link points at a verified, active person.
On top of the support desk's fields, add the fields the gate itself runs on:
The support desk's integration only ever creates records in Escalation Intake. It never updates them and never writes to any other table.
Create an automation with the When record matches conditions trigger on Escalation Intake, with the condition Processed is not checked. A new record from the support desk arrives with Processed unchecked and starts the automation, and so does a held record a reviewer sends back for another check. Add a Run a script action with one input variable, recordId, mapped to the triggering record's ID:
// Validate and write to production. Trigger: When record matches conditions
// (Escalation Intake, Processed is not checked). Input variable: recordId.
const { recordId } = input.config();
const intake = base.getTable('Escalation Intake');
const escalations = base.getTable('Escalations');
const people = base.getTable('People');
const rec = await intake.selectRecordAsync(recordId);
if (!rec) throw new Error(`Intake record ${recordId} not found`);
const val = (name) => rec.getCellValueAsString(name).trim();
const finish = (status, errors) => intake.updateRecordAsync(recordId, {
'Validation Status': { name: status },
'Validation Errors': errors.join('\n'),
'Processed': true,
});
// 1. A resend of an event that already reached production.
const eventId = val('Event ID');
if (eventId) {
const query = await intake.selectRecordsAsync({ fields: ['Event ID', 'Production Record'] });
const earlier = query.records.find((r) => r.id !== recordId
&& r.getCellValueAsString('Event ID') === eventId
&& r.getCellValue('Production Record'));
if (earlier) {
await finish('Duplicate', [`Event ${eventId} already reached production.`]);
return;
}
}
const errors = [];
// 2. Every field the contract requires has a value.
for (const name of ['Event ID', 'Account Name', 'Priority', 'Status',
'ARR at Risk', 'Account Owner ID', 'Ticket ID']) {
if (!val(name)) errors.push(`${name} is missing.`);
}
// 3. Select values are options production already has.
for (const name of ['Priority', 'Status']) {
const options = escalations.getField(name).options.choices.map((c) => c.name);
if (val(name) && !options.includes(val(name))) {
errors.push(name + ' "' + val(name) + '" is not one of: ' + options.join(', ') + '.');
}
}
// 4. ARR at Risk is a number production's currency field can hold.
const arr = Number(val('ARR at Risk'));
if (val('ARR at Risk') && (!Number.isFinite(arr) || arr < 0)) {
errors.push(`ARR at Risk "${val('ARR at Risk')}" is not a non-negative number.`);
}
// 5. The account owner is exactly one active person.
let ownerId = null;
const ownerKey = val('Account Owner ID');
if (ownerKey) {
const query = await people.selectRecordsAsync({ fields: ['Support Desk User ID', 'Active'] });
const matches = query.records.filter(
(r) => r.getCellValueAsString('Support Desk User ID') === ownerKey
);
if (matches.length !== 1) {
errors.push(`${matches.length} people in People have support desk user ID ${ownerKey}.`);
} else if (!matches[0].getCellValue('Active')) {
errors.push(`Support desk user ${ownerKey} is not an active person in People.`);
} else {
ownerId = matches[0].id;
}
}
if (errors.length > 0) {
await finish('Schema Error', errors);
return;
}
// 6. The ticket has at most one escalation in production.
const ticketId = val('Ticket ID');
const ticketQuery = await escalations.selectRecordsAsync({
fields: ['Ticket ID', 'Account Name', 'Priority', 'Status', 'ARR at Risk', 'Account Owner'],
});
const existing = ticketQuery.records.filter(
(r) => r.getCellValueAsString('Ticket ID') === ticketId
);
if (existing.length > 1) {
await finish('Flagged for Review',
[`${existing.length} escalations in Escalations have ticket ID ${ticketId}.`]);
return;
}
const current = existing[0] || null;
// 7. Create the escalation, or update the one the ticket already has, and
// undo the write if the intake record can't be updated.
const select = (r, name) => r.getCellValue(name) && { name: r.getCellValue(name).name };
const fields = {
'Account Name': val('Account Name'),
'Priority': { name: val('Priority') },
'Status': { name: val('Status') },
'ARR at Risk': arr,
'Account Owner': [{ id: ownerId }],
};
// The existing escalation's values before this write, to restore on failure.
const previous = current && {
'Account Name': current.getCellValue('Account Name'),
'Priority': select(current, 'Priority'),
'Status': select(current, 'Status'),
'ARR at Risk': current.getCellValue('ARR at Risk'),
'Account Owner': (current.getCellValue('Account Owner') || []).map((p) => ({ id: p.id })),
};
let productionId = null;
let updated = false;
try {
if (current) {
await escalations.updateRecordAsync(current.id, fields);
productionId = current.id;
updated = true;
} else {
productionId = await escalations.createRecordAsync({ ...fields, 'Ticket ID': ticketId });
}
await intake.updateRecordAsync(recordId, {
'Production Record': [{ id: productionId }],
'Validation Status': { name: 'Approved' },
'Validation Errors': '',
'Processed': true,
});
} catch (e) {
// Undo the escalation write (delete a new one, restore an updated one),
// record why, then fail the run
// so Airtable's failure notification goes out too.
try {
if (updated) await escalations.updateRecordAsync(productionId, previous);
else if (productionId) await escalations.deleteRecordAsync(productionId);
await finish('Write Failed', [String(e)]);
} finally {
throw e;
}
}
Three choices in this script are worth keeping whatever your own fields are. It collects every failure before deciding, so a record with three problems comes back with three reasons rather than one, and the reviewer doesn't fix the first only to watch it fail on the second. It reads the allowed Priority and Status values from production's own fields each time it runs, so adding an option in production updates the check without anyone editing the script. And it tries not to leave a half-finished write behind: if the escalation is created but the intake record can't be updated, it deletes the escalation, marks the intake record Write Failed and fails the run, so a retry starts from a clean state instead of creating a second escalation.
The pillar describes a further outcome for records that are well-formed but unusual, such as an escalation claiming ten times the account's ARR: Flagged for Review. It's a range check added before step 6, and left out of the listing above to keep it readable: look up the account by the support desk's account ID, sent as text the same way as Account Owner ID, compare ARR at Risk with the account's ARR, and finish with Flagged for Review instead of writing to production when it's out of range. Rechecking alone can't release a flagged record, since a value that's unusual but real would fail the same range check every time. Have the script skip the range check, and only the range check, when Reviewer Confirmed is checked. The structural checks still run on every pass.
Some integrations are one-way with a single owner: every field the support desk sends belongs to the support desk, and nobody edits those values in Airtable. In that case the build gets simpler, because production doesn't need copies of the values at all.
Each request from the support desk becomes a new record in Escalation Intake, including updates to an escalation that already exists. The validation script checks the record as above, but instead of writing values into production, it links the intake record to its production escalation, found by the support desk's ticket ID and created if it's new, and updates the validation status. On the Escalations table, every field the support desk owns is a lookup through that link, and nobody can edit a lookup in production.
A lookup field can be set to show only the most recent approved request. In each support desk lookup on Escalations, turn on the option to only include linked records that meet certain conditions, with the condition Validation Status is Approved. Then turn on the option to sort the looked-up records by Sent At, latest to earliest, and limit the number of items shown to 1. Production then shows the values from the most recent request that passed validation, and the lookups stay blank until an escalation’s first request is approved. A newer request that hasn’t been validated yet, or that was held as Schema Error, Flagged for Review, Duplicate or Write Failed, doesn’t replace them, and every request stays in Escalation Intake as history. The sort has to use Sent At, the timestamp the support desk sends with each request, not Received At, Airtable’s created time. A retry that arrives late can have the newest Received At but an older Sent At, so sorting by Sent At keeps a stale retry from displacing newer values.
Each request from the support desk becomes a new record in Escalation Intake, including updates to an escalation that already exists. That needs one more field on Escalation Intake: Ticket ID (text), the support desk’s own ID for the escalation, which stays the same across updates. Escalations gets a Ticket ID field too, so the script can find the right escalation. The validation script checks the record as above, but instead of writing values into production, it links the intake record to its production escalation, found by the support desk’s ticket ID and created if it’s new, and updates the validation status. On the Escalations table, every field the support desk owns is a lookup through that link, and nobody can edit a lookup in production.
A Schema Error isn't a failed automation run. The validation script did its job, the run succeeded, and so Airtable's failure email never fires for it. A gate that holds records nobody sees has only moved the problem, so the staging table needs an owner and a place to work from.
Build the review pages on Escalation Intake as list pages. Airtable can’t limit a user to certain pages within an interface, so the pages go in two interfaces, split by who works them: the Schema Error and Write Failed pages in an admin interface for the integration owner, and the Flagged for Review page in the Operations interface for Data Ops. Each page lists its records with Validation Errors shown read-only next to the fields a reviewer can correct. Leave Validation Status and Processed off the pages entirely, since each list’s filter already says what state its records are in. Then add buttons with the Update record action:
A reviewer can't approve a record by hand, because the only change the interface offers is sending it back through validation. That only holds if reviewers can't reach the table some other way. InAir's guide to building controlled interface pathways covers the Interface-Only access that keeps them out of the grid, where Validation Status could otherwise be edited directly.
If a run fails before it can record anything, Airtable's failure notification reaches whoever turned the automation on, and the record stays unprocessed with no status. Add a view of records with Processed not checked and a Received At older than a few minutes, so the integration owner can see them and send them through again by checking and then unchecking Processed.
The kinds of held records also need different people. Route Schema Errors and Write Failed records to whoever owns the integration: a missing field or a value production can't hold usually means the support desk's mapping is out of date, and even a single unknown user ID has to be traced back to the source. Route Flagged for Review to a named reviewer in Data Ops, because deciding whether an unusual value is real is a business judgment rather than a data fix. In the single-owner version from Step 3, corrections belong in the support desk rather than in Airtable: fix the value at the source, and the support desk's next request arrives as a new record.
Either version can also take corrections at the source: fix the value in the support desk, and its next request arrives as a new record. The single-owner version from Step 3 can use the same review pages as well. The only difference is what happens when a record passes: the script links the intake record to its escalation instead of writing the values into production.
Every escalation uses one automation run, and each Recheck or Confirm adds another. Those runs count toward the workspace plan's automation run limit, and the layer takes two automations. The support desk's own writes count against the API's rate limit of five requests per second per base, so a support desk that can produce bursts faster than that needs to space its requests or retry them.
The staging table grows with every request. The validation script reads its Event ID field on every run, so archive Approved and Duplicate records once they're older than the longest window in which the support desk might resend an event.
In Integration Load terms, a staging layer is how one connection answers the question of whether its writes are validated before they commit. The support desk keeps sending what it sent before, and its writes reach production only after they pass.
Send these requests to Escalation Intake in a sandbox copy of the base, each with its own Event ID unless the test says otherwise; then check both tables:
Before switching over, check what reads the Escalations table. Records the gate holds back will no longer reach the routing automation, the weekly report or the dashboard until someone clears them, so the people who own those should know the counts may drop for a reason that has nothing to do with the support desk. Then point the support desk's integration at Escalation Intake, with a token scoped to this base, and turn off whatever wrote to Escalations directly before.
If you have third-party systems writing straight into tables your reports and automations depend on, and you'd like each one behind a gate without dropping events during the switch, Schedule a Discovery Call with InAir and we'll plan the cutover with you.