Framer → Fluent Forms (No Tears): The Google Sheets Relay You'll Actually Use
Yes, this works for anything that can dump rows into Google Sheets. But the title says Framer to Fluent, ahahaha. Grab coffee, let's wire it up.
What You'll Do (5 Steps)
- Framer → Google Sheet (Framer creates it for you).
- Rename Sheet1 →
RAW Data
, then add an empty tab calledSubmissions
(copy the header row). - Install a tiny WordPress plugin that exposes a webhook and writes entries into Fluent Forms.
- Paste a Google Apps Script that copies new rows to
Submissions
, POSTs to your webhook, and marks them with Synced + Status. - (Important) Keep input names identical in Framer and Fluent (e.g.,
names, email, phone, company, job_title, message
).
1) Framer → Google Sheets (Framer does the heavy lifting)
In Framer, select your form → right panel → Send To → Add → Google Sheets.
Sign in and let Framer create a new spreadsheet. Open it in Google Sheets.
Rename Sheet1 to
RAW Data
.Click + to add a new empty tab named
Submissions
.In
RAW Data
, copy the entire header row (row 1).In
Submissions
, paste that header row into row 1 (in the same order).RAW Data
is your inbox.Submissions
is the staging area for rows you actually want to send to WordPress.
Works for Framer, Typeform exports, Airtable zaps—anything that lands rows in
RAW Data
.
2) WordPress: The Incoming-Webhook Plugin
- Make the plugin folder: Create a new folder on your computer named
fluent-forms-webhooks
. - Add the PHP file: Inside that folder, create a file named
fluent-forms-webhooks.php
. - Paste the plugin code: Copy the code below into that file and save.
- Zip the folder: Compress the folder into a
.zip
file.
<?php
/**
* Plugin Name: Fluent Forms Webhooks
* Description: Create multiple incoming webhook URLs that accept POSTs from any app and insert submissions into specific Fluent Forms.
* Version: 0.2.1
* Author: MisoMash
* License: GPLv2 or later
*/
if (!defined('ABSPATH')) {
exit;
}
final class FFWH_Plugin
{
const CPT = 'ffwh_endpoint';
const NS = 'ffwh/v1';
private static $instance = null;
public static function instance()
{
return self::$instance ?: (self::$instance = new self());
}
private function __construct()
{
register_activation_hook(__FILE__, [$this, 'activate']);
register_deactivation_hook(__FILE__, [$this, 'deactivate']);
add_action('init', [$this, 'register_cpt']);
add_action('add_meta_boxes', [$this, 'add_meta_boxes']);
add_action('save_post', [$this, 'save_meta_boxes']);
add_filter('manage_' . self::CPT . '_posts_columns', [$this, 'admin_cols']);
add_action('manage_' . self::CPT . '_posts_custom_column', [$this, 'admin_col_content'], 10, 2);
add_action('rest_api_init', [$this, 'register_routes']);
add_filter('rest_pre_serve_request', [$this, 'maybe_cors_headers'], 10, 4);
}
public function activate()
{
$this->register_cpt();
flush_rewrite_rules();
}
public function deactivate()
{
flush_rewrite_rules();
}
public function register_cpt()
{
register_post_type(self::CPT, [
'labels' => [
'name' => 'Fluent Webhooks',
'singular_name' => 'Fluent Webhook',
'add_new_item' => 'Add New Webhook',
'edit_item' => 'Edit Webhook',
],
'public' => false,
'show_ui' => true,
'show_in_menu' => true,
'menu_icon' => 'dashicons-randomize',
'supports' => ['title'],
]);
}
public function add_meta_boxes()
{
add_meta_box('ffwh_settings', 'Webhook Settings', [$this, 'render_settings_box'], self::CPT, 'normal', 'default');
add_meta_box('ffwh_url', 'Endpoint URL', [$this, 'render_url_box'], self::CPT, 'side', 'core');
}
private function get_meta($post_id, $key, $default = '')
{
$val = get_post_meta($post_id, $key, true);
return $val === '' ? $default : $val;
}
public function render_settings_box($post)
{
wp_nonce_field('ffwh_save', 'ffwh_nonce');
$form_id = intval($this->get_meta($post->ID, '_ffwh_form_id'));
$secret = $this->get_meta($post->ID, '_ffwh_secret', '');
$allowed = $this->get_meta($post->ID, '_ffwh_allowed_origins', '*');
$field_map = $this->get_meta($post->ID, '_ffwh_field_map', '');
// Build Fluent Forms dropdown (id => title)
$forms = [];
try {
if (class_exists('\FluentForm\App\Helpers\Helper')) {
$forms = \FluentForm\App\Helpers\Helper::getForms(); // [id => title]
}
if (!$forms && class_exists('\FluentForm\App\Models\Form')) {
$records = \FluentForm\App\Models\Form::select(['id', 'title'])->orderBy('id', 'DESC')->get();
foreach ($records as $r) {
$forms[intval($r->id)] = (string) $r->title;
}
}
} catch (\Throwable $e) {
$forms = [];
}
echo '<p><label for="ffwh_form_id"><strong>Fluent Form</strong></label><br />';
if ($forms) {
echo '<select name="ffwh_form_id" id="ffwh_form_id">';
echo '<option value="">— Select a form —</option>';
foreach ($forms as $id => $title) {
$id = intval($id);
if ($id <= 0) {
continue;
}
$selected = selected($form_id, $id, false);
$title = $title !== '' ? $title : ('Form #' . $id);
echo '<option value="' . esc_attr($id) . '" ' . $selected . '>' . esc_html($title) . ' (ID: ' . esc_html($id) . ')</option>';
}
echo '</select>';
} else {
echo '<input type="number" name="ffwh_form_id" id="ffwh_form_id" value="' . esc_attr($form_id) . '" placeholder="e.g. 3" />';
echo '<br /><em>If empty, ensure Fluent Forms is active or enter numeric Form ID manually.</em>';
}
echo '</p>';
echo '<p><label for="ffwh_secret"><strong>Signing Secret (Framer)</strong></label><br />';
echo '<input type="text" name="ffwh_secret" id="ffwh_secret" value="' . esc_attr($secret) . '" style="width:100%" />';
echo '<br /><em>If set, we verify Framer\'s headers (<code>Framer-Signature</code> over <code>rawBody + Framer-Webhook-Submission-Id</code>). Leave blank to disable.</em>';
echo '</p>';
echo '<p><label for="ffwh_allowed_origins"><strong>Allowed Origins (CORS)</strong></label><br />';
echo '<input type="text" name="ffwh_allowed_origins" id="ffwh_allowed_origins" value="' . esc_attr($allowed) . '" style="width:100%" placeholder="*, https://yourframer.site" />';
echo '<br /><em>Framer posts are server-to-server; CORS is rarely needed. Keep <code>*</code> unless you have a reason to limit it.</em>';
echo '</p>';
echo '<p><label for="ffwh_field_map"><strong>Field Map (JSON)</strong></label><br />';
echo '<textarea name="ffwh_field_map" id="ffwh_field_map" rows="6" style="width:100%" placeholder="{
"framer_key": "fluent_name"
}">' . esc_textarea($field_map) . '</textarea>';
echo '
Optional: incoming_key → Fluent Forms input name. Leave empty if your keys already match.';
}
public function render_url_box($post)
{
$key = $this->ensure_key($post->ID);
$url = rest_url(self::NS . '/hook/' . $key);
echo '<pre>' . esc_html($url) . '</pre>';
echo '<p>Ping: <code>GET ' . esc_html($url) . '?ping=1</code></p>';
echo '<p>POST JSON like: <br/><code>{"names":"Jane","email":"[email protected]"}</code> <br/>or <br/><code>{"fields":{"names":"Jane","email":"[email protected]"}}</code></p>';
}
public function save_meta_boxes($post_id)
{
if (!isset($_POST['ffwh_nonce']) || !wp_verify_nonce($_POST['ffwh_nonce'], 'ffwh_save')) {
return;
}
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
return;
}
if (get_post_type($post_id) !== self::CPT) {
return;
}
update_post_meta($post_id, '_ffwh_form_id', intval($_POST['ffwh_form_id'] ?? 0));
update_post_meta($post_id, '_ffwh_secret', sanitize_text_field($_POST['ffwh_secret'] ?? '')); // blank disables
update_post_meta($post_id, '_ffwh_allowed_origins', sanitize_text_field($_POST['ffwh_allowed_origins'] ?? '*'));
update_post_meta($post_id, '_ffwh_field_map', wp_unslash($_POST['ffwh_field_map'] ?? ''));
$this->ensure_key($post_id);
}
private function ensure_key($post_id)
{
$key = $this->get_meta($post_id, '_ffwh_key', '');
if (!$key) {
$key = strtolower(wp_generate_uuid4());
update_post_meta($post_id, '_ffwh_key', $key);
}
return $key;
}
public function admin_cols($cols)
{
$cols['ffwh_form'] = 'Form ID';
$cols['ffwh_url'] = 'Webhook URL';
return $cols;
}
public function admin_col_content($col, $post_id)
{
if ($col === 'ffwh_form') {
echo esc_html($this->get_meta($post_id, '_ffwh_form_id', '—'));
}
if ($col === 'ffwh_url') {
echo '<pre>' . esc_html(rest_url(self::NS . '/hook/' . esc_html($this->ensure_key($post_id)))) . '</pre>';
}
}
public function register_routes()
{
// POST
register_rest_route(self::NS, '/hook/(?P<key>[a-z0-9\-]+)', [
'methods' => 'POST',
'callback' => [$this, 'handle_incoming'],
'permission_callback' => '__return_true',
'args' => ['key' => ['required' => true]],
]);
// GET ping
register_rest_route(self::NS, '/hook/(?P<key>[a-z0-9\-]+)', [
'methods' => 'GET',
'callback' => function (WP_REST_Request $r) {
return new WP_REST_Response(['ok' => true, 'ping' => true, 'key' => $r->get_param('key')], 200);
},
'permission_callback' => '__return_true',
]);
// OPTIONS preflight
register_rest_route(self::NS, '/hook/(?P<key>[a-z0-9\-]+)', [
'methods' => 'OPTIONS',
'callback' => function () {
return new WP_REST_Response(null, 204);
},
'permission_callback' => '__return_true',
]);
}
public function maybe_cors_headers($served, $result, $request, $server)
{
$route = $request->get_route();
if (strpos($route, '/' . self::NS . '/hook/') !== false) {
$key = $request->get_param('key');
$post = $this->get_endpoint_by_key($key);
$allow = '*';
if ($post) {
$origins = array_map('trim', explode(',', $this->get_meta($post->ID, '_ffwh_allowed_origins', '*')));
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
$allow = (in_array('*', $origins, true) || in_array($origin, $origins, true)) ? $origin : ($origins[0] ?: '*');
}
header('Access-Control-Allow-Origin: ' . $allow);
header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Framer-Signature, Framer-Webhook-Submission-Id, X-FFWH-Signature');
}
return $served;
}
private function get_endpoint_by_key($key)
{
$q = new WP_Query([
'post_type' => self::CPT,
'post_status' => 'any',
'meta_key' => '_ffwh_key',
'meta_value' => sanitize_text_field($key),
'posts_per_page' => 1,
'no_found_rows' => true,
'fields' => 'all',
]);
return $q->posts ? $q->posts[0] : null;
}
private function log($label, $data)
{
// lightweight log to PHP error_log
$safe = is_scalar($data) ? (string) $data : wp_json_encode($data);
error_log('[FFWH] ' . $label . ': ' . $safe);
}
public function handle_incoming(WP_REST_Request $request)
{
if (!class_exists('FluentForm\App\Services\Form\SubmissionHandlerService')) {
return new WP_Error('ffwh_fluentforms_missing', 'Fluent Forms is not active.', ['status' => 501]);
}
$key = sanitize_text_field($request->get_param('key'));
$endpoint = $this->get_endpoint_by_key($key);
if (!$endpoint) {
return new WP_Error('ffwh_not_found', 'Webhook not found.', ['status' => 404]);
}
$form_id = intval(get_post_meta($endpoint->ID, '_ffwh_form_id', true));
if (!$form_id) {
return new WP_Error('ffwh_form_missing', 'This webhook is not linked to a form.', ['status' => 409]);
}
// Signing (optional)
$secret = get_post_meta($endpoint->ID, '_ffwh_secret', true);
$raw = $request->get_body() ?: '';
if (!empty($secret)) {
$sig = $request->get_header('framer-signature'); // "sha256=..."
$sid = $request->get_header('framer-webhook-submission-id');
if ($sig && $sid && stripos($sig, 'sha256=') === 0 && strlen($sig) === 71) {
$hmac = hash_hmac('sha256', $raw . $sid, $secret);
$expected = 'sha256=' . $hmac;
if (!hash_equals($expected, $sig)) {
$this->log('bad_sig', ['key' => $key, 'has_sig' => !!$sig, 'has_sid' => !!$sid]);
return new WP_Error('ffwh_bad_sig', 'Invalid Framer signature.', ['status' => 401]);
}
} else {
// Legacy fallback: allow X-FFWH-Signature = HMAC(raw, secret)
$legacy = $request->get_header('x-ffwh-signature');
if (!$legacy || !hash_equals(hash_hmac('sha256', $raw, $secret), $legacy)) {
$this->log('sig_missing', ['key' => $key]);
return new WP_Error('ffwh_sig_missing', 'Signature headers missing.', ['status' => 401]);
}
}
}
// Parse JSON or form-encoded
$payload = [];
$ct = $request->get_header('content-type') ?: '';
if (stripos($ct, 'application/json') !== false) {
$payload = json_decode($raw, true);
if (!is_array($payload)) {
$payload = [];
}
} else {
$payload = $request->get_params();
}
// Accept { fields: {...} } or flat
$incoming = (isset($payload['fields']) && is_array($payload['fields'])) ? $payload['fields'] : $payload;
// Clean and map keys
foreach (['key', '_', 'form_id'] as $unset) {
unset($incoming[$unset]);
}
$map_raw = get_post_meta($endpoint->ID, '_ffwh_field_map', true);
$map = ($map_raw && is_array(json_decode($map_raw, true))) ? json_decode($map_raw, true) : [];
$ff_data = [];
foreach ($incoming as $k => $v) {
$target = isset($map[$k]) ? $map[$k] : $k;
if (is_array($v)) {
$ff_data[$target] = array_map(function ($item) {
return is_scalar($item) ? sanitize_text_field((string) $item) : $item;
}, $v);
} else {
$ff_data[$target] = is_scalar($v) ? sanitize_text_field((string) $v) : $v;
}
}
$this->log('incoming', ['form_id' => $form_id, 'keys' => array_keys($ff_data)]);
// Insert via Fluent Forms
try {
$service = new \FluentForm\App\Services\Form\SubmissionHandlerService();
$entry_id = $service->handleSubmission($ff_data, $form_id);
} catch (\FluentForm\Framework\Validator\ValidationException $ve) {
$errors = method_exists($ve, 'errors') ? $ve->errors() : ['validation' => 'Validation failed'];
$this->log('validation_error', $errors);
return new WP_REST_Response(['ok' => false, 'errors' => $errors], 422);
} catch (\Throwable $e) {
$this->log('submit_error', $e->getMessage());
return new WP_Error('ffwh_submit_error', 'Submission failed: ' . $e->getMessage(), ['status' => 500]);
}
if (!$entry_id || is_wp_error($entry_id)) {
$msg = is_wp_error($entry_id) ? $entry_id->get_error_message() : 'Unknown error.';
$this->log('submit_failed', $msg);
return new WP_Error('ffwh_submit_failed', $msg, ['status' => 500]);
}
$this->log('ok', ['entry_id' => $entry_id]);
return new WP_REST_Response(['ok' => true, 'form_id' => $form_id, 'entry_id' => $entry_id], 200);
}
}
FFWH_Plugin::instance();
Install: Go to your WordPress dashboard → Plugins → Add New → Upload Plugin. Select the .zip
file you created and click Install Now. After it's installed, click Activate.
Then, go to Fluent Webhooks → Add New, pick your target Fluent Form, Publish the page, and copy the Endpoint URL (aka Webhook URL).
Reminder: Make sure the input names in your form fields are identical in both Framer and Fluent. Future-you will send a thank-you meme.
3) Google Apps Script: Copy Rows, Post to WP, Mark Synced
This script has three functions:
syncDataByHeaders()
→ Copies new rows fromRAW Data
intoSubmissions
by matching headers and a key (e.g.,Date
).processNewSubmissions()
→ POSTs unsyncedSubmissions
rows to your webhook, then writes back Synced + Status.runAll()
→ Runs both functions in order (perfect for a trigger).
In the Google Sheet, open Extensions → Apps Script, paste the code below, then set your
WEBHOOK_URL
. LeaveSIGNING_SECRET
blank (or set it if you enabled the signature in WP).
function syncDataByHeaders() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const RAW = ss.getSheetByName('RAW Data');
const SUB = ss.getSheetByName('Submissions');
if (!RAW || !SUB) throw new Error("Missing 'RAW Data' or 'Submissions' sheet.");
// ---- Configure your identity columns (by header name)
const KEY_COLUMNS = ['Date']; // or ['Date','Email'] for extra safety
const UTC_TZ = 'Etc/UTC';
// ---- Read headers
const rawLastRow = RAW.getLastRow();
const rawLastCol = RAW.getLastColumn();
if (rawLastRow <= 1) return; // only header
const subLastRow = SUB.getLastRow();
const subLastCol = SUB.getLastColumn();
const rawHeader = RAW.getRange(1, 1, 1, rawLastCol).getValues()[0].map(h => String(h || '').trim());
const subHeader = SUB.getRange(1, 1, 1, subLastCol).getValues()[0].map(h => String(h || '').trim());
// ---- Build header index maps
const rawIdx = Object.create(null);
rawHeader.forEach((h, i) => {
if (h) rawIdx[h] = i;
});
const subIdx = Object.create(null);
subHeader.forEach((h, i) => {
if (h) subIdx[h] = i;
});
// ---- Validate key columns exist on BOTH sides
const keyRawIdx = [];
const keySubIdx = [];
for (const k of KEY_COLUMNS) {
if (!(k in rawIdx)) throw new Error(`Key column '${k}' not found in RAW header`);
if (!(k in subIdx)) throw new Error(`Key column '${k}' not found in Submissions header`);
keyRawIdx.push(rawIdx[k]);
keySubIdx.push(subIdx[k]);
}
// ---- Build mapping from RAW -> SUB by common headers
const commonHeaders = subHeader.filter(h => h && (h in rawIdx));
const mapRawToSub = commonHeaders.map(h => rawIdx[h]); // raw source positions in SUB order
// ---- Helpers
const normDateText = v => {
if (typeof v === 'string') return v.trim(); // e.g., "2025-08-14T18:56:41Z"
if (v instanceof Date) return Utilities.formatDate(v, UTC_TZ, "yyyy-MM-dd'T'HH:mm:ss'Z'");
return '';
};
const norm = v => {
if (v === null || v === undefined) return '';
if (v instanceof Date) return Utilities.formatDate(v, UTC_TZ, "yyyy-MM-dd'T'HH:mm:ss'Z'");
if (typeof v === 'number') return String(v);
if (typeof v === 'boolean') return v ? '1' : '0';
return String(v).trim().replace(/\s+/g, ' ');
};
const makeKeyFromRaw = row => JSON.stringify(keyRawIdx.map(i => (subHeader[subIdx[KEY_COLUMNS[keyRawIdx.indexOf(i)]]] === 'Date' ? normDateText(row[i]) : norm(row[i]))));
const makeKeyFromSub = row => JSON.stringify(keySubIdx.map(i => (subHeader[i] === 'Date' ? normDateText(row[i]) : norm(row[i]))));
const isBlank = row => row.every(v => v === '' || v === null || v === undefined);
// ---- Existing keys from Submissions
const existing = new Set();
if (subLastRow > 1) {
const subData = SUB.getRange(2, 1, subLastRow - 1, subLastCol).getValues();
for (const r of subData) {
if (!isBlank(r)) existing.add(makeKeyFromSub(r));
}
}
// ---- Read RAW rows and project to SUB order
const rawData = RAW.getRange(2, 1, rawLastRow - 1, rawLastCol).getValues();
const toAppend = [];
for (const r of rawData) {
if (isBlank(r)) continue;
// Build key using RAW columns by name
const key = makeKeyFromRaw(r);
if (!key) continue;
if (existing.has(key)) continue;
existing.add(key);
// Project RAW row into SUB's column order
const projected = new Array(subLastCol).fill('');
for (let j = 0; j < commonHeaders.length; j++) {
const subPos = subIdx[commonHeaders[j]];
const rawPos = mapRawToSub[j];
projected[subPos] = r[rawPos];
}
toAppend.push(projected);
}
if (toAppend.length) {
SUB.getRange(SUB.getLastRow() + 1, 1, toAppend.length, subLastCol).setValues(toAppend);
}
}
/***** CONFIG *****/
const CONFIG = {
SHEET_NAME: 'Submissions',
WEBHOOK_URL: 'https://YOUR-SITE.com/wp-json/ffwh/v1/hook/YOUR-KEY', // ← paste your endpoint
// Leave blank to send unsigned. If you set a secret in WP, paste the same here to send X-FFWH-Signature.
SIGNING_SECRET: '',
BATCH_LIMIT: 50, // max rows to send per run
TIMEOUT_MS: 15000, // UrlFetch timeout
SYNC_COL_NAME: 'Synced',
STATUS_COL_NAME: 'Status'
};
/**
* Scans unsynced rows and POSTs each to the webhook; writes back Synced + Status.
* Run manually or via an installable trigger (From spreadsheet → On change).
*/
function processNewSubmissions() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sh = ss.getSheetByName(CONFIG.SHEET_NAME);
if (!sh) throw new Error(`Sheet "${CONFIG.SHEET_NAME}" not found`);
// Read headers
const lastRow = sh.getLastRow();
const lastCol = sh.getLastColumn();
if (lastRow < 2 || lastCol < 1) return; // nothing to do
const header = sh.getRange(1, 1, 1, lastCol).getValues()[0].map(h => String(h).trim());
// Ensure Synced & Status columns exist at the end
let syncColIndex = header.indexOf(CONFIG.SYNC_COL_NAME);
let statusColIndex = header.indexOf(CONFIG.STATUS_COL_NAME);
const toAppend = [];
if (syncColIndex === -1) toAppend.push(CONFIG.SYNC_COL_NAME);
if (statusColIndex === -1) toAppend.push(CONFIG.STATUS_COL_NAME);
if (toAppend.length) {
sh.getRange(1, lastCol + 1, 1, toAppend.length).setValues([toAppend]);
// refresh header references
const newLastCol = sh.getLastColumn();
const hdr2 = sh.getRange(1, 1, 1, newLastCol).getValues()[0].map(h => String(h).trim());
syncColIndex = hdr2.indexOf(CONFIG.SYNC_COL_NAME);
statusColIndex = hdr2.indexOf(CONFIG.STATUS_COL_NAME);
}
// Build a set of payload columns (exclude Synced/Status and blanks)
const payloadCols = header
.map((name, idx) => ({ name, idx }))
.filter(c => c.name && c.idx !== syncColIndex && c.idx !== statusColIndex);
if (payloadCols.length === 0) return;
// Read data rows
const data = sh.getRange(2, 1, lastRow - 1, sh.getLastColumn()).getValues();
let processed = 0;
for (let r = 0; r < data.length; r++) {
if (processed >= CONFIG.BATCH_LIMIT) break;
const row = data[r];
const syncVal = String(row[syncColIndex] ?? '').trim();
const alreadySynced = syncVal.toLowerCase() === 'true' || syncVal === '1' || syncVal.toLowerCase() === 'yes';
if (alreadySynced) continue;
// Build payload object from headers
const payload = {};
payloadCols.forEach(c => {
if (c.name && c.name !== CONFIG.SYNC_COL_NAME && c.name !== CONFIG.STATUS_COL_NAME) {
payload[c.name] = row[c.idx] === '' ? '' : row[c.idx];
}
});
// Prepare request
const body = JSON.stringify(payload);
const headers = { 'Content-Type': 'application/json' };
// Optional signing (plugin accepts this legacy header if secret exists)
if (CONFIG.SIGNING_SECRET) {
headers['X-FFWH-Signature'] = hmacSha256Hex(body, CONFIG.SIGNING_SECRET);
}
let code = 0, text = '', ok = false;
try {
const res = UrlFetchApp.fetch(CONFIG.WEBHOOK_URL, {
method: 'post',
contentType: 'application/json',
payload: body,
headers,
muteHttpExceptions: true,
followRedirects: false,
escaping: false
});
code = res.getResponseCode();
text = safeTruncate(res.getContentText() || '', 500);
ok = code >= 200 && code < 300 && /"ok"\s*:\s*true/i.test(text);
} catch (e) {
code = -1;
text = `ERR: ${e.message}`;
ok = false;
}
// Write back Synced + Status
const writeRow = r + 2;
sh.getRange(writeRow, syncColIndex + 1).setValue(ok ? true : '');
sh.getRange(writeRow, statusColIndex + 1).setValue(`${code} ${text}`);
processed++;
SpreadsheetApp.flush();
}
}
/** Utilities **/
function hmacSha256Hex(message, secret) {
const sigBytes = Utilities.computeHmacSha256Signature(message, secret);
return bytesToHex(sigBytes);
}
function bytesToHex(bytes) {
return bytes.map(b => ('0' + (b & 0xff).toString(16)).slice(-2)).join('');
}
function safeTruncate(s, n) {
return (s && s.length > n) ? s.substring(0, n) + '…' : s;
}
/** Function to run with Google Sheet onChange trigger. **/
function runAll() {
syncDataByHeaders();
SpreadsheetApp.flush();
processNewSubmissions();
}
Trigger It
- In Google Sheets, go to Extensions → Apps Script → Triggers → Add Trigger.
- Function to run:
runAll
- Event source: From spreadsheet
- Event type: On change
- You can delete the trigger at any time to pause syncing. Manual runs will still work.
Daily Rhythm
- New Framer submissions appear in
RAW Data
. runAll()
copies new rows (by header + key) intoSubmissions
, then posts them to WordPress, and marks them with Synced + Status.
Pros & Cons
Pros
- Works with any source that drops rows in
RAW Data
. - Human-in-the-loop control (staging in
Submissions
). - Clear receipts with a ✅ and status codes.
Cons
- One tiny relay hop (Sheets) for a big payoff: visibility and veto power.
Moral of the Hack
When "direct" gets precious, route through Sheets. It’s the duct tape of the internet—quiet, cheap, weirdly reliable. The title says Framer → Fluent, but this is really Anything → Sheets → Fluent. Ahahaha.