- Create a Sheet on Google Sheets.
- Save the SHEET_ID stored in the URL before
/edit
.

- Paste this as
code.gs
in the editor
/***** Einstellungen *****/
// Felder/Spalten, die NICHT angelegt/geschrieben werden
const RESERVED_KEYS = [
'token',
'hp',
'config_path',
'source',
'Raw JSON',
'_content_type',
'_remote_ip',
'_server_ts',
'_source',
'_user_agent',
'SHEET_ID',
'SHEET_NAME'
];
// Bereits vorhandene verbotene Spalten physisch entfernen?
const TRIM_RESERVED_COLUMNS = true;
/***** Helpers *****/
function parseRequest_(e) {
if (!e) return {};
if (e.postData && e.postData.type && e.postData.type.indexOf('application/json') !== -1) {
try { return JSON.parse(e.postData.contents || '{}'); } catch (_) { return {}; }
}
return e.parameter || {};
}
function readHeader_(sh) {
const lastCol = sh.getLastColumn();
if (sh.getLastRow() === 0 || lastCol === 0) return [];
return sh.getRange(1, 1, 1, lastCol).getValues()[0].map(v => String(v || ''));
}
function writeHeader_(sh, header) {
if (header.length === 0) header = [''];
sh.getRange(1, 1, 1, header.length).setValues([header]);
}
function trimReservedColumns_(sh, header) {
const toDelete = [];
header.forEach((t, idx) => { if (RESERVED_KEYS.includes(t)) toDelete.push(idx + 1); });
toDelete.sort((a, b) => b - a).forEach(colIdx => sh.deleteColumn(colIdx));
}
function ensureHeader_(sh, incomingKeys) {
let header = readHeader_(sh);
// Optional: verbotene Spalten entfernen
if (TRIM_RESERVED_COLUMNS && header.length > 0) {
const before = header.slice();
trimReservedColumns_(sh, header);
header = readHeader_(sh);
if (header.join('|') !== before.join('|')) writeHeader_(sh, header);
}
// Neue, erlaubte Keys ergänzen
const toAdd = [];
for (const k of incomingKeys) {
if (!k) continue;
if (RESERVED_KEYS.includes(k)) continue;
if (!header.includes(k)) toAdd.push(k);
}
if (header.length === 0) {
header = toAdd.slice();
writeHeader_(sh, header);
} else if (toAdd.length) {
header = header.concat(toAdd);
writeHeader_(sh, header);
}
return header;
}
/***** Web-App *****/
function doPost(e) {
const req = parseRequest_(e);
// Pflicht: Token & Sheet-Parameter
const tokenCfg = PropertiesService.getScriptProperties().getProperty('WEBHOOK_TOKEN') || '';
const provided = (req && (req.token || req.TOKEN)) || (e && e.parameter && e.parameter.token) || '';
if (!tokenCfg || provided !== tokenCfg) {
return ContentService.createTextOutput(JSON.stringify({ ok: false, error: 'unauthorized' }))
.setMimeType(ContentService.MimeType.JSON);
}
const sheetId = req.SHEET_ID || (e && e.parameter && e.parameter.SHEET_ID);
const sheetName = req.SHEET_NAME || (e && e.parameter && e.parameter.SHEET_NAME);
if (!sheetId || !sheetName) {
return ContentService.createTextOutput(JSON.stringify({ ok: false, error: 'missing SHEET_ID/SHEET_NAME' }))
.setMimeType(ContentService.MimeType.JSON);
}
const ss = SpreadsheetApp.openById(String(sheetId));
const sh = ss.getSheetByName(String(sheetName)) || ss.insertSheet(String(sheetName));
const incomingKeys = Object.keys(req || {});
const header = ensureHeader_(sh, incomingKeys);
if (header.length === 0) {
return ContentService.createTextOutput(JSON.stringify({ ok: true, note: 'no allowed fields' }))
.setMimeType(ContentService.MimeType.JSON);
}
const row = header.map(h => Object.prototype.hasOwnProperty.call(req, h) ? String(req[h]) : '');
sh.appendRow(row);
return ContentService.createTextOutput(JSON.stringify({ ok: true }))
.setMimeType(ContentService.MimeType.JSON);
}
function doGet() {
return ContentService.createTextOutput(JSON.stringify({ ok: true, when: (new Date()).toISOString() }))
.setMimeType(ContentService.MimeType.JSON);
}
- Choose
Bereitstellen
– Neue Bereitstellung
- Set the Parameters:
- Klick Bereitstellen
- Save the Web-App URL
- At the Project-Settings add a Script-Attribut
WEBHOOK_TOKEN
(save this)
- Add a file
webhook.php
in Elements Root
<?php
/**
* webhook.php — Forwarder zu Google Apps Script (Pflicht-Parameter)
* Erfordert in der URL:
* ?APPS_URL=...&TOKEN_FALLBACK=...&SHEET_ID=...&SHEET_NAME=...
* Nimmt POST (x-www-form-urlencoded/multipart) und JSON an,
* hängt token + SHEET_ID/SHEET_NAME an und postet zu Apps Script /exec.
* Folgt 302 NICHT. Antwort ist JSON.
*/
declare(strict_types=1);
const CA_BUNDLE = ''; // optional, falls php.ini kein curl.cainfo/openssl.cafile gesetzt hat
const LOG_FILE = ''; // optional: z. B. 'C:\inetpub\logs\webhook-forwarder\webhook.log'
const ADD_META_FIELDS = true;
@date_default_timezone_set('Europe/Berlin');
header('Content-Type: application/json; charset=utf-8');
/* ---------- helpers ---------- */
function log_line(string $level, string $msg, array $ctx = []): void
{
if (!LOG_FILE) return;
$line = sprintf("[%s] %s: %s %s\n",
(new DateTimeImmutable())->format('Y-m-d\TH:i:s.uP'),
$level, $msg,
$ctx ? json_encode($ctx, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) : ''
);
@file_put_contents(LOG_FILE, $line, FILE_APPEND|LOCK_EX);
}
function json_out(int $code, array $data): never
{
http_response_code($code);
echo json_encode($data, JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES);
exit;
}
function validate_apps_url(string $url): bool
{
$p = @parse_url($url);
if (!$p) return false;
if (($p['scheme'] ?? '') !== 'https') return false;
if (($p['host'] ?? '') !== 'script.google.com') return false;
return (bool)preg_match('~^/macros/s/[A-Za-z0-9_-]+/exec$~', $p['path'] ?? '');
}
function require_query_params(): array
{
$raw = $_SERVER['QUERY_STRING'] ?? '';
parse_str(str_replace(';','&',$raw), $qs);
$appsUrl = $qs['APPS_URL'] ?? null;
$token = $qs['TOKEN_FALLBACK'] ?? null;
$sheetId = $qs['SHEET_ID'] ?? null;
$sheetNm = $qs['SHEET_NAME'] ?? null;
if (!$appsUrl || !$token || !$sheetId || !$sheetNm) {
json_out(400, ['ok'=>false,'error'=>'Missing URL params: APPS_URL, TOKEN_FALLBACK, SHEET_ID, SHEET_NAME']);
}
if (!validate_apps_url($appsUrl)) {
json_out(400, ['ok'=>false,'error'=>'Invalid APPS_URL']);
}
// Sheet-ID grob validieren (Buchstaben/Zahlen/_-; Länge > 20)
if (!preg_match('~^[A-Za-z0-9_-]{20,}$~', $sheetId)) {
json_out(400, ['ok'=>false,'error'=>'Invalid SHEET_ID format']);
}
// Sheet-Name >0 Zeichen (Umlaute/Spaces ok)
if (!is_string($sheetNm) || $sheetNm === '') {
json_out(400, ['ok'=>false,'error'=>'Invalid SHEET_NAME']);
}
return [$appsUrl, (string)$token, (string)$sheetId, (string)$sheetNm];
}
/* ---------- request ---------- */
log_line('INFO','incoming',[
'method'=>$_SERVER['REQUEST_METHOD'] ?? '',
'ip'=>$_SERVER['REMOTE_ADDR'] ?? '',
'ctype'=>$_SERVER['CONTENT_TYPE'] ?? ($_SERVER['HTTP_CONTENT_TYPE'] ?? ''),
'ua'=>$_SERVER['HTTP_USER_AGENT'] ?? '',
'uri'=>$_SERVER['REQUEST_URI'] ?? '',
]);
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
json_out(405, ['ok'=>false,'error'=>'Only POST allowed']);
}
[$appsUrl, $token, $sheetId, $sheetName] = require_query_params();
$ctype = $_SERVER['CONTENT_TYPE'] ?? $_SERVER['HTTP_CONTENT_TYPE'] ?? '';
$rawBody = file_get_contents('php://input') ?: '';
$payload = [];
// JSON oder Form
if (stripos($ctype, 'application/json') !== false) {
$tmp = json_decode($rawBody, true);
if (is_array($tmp)) $payload = $tmp;
}
if (!$payload) $payload = $_POST ?: [];
// Honeypot → stiller Erfolg
if (!empty($payload['hp'])) {
log_line('INFO','honeypot'); json_out(200, ['ok'=>true]);
}
// optionale Meta
if (ADD_META_FIELDS) {
$payload['_server_ts'] = (new DateTimeImmutable())->format('c');
$payload['_remote_ip'] = $_SERVER['REMOTE_ADDR'] ?? '';
$payload['_content_type'] = $ctype;
$payload['_user_agent'] = $_SERVER['HTTP_USER_AGENT'] ?? '';
$payload['_source'] = 'rw_webhook_php';
}
// Pflichtwerte einfügen/überschreiben
$payload['token'] = $token;
$payload['SHEET_ID'] = $sheetId;
$payload['SHEET_NAME'] = $sheetName;
/* ---------- forward ---------- */
$postFields = http_build_query($payload, '', '&', PHP_QUERY_RFC3986);
$ch = curl_init($appsUrl);
if ($ch === false) json_out(500, ['ok'=>false,'error'=>'cURL init failed']);
$opts = [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded; charset=UTF-8'],
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_FOLLOWLOCATION => false, // 302 nicht folgen
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'cornrow-webhook/1.0',
];
if (CA_BUNDLE) $opts[CURLOPT_CAINFO] = CA_BUNDLE;
curl_setopt_array($ch, $opts);
$respBody = curl_exec($ch);
$errno = curl_errno($ch);
$error = $errno ? curl_error($ch) : '';
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
$hint = ($errno === 60) ? ' (CA bundle fehlt? php.ini curl.cainfo/openssl.cafile setzen)' : '';
log_line('ERROR','curl', ['errno'=>$errno,'error'=>$error]);
json_out(502, ['ok'=>false,'error'=>"Upstream TLS/HTTP error: $error$hint"]);
}
if ($status >= 200 && $status < 400) {
log_line('INFO','forwarded',['status'=>$status]);
json_out(200, ['ok'=>true,'status'=>$status]);
}
log_line('ERROR','upstream',['status'=>$status,'body'=>$respBody]);
json_out($status ?: 500, ['ok'=>false,'error'=>'Upstream HTTP '.($status ?: 0),'body'=>$respBody]);
- Create a Form with Fields
- Use Webhook in your form. The URL should be like
https://www.yourdomain.tld/webhook.php?TOKEN_FALLBACK=aaa;APPS_URL=bbb;SHEET_ID=ccc;SHEET_NAME=ddd
- aaa = Your WEBHOOK_TOKEN
- bbb = Your APPS_URL
- ccc = Your SHEET_ID
- ddd = Sheet Name (it will be created)