HTTP endpoints and webhooks with doPost
Handle HTTP requests and webhooks in Apps Script with doGet and doPost, test redirected web-app URLs, and verify HMAC-signed JSON payloads.
Send a fictional request note, authenticate its signed fields, and distinguish acceptance, duplicate receipt, rejection, and an uncertain response without performing a downstream action.
Send a fictional request note from one Apps Script project to another, authenticate it with a shared key, and keep a receipt. Sending the same event again returns the original receipt and creates no new record. The receiver stores only the event and its receipt in Script Properties. Each request body is at most 2,048 UTF-8 bytes.
This chapter builds on runtime and permissions, Fetch data from web APIs, and web apps. A sender project posts to a receiver project over HTTP. Each request carries an event ID and an HMAC-SHA256 signature, a message authentication code computed from the event's fields and a shared key. The receiver stores receipts in a list, the receipt ledger, and checks event IDs to distinguish retries from new events.
Choose google.script.run or an HTTP endpoint
An HTML-service page hosted by Apps Script can call a server-side function through google.script.run. That API works only inside an Apps Script HTML-service page. The browser names the server function directly and receives its result through success and failure callbacks.
google.script.run is asynchronous and can complete calls out of order, so a client must sequence dependent work in callbacks or promises. withUserObject(value) passes a client-side value to both callbacks without sending it to the server.
Use doGet(e) and doPost(e) when the caller sends an HTTP request: curl, fetch, another Apps Script project, or an external webhook sender. An Apps Script project has one top-level doGet and one doPost. They return HtmlOutput or TextOutput. The web app deployment's access setting decides who can call them, and its execution-identity setting decides which account they run as.
| Need | google.script.run | doGet or doPost |
|---|---|---|
| Caller | Apps Script HTML-service page | Browser, command line, server, or webhook sender |
| Server selection | Calls a permitted function by name | doGet handles every GET, and doPost handles every POST |
| Result model | Asynchronous success and failure callbacks | HTTP response body, MIME type, and platform status behavior |
| Client context | withUserObject keeps a client-only value | Send everything the server needs in the signed request body |
| Deployment | None, because Apps Script already serves the page | A web app deployment, whose access setting decides who can call it |
| Testing | Browser UI plus server handlers | curl, UrlFetchApp, or another HTTP client |
| Download-like response | Poor fit for arbitrary response bytes | A text body from ContentService, where downloadAsFile(filename) can suggest a filename. The response arrives through a redirect (see Test a JSON POST with curl) |
With either option, validate every input on the server and keep responses small.
Choose a request format both projects can handle
An HTTP endpoint accepts a defined request and returns a defined response. A webhook is an HTTP request that pushes an event to such a URL when something happens. In this chapter, the sender dispatches a custom request.note event when you run a function in the Apps Script editor.
Because you control both projects, you can put the signature inside the JSON body. The documented web-app event object exposes the POST body's text (e.postData.contents) and MIME type (e.postData.type), but no inbound request headers. Likewise, a TextOutput sets body text and MIME type, and it has no methods to set a custom HTTP status code or response headers.
The receiver therefore returns a JSON response envelope. ok: true with a matching receipt means acceptance, and ok: false with an error code means an application rejection. The sender still checks the HTTP status code first, because Google can reject access or fail a request before doPost runs.
A valid shared-key HMAC shows that a holder of that key produced the signed fields and that they were not modified in transit. The sender and receiver hold the same key, so both can compute valid signatures. Use a distinct random key for each sender-receiver pair. See RFC 2104 for the HMAC construction and key requirements.
Limit what the public URL exposes
The web app in the web apps chapter could be opened only by you. This receiver must accept requests that carry no interactive browser session. UrlFetchApp does not forward a browser login cookie, so the receiver authenticates each request with the body HMAC instead.
| Decision | Choice for this example | Consequence |
|---|---|---|
| Receiver execution identity | Me, represented by USER_DEPLOYING | The receiver runs as the deploying account. |
| Receiver audience | Anyone, including callers not signed in, represented by ANYONE_ANONYMOUS | Requests reach doPost without Google sign-in, and the handler authenticates the payload itself. |
| Receiver project access | Keep code-editor access private | Endpoint callers cannot view or edit the script project or its properties. |
| Receiver Google data scopes | An empty oauthScopes array | The receiver needs no Sheets, Gmail, Drive, or outbound-request permissions. |
| Sender access | Run only from its private editor | Requires script.external_request and has no incoming endpoint. |
The manifest access values distinguish ANYONE (requires a signed-in Google account) from ANYONE_ANONYMOUS.
doGet returns only a fixed service name and protocol version. The inspection and pruning functions run from the editor and are not reachable over HTTP.
Install two separate projects
Create two standalone projects from script.google.com/home with New project, named Custom event receiver and Custom event sender. Separate projects give each side its own Script Properties and its own lock.
In the receiver, replace
Code.gsand add a Script file namedProtocol(Protocol.gs).In the sender, replace
Code.gsand add Script files namedProtocol(Protocol.gs) andSample(Sample.gs).In each project's Project Settings, enable Show "appsscript.json" manifest file in editor and replace
appsscript.jsonwith the matching manifest below.Save both projects. The two
Protocol.gsfiles are identical.
Receiver manifest:
{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"oauthScopes": [],
"webapp": {
"access": "ANYONE_ANONYMOUS",
"executeAs": "USER_DEPLOYING"
}
}
Sender manifest:
{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/script.external_request"
]
}
The sender manifest declares script.external_request so UrlFetchApp can call the receiver's /exec URL. See Google's authorization guide for how editor execution identity differs from a deployed web app's identity.
Sender sample payload:
// Fictional event data, not a real tracker ID or personal information.
const SAMPLE_EVENT_PAYLOAD = Object.freeze({
type: 'request.note',
requestId: 'REQ-001',
note: 'Check step-free access before Friday.',
});
Generate and store one shared secret
Generate a 32-byte random hex string locally with OpenSSL:
openssl rand -hex 32 OpenSSL's rand command prints 64 lowercase hexadecimal characters, which encode 256 bits of random key material. Store the same value in both projects. Script Properties are separate for each project, so repeat these steps in the sender and in the receiver:
Open Project Settings > Script Properties.
Click Add script property (or Edit script properties > Add script property).
Set the property name to
EVENT_SECRET_HEX.Paste the 64-character hex string without quotes or whitespace, then click Save script properties.
The protocol uses the 64 hex characters of EVENT_SECRET_HEX as a UTF-8 HMAC key string. It does not decode them into raw bytes. Both projects must use the key the same way.
Script Properties keep secrets out of source files, but anyone with edit access to the script project can read or log them.
Deploy only the receiver
In the receiver editor, run checkReceiverSetup. It checks the format of EVENT_SECRET_HEX and reads the receipt ledger without writing to it. On a fresh project it logs configurationShapeChecked: true and storedReceipts: 0.
Next, deploy the receiver and configure the sender:
In the receiver, choose Deploy > New deployment, select Web app, and enter a description.
Set Execute as to Me (
USER_DEPLOYING) and Who has access to Anyone, even anonymous (ANYONE_ANONYMOUS), then click Deploy.Copy the
/execURL from the deployment dialog. In the sender's Script Properties, addEVENT_ENDPOINT_URLwith that exacthttps://script.google.com/macros/s/.../execURL.In the sender editor, run
checkSenderSetupand approve thescript.external_requestconsent prompt.checkSenderSetupchecks the URL and secret formats locally and sends nothing.
Use the /exec URL. The /dev URL requires editor sign-in, and the script.googleusercontent.com redirect URL described in the next section works only once. See the web-app guide and Content Service redirect rules.
To verify that the endpoint answers without sign-in, open the /exec URL in an incognito browser tab. It returns:
{"protocol":1,"ok":true,"service":"custom-event-receiver"} Test a JSON POST with curl
ContentService redirects responses from script.google.com to a one-time script.googleusercontent.com URL. Pass -L so curl follows the redirect, and use -d so the initial request is a POST and the redirected retrieval is a GET:
curl -L \
-H 'Content-Type: application/json' \
-d '{"foo":"bar"}' \
'https://script.google.com/macros/s/DEPLOYMENT_ID/exec' Replace DEPLOYMENT_ID with your receiver's deployment ID. {"foo":"bar"} is not a valid signed envelope, so the receiver returns {"protocol":1,"ok":false,"code":"bad_request","retryable":false}. Omit -X POST and --post302. Forcing POST across the 302 redirect sends a POST to script.googleusercontent.com, which expects GET.
Agree on every authenticated field
The sender transmits UTF-8 JSON with Content-Type: application/json; charset=utf-8. A string's .length counts UTF-16 code units, and a non-ASCII character can take more UTF-8 bytes than it has code units. Both projects measure UTF-8 byte lengths with Utilities.newBlob(text).getBytes().length.
The envelope has exactly five fields:
| Field | Required value |
|---|---|
version | Numeric 1. |
eventId | Lowercase 36-character UUID v4 string created when the event is prepared. |
issuedAt | Positive safe integer containing Date.now() in epoch milliseconds at preparation time. |
payload | Object with type, requestId, and note meeting the constraints below. |
signature | 32-byte HMAC-SHA256 digest encoded as 64 lowercase hex characters. |
payload.type must be 'request.note', and payload.requestId must match /^REQ-[0-9]{3}$/. payload.note must contain some non-whitespace text, no ASCII control characters, and at most 120 UTF-16 code units and 240 UTF-8 bytes. The full POST body cannot exceed 2,048 UTF-8 bytes. The receiver rejects any property not listed here.
canonicalEvent_ serializes a seven-element JSON array in fixed order:
'apps-script-custom-event'versioneventIdissuedAtpayload.typepayload.requestIdpayload.note
signEvent_ passes that compact JSON array string to Utilities.computeHmacSha256Signature(canonical, secret, Utilities.Charset.UTF_8) and converts the returned byte array to 64 lowercase hex characters. See the Utilities reference. Signing the canonical array authenticates the parsed field values regardless of the whitespace or key order in the outer JSON.
equalMac_ first checks that both strings are 64 lowercase hex characters. It then compares the character codes at all 64 positions, combining the differences with bitwise OR. A mismatch does not stop the comparison early. See OWASP's authentication guidance on early-exit string comparisons and OWASP's REST security guidance on transport protection.
Receiver Protocol.gs:
// Install an identical copy in each independent project. No shared library is used.
const EVENT_PROTOCOL = Object.freeze({
version: 1,
maxBodyBytes: 2048,
freshMs: 5 * 60 * 1000,
futureMs: 30 * 1000,
retentionMs: 24 * 60 * 60 * 1000,
maxReceipts: 8,
maxStoreBytes: 7000,
});
/** @param {unknown} value @param {string[]} keys */
function exactKeys_(value, keys) {
return value !== null && typeof value === 'object' && !Array.isArray(value) &&
Object.keys(value).length === keys.length &&
keys.every(function (key) { return Object.prototype.hasOwnProperty.call(value, key); });
}
/** @param {string} text */
function utf8Length_(text) {
return Utilities.newBlob(text).getBytes().length;
}
/** @param {string} text @param {number} maxBytes @returns {*} */
function parseBoundedJson_(text, maxBytes) {
if (typeof text !== 'string' || text.length === 0 || text.length > maxBytes ||
utf8Length_(text) > maxBytes) {
throw new Error('JSON text is missing or exceeds its byte limit.');
}
try {
return JSON.parse(text);
} catch (error) {
// Do not include a parser message that might quote the input.
throw new Error('Invalid JSON text.');
}
}
/** @param {*} event */
function validEventFields_(event) {
if (event.version !== EVENT_PROTOCOL.version ||
typeof event.eventId !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(event.eventId) ||
!Number.isSafeInteger(event.issuedAt) || event.issuedAt <= 0 ||
!exactKeys_(event.payload, ['type', 'requestId', 'note'])) {
return false;
}
const payload = event.payload;
return payload.type === 'request.note' && typeof payload.requestId === 'string' &&
/^REQ-[0-9]{3}$/.test(payload.requestId) &&
typeof payload.note === 'string' && payload.note.trim().length > 0 &&
payload.note.length <= 120 && !/[\u0000-\u001f\u007f]/.test(payload.note) &&
utf8Length_(payload.note) <= 240;
}
/** @param {string} body */
function readEnvelope_(body) {
const event = parseBoundedJson_(body, EVENT_PROTOCOL.maxBodyBytes);
if (!exactKeys_(event, ['version', 'eventId', 'issuedAt', 'payload', 'signature']) ||
!validEventFields_(event) || typeof event.signature !== 'string' ||
!/^[0-9a-f]{64}$/.test(event.signature)) {
throw new Error('Invalid event envelope.');
}
return event;
}
/** @param {*} event */
function eventFields_(event) {
return {
version: event.version,
eventId: event.eventId,
issuedAt: event.issuedAt,
payload: {
type: event.payload.type,
requestId: event.payload.requestId,
note: event.payload.note,
},
};
}
/** @param {*} event */
function canonicalEvent_(event) {
// No pretty printing, trailing newline, Unicode normalization, or key sorting.
return JSON.stringify([
'apps-script-custom-event', event.version, event.eventId, event.issuedAt,
event.payload.type, event.payload.requestId, event.payload.note,
]);
}
function readSecret_() {
const secret = PropertiesService.getScriptProperties().getProperty('EVENT_SECRET_HEX');
if (typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret)) {
throw new Error('Set EVENT_SECRET_HEX to the generated 64-character lowercase hex value.');
}
return secret;
}
/** @param {*} event @param {string} secret */
function signEvent_(event, secret) {
// The 64 hex characters are the literal UTF-8 key, NOT decoded hex bytes.
return Utilities.computeHmacSha256Signature(
canonicalEvent_(event), secret, Utilities.Charset.UTF_8,
).map(function (byte) {
return ('0' + (byte & 255).toString(16)).slice(-2);
}).join('');
}
/** @param {string} provided @param {string} expected */
function equalMac_(provided, expected) {
if (typeof provided !== 'string' || typeof expected !== 'string' ||
!/^[0-9a-f]{64}$/.test(provided) || !/^[0-9a-f]{64}$/.test(expected)) {
return false;
}
let difference = 0;
for (let i = 0; i < 64; i += 1) {
difference |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
}
// Fixed 64-position work, not a documented V8 constant-time primitive.
return difference === 0;
}
/** @param {*} event @param {number} now */
function freshEvent_(event, now) {
return now - event.issuedAt <= EVENT_PROTOCOL.freshMs &&
event.issuedAt - now <= EVENT_PROTOCOL.futureMs;
}
/** @param {*} receipt @param {*} event */
function validReceipt_(receipt, event) {
return exactKeys_(receipt, ['eventId', 'acceptedAt', 'expiresAt']) &&
receipt.eventId === event.eventId && Number.isSafeInteger(receipt.acceptedAt) &&
receipt.acceptedAt > 0 && Number.isSafeInteger(receipt.expiresAt) &&
receipt.expiresAt === receipt.acceptedAt + EVENT_PROTOCOL.retentionMs &&
freshEvent_(event, receipt.acceptedAt);
}
Sender Protocol.gs:
// Install an identical copy in each independent project. No shared library is used.
const EVENT_PROTOCOL = Object.freeze({
version: 1,
maxBodyBytes: 2048,
freshMs: 5 * 60 * 1000,
futureMs: 30 * 1000,
retentionMs: 24 * 60 * 60 * 1000,
maxReceipts: 8,
maxStoreBytes: 7000,
});
/** @param {unknown} value @param {string[]} keys */
function exactKeys_(value, keys) {
return value !== null && typeof value === 'object' && !Array.isArray(value) &&
Object.keys(value).length === keys.length &&
keys.every(function (key) { return Object.prototype.hasOwnProperty.call(value, key); });
}
/** @param {string} text */
function utf8Length_(text) {
return Utilities.newBlob(text).getBytes().length;
}
/** @param {string} text @param {number} maxBytes @returns {*} */
function parseBoundedJson_(text, maxBytes) {
if (typeof text !== 'string' || text.length === 0 || text.length > maxBytes ||
utf8Length_(text) > maxBytes) {
throw new Error('JSON text is missing or exceeds its byte limit.');
}
try {
return JSON.parse(text);
} catch (error) {
// Do not include a parser message that might quote the input.
throw new Error('Invalid JSON text.');
}
}
/** @param {*} event */
function validEventFields_(event) {
if (event.version !== EVENT_PROTOCOL.version ||
typeof event.eventId !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(event.eventId) ||
!Number.isSafeInteger(event.issuedAt) || event.issuedAt <= 0 ||
!exactKeys_(event.payload, ['type', 'requestId', 'note'])) {
return false;
}
const payload = event.payload;
return payload.type === 'request.note' && typeof payload.requestId === 'string' &&
/^REQ-[0-9]{3}$/.test(payload.requestId) &&
typeof payload.note === 'string' && payload.note.trim().length > 0 &&
payload.note.length <= 120 && !/[\u0000-\u001f\u007f]/.test(payload.note) &&
utf8Length_(payload.note) <= 240;
}
/** @param {string} body */
function readEnvelope_(body) {
const event = parseBoundedJson_(body, EVENT_PROTOCOL.maxBodyBytes);
if (!exactKeys_(event, ['version', 'eventId', 'issuedAt', 'payload', 'signature']) ||
!validEventFields_(event) || typeof event.signature !== 'string' ||
!/^[0-9a-f]{64}$/.test(event.signature)) {
throw new Error('Invalid event envelope.');
}
return event;
}
/** @param {*} event */
function eventFields_(event) {
return {
version: event.version,
eventId: event.eventId,
issuedAt: event.issuedAt,
payload: {
type: event.payload.type,
requestId: event.payload.requestId,
note: event.payload.note,
},
};
}
/** @param {*} event */
function canonicalEvent_(event) {
// No pretty printing, trailing newline, Unicode normalization, or key sorting.
return JSON.stringify([
'apps-script-custom-event', event.version, event.eventId, event.issuedAt,
event.payload.type, event.payload.requestId, event.payload.note,
]);
}
function readSecret_() {
const secret = PropertiesService.getScriptProperties().getProperty('EVENT_SECRET_HEX');
if (typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret)) {
throw new Error('Set EVENT_SECRET_HEX to the generated 64-character lowercase hex value.');
}
return secret;
}
/** @param {*} event @param {string} secret */
function signEvent_(event, secret) {
// The 64 hex characters are the literal UTF-8 key, NOT decoded hex bytes.
return Utilities.computeHmacSha256Signature(
canonicalEvent_(event), secret, Utilities.Charset.UTF_8,
).map(function (byte) {
return ('0' + (byte & 255).toString(16)).slice(-2);
}).join('');
}
/** @param {string} provided @param {string} expected */
function equalMac_(provided, expected) {
if (typeof provided !== 'string' || typeof expected !== 'string' ||
!/^[0-9a-f]{64}$/.test(provided) || !/^[0-9a-f]{64}$/.test(expected)) {
return false;
}
let difference = 0;
for (let i = 0; i < 64; i += 1) {
difference |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
}
// Fixed 64-position work, not a documented V8 constant-time primitive.
return difference === 0;
}
/** @param {*} event @param {number} now */
function freshEvent_(event, now) {
return now - event.issuedAt <= EVENT_PROTOCOL.freshMs &&
event.issuedAt - now <= EVENT_PROTOCOL.futureMs;
}
/** @param {*} receipt @param {*} event */
function validReceipt_(receipt, event) {
return exactKeys_(receipt, ['eventId', 'acceptedAt', 'expiresAt']) &&
receipt.eventId === event.eventId && Number.isSafeInteger(receipt.acceptedAt) &&
receipt.acceptedAt > 0 && Number.isSafeInteger(receipt.expiresAt) &&
receipt.expiresAt === receipt.acceptedAt + EVENT_PROTOCOL.retentionMs &&
freshEvent_(event, receipt.acceptedAt);
}
Authenticate before changing receipt state
Apps Script calls doGet(e) for GET requests and doPost(e) for POST requests. On a POST, it fills in e.postData.contents, e.postData.type, e.postData.length, and e.contentLength. See the web-app event documentation, which also notes that the platform reserves the query parameters c and sid.
doPost rejects a query string, a path suffix, a missing or oversized body, a non-JSON MIME type, malformed JSON, and invalid envelope fields before it checks the HMAC. Only after equalMac_ succeeds does the receiver check timestamp freshness, acquire the script lock, and read or write EVENT_RECEIPTS_V1. An unauthenticated request never acquires the lock or touches Script Properties.
const RECEIPTS_PROPERTY = 'EVENT_RECEIPTS_V1';
/** @param {*} e */
function doGet(e) {
if (e && (e.queryString || e.pathInfo)) return jsonReply_(failure_('bad_request', false));
// Liveness only: no secret, receipt, configuration, or readiness disclosure.
return jsonReply_({ protocol: 1, ok: true, service: 'custom-event-receiver' });
}
/** @param {*} e */
function doPost(e) {
try {
const post = e && e.postData;
if (!post || e.queryString || e.pathInfo ||
!Number.isInteger(e.contentLength) || e.contentLength <= 0 ||
e.contentLength > EVENT_PROTOCOL.maxBodyBytes || post.length !== e.contentLength ||
typeof post.type !== 'string' ||
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(post.type)) {
return jsonReply_(failure_('bad_request', false));
}
let event;
try {
event = readEnvelope_(post.contents);
} catch (error) {
return jsonReply_(failure_('bad_request', false));
}
const expected = signEvent_(event, readSecret_());
if (!equalMac_(event.signature, expected)) {
return jsonReply_(failure_('unauthorized', false));
}
if (!freshEvent_(event, Date.now())) return jsonReply_(failure_('stale', false));
// No lock acquisition or receipt-store access occurs before authentication.
return jsonReply_(receiveEvent_(event));
} catch (error) {
// Storage failure or response failure can follow acceptance. Do not echo errors.
return jsonReply_(failure_('unavailable', true));
}
}
/** @param {*} event */
function receiveEvent_(event) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) return failure_('busy', true);
try {
const store = PropertiesService.getScriptProperties();
const records = readReceipts_(store);
const now = Date.now();
// Include time spent waiting for the lock and reading/validating the ledger.
if (!freshEvent_(event, now)) return failure_('stale', false);
const live = records.filter(/** @param {*} record */ function (record) { return record.expiresAt > now; });
const existing = live.find(/** @param {*} record */ function (record) { return record.event.eventId === event.eventId; });
if (existing) {
if (canonicalEvent_(existing.event) !== canonicalEvent_(event)) {
return failure_('conflict', false);
}
// A duplicate does not refresh either timestamp or rewrite the ledger.
return receiptReply_('duplicate', existing);
}
if (live.length >= EVENT_PROTOCOL.maxReceipts) return failure_('capacity', true);
const record = {
event: eventFields_(event),
acceptedAt: now,
expiresAt: now + EVENT_PROTOCOL.retentionMs,
};
live.push(record);
const serialized = JSON.stringify({ version: 1, receipts: live });
if (utf8Length_(serialized) > EVENT_PROTOCOL.maxStoreBytes) return failure_('capacity', true);
// One value contains both the harmless event and its receipt. No external action.
store.setProperty(RECEIPTS_PROPERTY, serialized);
return receiptReply_('accepted', record);
} finally {
lock.releaseLock();
}
}
/** @param {PropertiesService.Properties} store */
function readReceipts_(store) {
const raw = store.getProperty(RECEIPTS_PROPERTY);
if (raw === null) return [];
const ledger = parseBoundedJson_(raw, EVENT_PROTOCOL.maxStoreBytes);
if (!exactKeys_(ledger, ['version', 'receipts']) || ledger.version !== 1 ||
!Array.isArray(ledger.receipts) || ledger.receipts.length > EVENT_PROTOCOL.maxReceipts) {
throw new Error('Invalid receipt ledger; stop and inspect rather than resetting it.');
}
const seen = new Set();
for (const record of ledger.receipts) {
if (!exactKeys_(record, ['event', 'acceptedAt', 'expiresAt']) ||
!exactKeys_(record.event, ['version', 'eventId', 'issuedAt', 'payload']) ||
!validEventFields_(record.event) ||
!validReceipt_({
eventId: record.event.eventId, acceptedAt: record.acceptedAt, expiresAt: record.expiresAt,
}, record.event) || seen.has(record.event.eventId)) {
throw new Error('Invalid receipt ledger; stop and inspect rather than resetting it.');
}
seen.add(record.event.eventId);
}
return ledger.receipts;
}
function checkReceiverSetup() {
readSecret_();
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('Receiver busy; try this editor check later.');
try {
const records = readReceipts_(PropertiesService.getScriptProperties());
console.log(JSON.stringify({ configurationShapeChecked: true, storedReceipts: records.length }));
} finally {
lock.releaseLock();
}
}
function inspectReceipts() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('Receiver busy; inspect again later.');
try {
const records = readReceipts_(PropertiesService.getScriptProperties());
const now = Date.now();
// Only editor-visible identifiers/timestamps, not notes, signatures, or secrets.
console.log(JSON.stringify(records.map(/** @param {*} record */ function (record) {
return {
eventId: record.event.eventId,
requestId: record.event.payload.requestId,
issuedAt: record.event.issuedAt,
acceptedAt: record.acceptedAt,
expiresAt: record.expiresAt,
expired: record.expiresAt <= now,
};
})));
} finally {
lock.releaseLock();
}
}
function pruneExpiredReceipts() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('Receiver busy; prune later.');
try {
const store = PropertiesService.getScriptProperties();
const records = readReceipts_(store);
const now = Date.now();
const live = records.filter(/** @param {*} record */ function (record) { return record.expiresAt > now; });
if (live.length !== records.length) {
store.setProperty(RECEIPTS_PROPERTY, JSON.stringify({ version: 1, receipts: live }));
}
console.log(JSON.stringify({ removedExpired: records.length - live.length, retained: live.length }));
} finally {
lock.releaseLock();
}
}
/** @param {string} status @param {*} record */
function receiptReply_(status, record) {
return {
protocol: 1,
ok: true,
status: status,
receipt: {
eventId: record.event.eventId,
acceptedAt: record.acceptedAt,
expiresAt: record.expiresAt,
},
};
}
/** @param {string} code @param {boolean} retryable */
function failure_(code, retryable) {
return { protocol: 1, ok: false, code: code, retryable: retryable };
}
/** @param {*} body */
function jsonReply_(body) {
return ContentService.createTextOutput(JSON.stringify(body)).setMimeType(ContentService.MimeType.JSON);
}
The receiver stores up to eight live receipts in the EVENT_RECEIPTS_V1 Script Property. LockService.getScriptLock() coordinates concurrent executions of the receiver project. tryLock(1000) waits up to one second for the lock. While it holds the lock, the handler:
Parses the receipt ledger.
Checks freshness again.
Drops expired receipts in memory.
Applies the duplicate, conflict, and capacity rules.
Calls
setPropertyonce if it accepts a new event.
The handler releases the lock in a finally block. See the Properties guide and Properties reference.
The property write and the HTTP response do not succeed or fail together. If the network drops after setProperty, the receipt is stored but the sender cannot tell. Resending the identical signed envelope within the freshness window returns duplicate with the stored receipt, so the sender learns that the first request was accepted.
Separate freshness from retention
Freshness is how long the receiver accepts a signed envelope after it is signed. issuedAt must be no more than five minutes in the past and no more than 30 seconds ahead of the receiver's clock. The receiver checks this before and again after it acquires the script lock.
Retention is how long the receiver stores an accepted eventId. Each receipt records acceptedAt and an expiresAt time 24 hours later. The ledger holds at most eight unexpired receipts and caps its serialized JSON at 7,000 UTF-8 bytes, under the 9 KB per-property quota.
| Incoming event | Receiver decision |
|---|---|
| Valid MAC, fresh time, new ID, available capacity | Store the event and receipt, and return accepted. |
| Valid MAC, fresh time, retained ID, identical signed fields | Return duplicate with the original receipt. Do not write or extend retention. |
| Valid MAC, fresh time, retained ID, different payload or timestamp | Return conflict, and keep the original record. |
| Valid MAC but stale or excessively future time | Return stale, even if an original receipt remains in storage. |
| Valid MAC, fresh new ID, eight live records | Return capacity. Do not evict a live receipt to make room. |
A sender can retry an unacknowledged request with the exact original envelope for up to five minutes. If the receiver already accepted the event, a retry within that freshness window returns duplicate without creating a second record. The receiver retains receipts for 24 hours. Re-signing the same eventId with a new issuedAt returns conflict, not duplicate. Once the five-minute window closes, stop retrying and compare inspectPendingEvent on the sender with inspectReceipts on the receiver.
The receiver drops expired receipts when it writes a new event, or when you run pruneExpiredReceipts in the editor. Receipts live in Script Properties rather than CacheService because a cache entry can be evicted before its expiration time.
Keep the same event ID when a send fails
prepareSampleEvent generates an eventId and issuedAt once, signs the envelope, and stores the wire JSON together with the target URL in EVENT_PENDING_V1. Calling prepareSampleEvent again while a pending record exists throws an error and leaves the unconfirmed event in place.
sendPendingEvent verifies that EVENT_ENDPOINT_URL and EVENT_SECRET_HEX still match the prepared envelope and that the event is still within the freshness window. It writes attemptedAt before its first UrlFetchApp.fetch call, so if the execution is interrupted, the record shows that the request may have been sent.
const PENDING_PROPERTY = 'EVENT_PENDING_V1';
function checkSenderSetup() {
readSenderConfig_();
console.log('Sender configuration shape checked; no request sent.');
}
function prepareSampleEvent() {
return withSenderLock_(function () {
const config = readSenderConfig_();
const store = PropertiesService.getScriptProperties();
if (store.getProperty(PENDING_PROPERTY) !== null) {
throw new Error('A pending event already exists. Inspect it; do not replace an uncertain identity.');
}
const event = {
version: 1,
eventId: Utilities.getUuid().toLowerCase(),
issuedAt: Date.now(),
payload: {
type: SAMPLE_EVENT_PAYLOAD.type,
requestId: SAMPLE_EVENT_PAYLOAD.requestId,
note: SAMPLE_EVENT_PAYLOAD.note,
},
signature: '',
};
if (!validEventFields_(event)) throw new Error('Correct the bounded fictional payload in Sample.gs.');
event.signature = signEvent_(event, config.secret);
const body = JSON.stringify(event);
readEnvelope_(body);
savePending_(store, {
endpointUrl: config.url, body: body, attemptedAt: null, receipt: null,
});
console.log(JSON.stringify({ state: 'prepared', eventId: event.eventId, issuedAt: event.issuedAt }));
});
}
function sendPendingEvent() {
return withSenderLock_(function () {
const config = readSenderConfig_();
const store = PropertiesService.getScriptProperties();
const pending = requirePending_(store, config);
if (!freshEvent_(pending.event, Date.now())) {
throw new Error('Pending timestamp is outside the send window. Inspect/reconcile; do not re-sign or restamp.');
}
if (pending.stored.attemptedAt === null) {
// Save the possibility of dispatch before the HTTP call. A crash can follow this write.
pending.stored.attemptedAt = Date.now();
savePending_(store, pending.stored);
}
const result = postEnvelope_(config.url, pending.stored.body, pending.event);
if (result.ok) {
const previous = pending.stored.receipt;
if (previous && (previous.acceptedAt !== result.receipt.acceptedAt ||
previous.expiresAt !== result.receipt.expiresAt)) {
throw new Error('The receipt changed. Stop and reconcile the receiver; keep the original pending record.');
}
if (!previous) {
pending.stored.receipt = result.receipt;
savePending_(store, pending.stored);
}
}
// postEnvelope_ permits only known fields; no raw response/body/URL/secret logging.
console.log(JSON.stringify(result));
return result;
});
}
function sendTamperedProbe() {
return sendProbe_('tampered');
}
function sendStaleProbe() {
return sendProbe_('stale');
}
function sendConflictProbe() {
return sendProbe_('conflict');
}
/** @param {string} kind */
function sendProbe_(kind) {
return withSenderLock_(function () {
const config = readSenderConfig_();
const pending = requirePending_(PropertiesService.getScriptProperties(), config);
if (!pending.stored.receipt) throw new Error('Confirm the normal event before running rejection probes.');
if (!freshEvent_(pending.event, Date.now())) {
throw new Error('The original event is too old for these probes. Finish this event before preparing a new example.');
}
// Change only a local parsed copy. The saved body, identity, and receipt stay intact.
const event = pending.event;
let expectedCode;
if (kind === 'stale') {
event.issuedAt -= 2 * EVENT_PROTOCOL.freshMs;
event.signature = signEvent_(event, config.secret);
expectedCode = 'stale';
} else if (kind === 'tampered' || kind === 'conflict') {
event.payload.note = event.payload.note === 'Changed probe note.'
? 'Different probe note.' : 'Changed probe note.';
if (kind === 'conflict') event.signature = signEvent_(event, config.secret);
expectedCode = kind === 'tampered' ? 'unauthorized' : 'conflict';
} else {
throw new Error('Unknown probe.');
}
const body = JSON.stringify(event);
readEnvelope_(body);
const result = postEnvelope_(config.url, body, event);
if (result.ok || result.code !== expectedCode) {
throw new Error('Probe did not receive its expected rejection. Inspect the receiver; no stored sender state was changed.');
}
console.log(JSON.stringify({ probe: kind, code: result.code, eventId: event.eventId }));
return result;
});
}
function inspectPendingEvent() {
return withSenderLock_(function () {
const pending = readPending_(PropertiesService.getScriptProperties());
if (!pending) {
console.log('No pending event.');
return;
}
console.log(JSON.stringify({
state: pending.stored.receipt ? 'confirmed' : pending.stored.attemptedAt === null ? 'prepared' : 'uncertain',
eventId: pending.event.eventId,
requestId: pending.event.payload.requestId,
issuedAt: pending.event.issuedAt,
attemptedAt: pending.stored.attemptedAt,
receipt: pending.stored.receipt,
}));
});
}
function forgetConfirmedEvent() {
return withSenderLock_(function () {
const store = PropertiesService.getScriptProperties();
const pending = readPending_(store);
if (!pending || !pending.stored.receipt) {
throw new Error('No confirmed event to forget. An unconfirmed event requires reconciliation, not a new identity.');
}
store.deleteProperty(PENDING_PROPERTY);
console.log('Confirmed sender record removed; receiver receipt was not changed.');
});
}
function readSenderConfig_() {
const url = PropertiesService.getScriptProperties().getProperty('EVENT_ENDPOINT_URL');
if (!validEndpointUrl_(url)) {
throw new Error('Set EVENT_ENDPOINT_URL to the approved canonical HTTPS /macros/s/deployment-ID/exec URL, without query or suffix.');
}
return { url: url, secret: readSecret_() };
}
/** @param {unknown} url @returns {url is string} */
function validEndpointUrl_(url) {
return typeof url === 'string' &&
/^https:\/\/script\.google\.com\/macros\/s\/[A-Za-z0-9_-]{20,200}\/exec$/.test(url);
}
/** @param {PropertiesService.Properties} store */
function readPending_(store) {
const raw = store.getProperty(PENDING_PROPERTY);
if (raw === null) return null;
const stored = parseBoundedJson_(raw, EVENT_PROTOCOL.maxStoreBytes);
if (!exactKeys_(stored, ['endpointUrl', 'body', 'attemptedAt', 'receipt']) ||
!validEndpointUrl_(stored.endpointUrl) ||
!(stored.attemptedAt === null || (Number.isSafeInteger(stored.attemptedAt) && stored.attemptedAt > 0))) {
throw new Error('Invalid pending record; stop and inspect rather than replacing it.');
}
const event = readEnvelope_(stored.body);
if (stored.receipt !== null &&
(stored.attemptedAt === null || !validReceipt_(stored.receipt, event))) {
throw new Error('Invalid pending receipt; stop and inspect rather than replacing it.');
}
return { stored: stored, event: event };
}
/** @param {PropertiesService.Properties} store @param {*} config */
function requirePending_(store, config) {
const pending = readPending_(store);
if (!pending) throw new Error('Run prepareSampleEvent once before sending.');
if (pending.stored.endpointUrl !== config.url) {
throw new Error('The configured receiver changed. Reconcile the original event before changing its destination.');
}
if (!equalMac_(pending.event.signature, signEvent_(pending.event, config.secret))) {
throw new Error('The pending signature no longer matches the key. Reconcile after rotation; do not silently re-sign.');
}
return pending;
}
/** @param {PropertiesService.Properties} store @param {*} pending */
function savePending_(store, pending) {
const serialized = JSON.stringify(pending);
if (utf8Length_(serialized) > EVENT_PROTOCOL.maxStoreBytes) throw new Error('Pending record exceeds its byte limit.');
store.setProperty(PENDING_PROPERTY, serialized);
}
/** @param {string} url @param {string} body @param {*} event */
function postEnvelope_(url, body, event) {
let response;
try {
response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json; charset=utf-8',
payload: body,
followRedirects: true,
muteHttpExceptions: true,
validateHttpsCertificates: true,
timeoutSeconds: 30,
});
} catch (error) {
throw new Error('Transport failed or timed out; acceptance is unknown. Keep the pending event.');
}
const status = response.getResponseCode();
if (status !== 200) throw new Error('HTTP ' + status + '; acceptance is unknown. Keep the pending event.');
const headers = response.getAllHeaders();
const names = Object.keys(headers).filter(/** @param {string} name */ function (name) { return name.toLowerCase() === 'content-type'; });
const type = names.length === 1 ? headers[names[0]] : null;
if (typeof type !== 'string' || type.split(';')[0].trim().toLowerCase() !== 'application/json') {
throw new Error('Expected a JSON response, not an authorization or error page. Acceptance is unknown.');
}
let result;
try {
result = parseBoundedJson_(response.getContentText('UTF-8'), EVENT_PROTOCOL.maxBodyBytes);
} catch (error) {
throw new Error('Response JSON is malformed or too large; acceptance is unknown.');
}
if (result && result.protocol === 1 && result.ok === true &&
exactKeys_(result, ['protocol', 'ok', 'status', 'receipt']) &&
(result.status === 'accepted' || result.status === 'duplicate') && validReceipt_(result.receipt, event)) {
return result;
}
if (result && result.protocol === 1 && result.ok === false &&
exactKeys_(result, ['protocol', 'ok', 'code', 'retryable']) && typeof result.code === 'string' &&
rejectionMatches_(result.code, result.retryable)) {
return result;
}
throw new Error('Unexpected response envelope or receipt; acceptance is unknown.');
}
/** @param {string} code @param {boolean} retryable */
function rejectionMatches_(code, retryable) {
switch (code) {
case 'bad_request':
case 'unauthorized':
case 'stale':
case 'conflict':
return retryable === false;
case 'capacity':
case 'busy':
case 'unavailable':
return retryable === true;
default:
return false;
}
}
/** @param {function(): *} work */
function withSenderLock_(work) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('Another sender operation is running. Let it finish.');
try {
// This local lock also prevents forgetting/replacing an event during its fetch.
return work();
} finally {
lock.releaseLock();
}
}
The sender wraps preparation, sending, inspection, and clearing in its own LockService.getScriptLock(). sendPendingEvent posts the serialized JSON with followRedirects: true, muteHttpExceptions: true, and timeoutSeconds: 30. See the UrlFetchApp reference and HTTPResponse reference.
The sender accepts a response only after four checks pass:
The HTTP status is
200, the responseContent-Typeis JSON, and the body is at most 2,048 UTF-8 bytes.The body parses as a valid protocol
1response envelope.For
ok: true,statusisacceptedorduplicateand the returned receipt matches the pendingeventId.For
ok: false,codeis a known error string with a booleanretryablefield.
Interpret each response code
| Result | What to do |
|---|---|
accepted or duplicate with the matching receipt | The sender saves the confirmation. An unchanged repeat returns the same receipt while fresh. |
bad_request | Check the body, MIME type, version, and both Protocol.gs files. |
unauthorized | Check EVENT_SECRET_HEX in both projects and the signing input. |
stale | Stop retrying this envelope, and inspect the receiver's retained receipts. |
conflict | The eventId is already stored with different signed fields. Compare the sender and receiver records. |
busy | Another execution holds the receiver's script lock. Retry the unchanged envelope while still fresh. |
capacity | Eight unexpired receipts are stored. Inspect retention. Do not delete live receipts. |
unavailable, transport error, non-200 HTTP, HTML, malformed JSON, or mismatched receipt | The outcome is uncertain. Retry the exact pending envelope while it is fresh, or compare with inspectReceipts. |
When a retry within the five-minute window returns duplicate, the sender changes its pending state from uncertain to confirmed. After the window closes, compare inspectPendingEvent in the sender with inspectReceipts in the receiver by eventId and issuedAt.
Observe success, repetition, and rejection
Start with a receiver whose inspectReceipts returns an empty array and a sender with no pending event. Steps 3 to 7 reuse the event from step 1, so complete all seven steps within five minutes of running prepareSampleEvent:
In the sender, run
prepareSampleEvent.inspectPendingEventreportsprepared, andinspectReceiptsin the receiver still shows[].Run
sendPendingEvent. It returnsok: trueandstatus: "accepted", updates the sender state toconfirmed, and stores one receipt forREQ-001in the receiver.Run
sendPendingEventagain without editing anything. It returnsstatus: "duplicate"with the sameeventId,acceptedAt, andexpiresAt, and the receiver still holds one receipt.Run
sendTamperedProbe, which changes the note in memory but keeps the original signature. The receiver returnsunauthorizedand leaves the stored receipt unchanged.Run
sendStaleProbe, which subtracts ten minutes fromissuedAtand computes a valid signature for that older timestamp. The receiver passes HMAC verification and then rejects the request asstale.Run
sendConflictProbe, which changes the note and re-signs the envelope with the originaleventIdandissuedAt. The receiver rejects it asconflict.Run
sendPendingEventonce more to confirm that the stored envelope still returnsduplicate, then runforgetConfirmedEventto clear the sender's confirmed record.
To test a second event ID, edit Sample.gs to use requestId: 'REQ-002' and a new note, save, and run prepareSampleEvent followed by sendPendingEvent. The event gets a new eventId, and the receiver stores a second receipt. Afterward, restore Sample.gs and run forgetConfirmedEvent.
Do not register this as a standard provider webhook
Third-party webhook providers sign requests with their own headers and raw-body formats. They do not use this JSON envelope:
Slack puts the timestamp and HMAC signature in HTTP request headers, computed over a versioned raw body string.
Stripe sends
Stripe-Signature, computed over the raw request payload.Twilio sends
X-Twilio-Signature, computed from the target URL and request parameters.
Apps Script's doPost(e) does not expose inbound HTTP headers. To verify a provider signature that lives in a header, use an external HTTP proxy or Worker. Have it check the provider's header and raw body, then forward a signed request to Apps Script.
Rotate, reconcile, and withdraw the endpoint
To rotate EVENT_SECRET_HEX:
Stop new sends.
Confirm any pending event in the sender, or compare it with the receiver's receipts.
Generate a new 64-character hex secret with
openssl rand -hex 32.Update
EVENT_SECRET_HEXin both projects.Run
checkReceiverSetupandcheckSenderSetupbefore preparing new events.
To update receiver code without changing the /exec URL, choose Deploy > Manage deployments > Edit, select New version, and deploy. See the deployments guide.
When you finish testing:
In the sender, run
forgetConfirmedEventif a confirmed event remains inEVENT_PENDING_V1.In the receiver, archive the web app deployment under Deploy > Manage deployments > Archive deployment so the
/execURL stops accepting requests.In Project Settings > Script Properties, delete
EVENT_SECRET_HEXandEVENT_RECEIPTS_V1in the receiver, and deleteEVENT_SECRET_HEX,EVENT_ENDPOINT_URL, andEVENT_PENDING_V1in the sender.If you no longer need the projects, move Custom event receiver and Custom event sender to the trash. You can also revoke the sender's OAuth grant on your Google Account's third-party connections page.
Project files
Complete source files for this chapter's examples.
Receive an authenticated custom event and retain its receipt
Code.gs
Download fileconst RECEIPTS_PROPERTY = 'EVENT_RECEIPTS_V1';
/** @param {*} e */
function doGet(e) {
if (e && (e.queryString || e.pathInfo)) return jsonReply_(failure_('bad_request', false));
// Liveness only: no secret, receipt, configuration, or readiness disclosure.
return jsonReply_({ protocol: 1, ok: true, service: 'custom-event-receiver' });
}
/** @param {*} e */
function doPost(e) {
try {
const post = e && e.postData;
if (!post || e.queryString || e.pathInfo ||
!Number.isInteger(e.contentLength) || e.contentLength <= 0 ||
e.contentLength > EVENT_PROTOCOL.maxBodyBytes || post.length !== e.contentLength ||
typeof post.type !== 'string' ||
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(post.type)) {
return jsonReply_(failure_('bad_request', false));
}
let event;
try {
event = readEnvelope_(post.contents);
} catch (error) {
return jsonReply_(failure_('bad_request', false));
}
const expected = signEvent_(event, readSecret_());
if (!equalMac_(event.signature, expected)) {
return jsonReply_(failure_('unauthorized', false));
}
if (!freshEvent_(event, Date.now())) return jsonReply_(failure_('stale', false));
// No lock acquisition or receipt-store access occurs before authentication.
return jsonReply_(receiveEvent_(event));
} catch (error) {
// Storage failure or response failure can follow acceptance. Do not echo errors.
return jsonReply_(failure_('unavailable', true));
}
}
/** @param {*} event */
function receiveEvent_(event) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) return failure_('busy', true);
try {
const store = PropertiesService.getScriptProperties();
const records = readReceipts_(store);
const now = Date.now();
// Include time spent waiting for the lock and reading/validating the ledger.
if (!freshEvent_(event, now)) return failure_('stale', false);
const live = records.filter(/** @param {*} record */ function (record) { return record.expiresAt > now; });
const existing = live.find(/** @param {*} record */ function (record) { return record.event.eventId === event.eventId; });
if (existing) {
if (canonicalEvent_(existing.event) !== canonicalEvent_(event)) {
return failure_('conflict', false);
}
// A duplicate does not refresh either timestamp or rewrite the ledger.
return receiptReply_('duplicate', existing);
}
if (live.length >= EVENT_PROTOCOL.maxReceipts) return failure_('capacity', true);
const record = {
event: eventFields_(event),
acceptedAt: now,
expiresAt: now + EVENT_PROTOCOL.retentionMs,
};
live.push(record);
const serialized = JSON.stringify({ version: 1, receipts: live });
if (utf8Length_(serialized) > EVENT_PROTOCOL.maxStoreBytes) return failure_('capacity', true);
// One value contains both the harmless event and its receipt. No external action.
store.setProperty(RECEIPTS_PROPERTY, serialized);
return receiptReply_('accepted', record);
} finally {
lock.releaseLock();
}
}
/** @param {PropertiesService.Properties} store */
function readReceipts_(store) {
const raw = store.getProperty(RECEIPTS_PROPERTY);
if (raw === null) return [];
const ledger = parseBoundedJson_(raw, EVENT_PROTOCOL.maxStoreBytes);
if (!exactKeys_(ledger, ['version', 'receipts']) || ledger.version !== 1 ||
!Array.isArray(ledger.receipts) || ledger.receipts.length > EVENT_PROTOCOL.maxReceipts) {
throw new Error('Invalid receipt ledger; stop and inspect rather than resetting it.');
}
const seen = new Set();
for (const record of ledger.receipts) {
if (!exactKeys_(record, ['event', 'acceptedAt', 'expiresAt']) ||
!exactKeys_(record.event, ['version', 'eventId', 'issuedAt', 'payload']) ||
!validEventFields_(record.event) ||
!validReceipt_({
eventId: record.event.eventId, acceptedAt: record.acceptedAt, expiresAt: record.expiresAt,
}, record.event) || seen.has(record.event.eventId)) {
throw new Error('Invalid receipt ledger; stop and inspect rather than resetting it.');
}
seen.add(record.event.eventId);
}
return ledger.receipts;
}
function checkReceiverSetup() {
readSecret_();
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('Receiver busy; try this editor check later.');
try {
const records = readReceipts_(PropertiesService.getScriptProperties());
console.log(JSON.stringify({ configurationShapeChecked: true, storedReceipts: records.length }));
} finally {
lock.releaseLock();
}
}
function inspectReceipts() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('Receiver busy; inspect again later.');
try {
const records = readReceipts_(PropertiesService.getScriptProperties());
const now = Date.now();
// Only editor-visible identifiers/timestamps, not notes, signatures, or secrets.
console.log(JSON.stringify(records.map(/** @param {*} record */ function (record) {
return {
eventId: record.event.eventId,
requestId: record.event.payload.requestId,
issuedAt: record.event.issuedAt,
acceptedAt: record.acceptedAt,
expiresAt: record.expiresAt,
expired: record.expiresAt <= now,
};
})));
} finally {
lock.releaseLock();
}
}
function pruneExpiredReceipts() {
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('Receiver busy; prune later.');
try {
const store = PropertiesService.getScriptProperties();
const records = readReceipts_(store);
const now = Date.now();
const live = records.filter(/** @param {*} record */ function (record) { return record.expiresAt > now; });
if (live.length !== records.length) {
store.setProperty(RECEIPTS_PROPERTY, JSON.stringify({ version: 1, receipts: live }));
}
console.log(JSON.stringify({ removedExpired: records.length - live.length, retained: live.length }));
} finally {
lock.releaseLock();
}
}
/** @param {string} status @param {*} record */
function receiptReply_(status, record) {
return {
protocol: 1,
ok: true,
status: status,
receipt: {
eventId: record.event.eventId,
acceptedAt: record.acceptedAt,
expiresAt: record.expiresAt,
},
};
}
/** @param {string} code @param {boolean} retryable */
function failure_(code, retryable) {
return { protocol: 1, ok: false, code: code, retryable: retryable };
}
/** @param {*} body */
function jsonReply_(body) {
return ContentService.createTextOutput(JSON.stringify(body)).setMimeType(ContentService.MimeType.JSON);
}
Protocol.gs
Download file// Install an identical copy in each independent project. No shared library is used.
const EVENT_PROTOCOL = Object.freeze({
version: 1,
maxBodyBytes: 2048,
freshMs: 5 * 60 * 1000,
futureMs: 30 * 1000,
retentionMs: 24 * 60 * 60 * 1000,
maxReceipts: 8,
maxStoreBytes: 7000,
});
/** @param {unknown} value @param {string[]} keys */
function exactKeys_(value, keys) {
return value !== null && typeof value === 'object' && !Array.isArray(value) &&
Object.keys(value).length === keys.length &&
keys.every(function (key) { return Object.prototype.hasOwnProperty.call(value, key); });
}
/** @param {string} text */
function utf8Length_(text) {
return Utilities.newBlob(text).getBytes().length;
}
/** @param {string} text @param {number} maxBytes @returns {*} */
function parseBoundedJson_(text, maxBytes) {
if (typeof text !== 'string' || text.length === 0 || text.length > maxBytes ||
utf8Length_(text) > maxBytes) {
throw new Error('JSON text is missing or exceeds its byte limit.');
}
try {
return JSON.parse(text);
} catch (error) {
// Do not include a parser message that might quote the input.
throw new Error('Invalid JSON text.');
}
}
/** @param {*} event */
function validEventFields_(event) {
if (event.version !== EVENT_PROTOCOL.version ||
typeof event.eventId !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(event.eventId) ||
!Number.isSafeInteger(event.issuedAt) || event.issuedAt <= 0 ||
!exactKeys_(event.payload, ['type', 'requestId', 'note'])) {
return false;
}
const payload = event.payload;
return payload.type === 'request.note' && typeof payload.requestId === 'string' &&
/^REQ-[0-9]{3}$/.test(payload.requestId) &&
typeof payload.note === 'string' && payload.note.trim().length > 0 &&
payload.note.length <= 120 && !/[\u0000-\u001f\u007f]/.test(payload.note) &&
utf8Length_(payload.note) <= 240;
}
/** @param {string} body */
function readEnvelope_(body) {
const event = parseBoundedJson_(body, EVENT_PROTOCOL.maxBodyBytes);
if (!exactKeys_(event, ['version', 'eventId', 'issuedAt', 'payload', 'signature']) ||
!validEventFields_(event) || typeof event.signature !== 'string' ||
!/^[0-9a-f]{64}$/.test(event.signature)) {
throw new Error('Invalid event envelope.');
}
return event;
}
/** @param {*} event */
function eventFields_(event) {
return {
version: event.version,
eventId: event.eventId,
issuedAt: event.issuedAt,
payload: {
type: event.payload.type,
requestId: event.payload.requestId,
note: event.payload.note,
},
};
}
/** @param {*} event */
function canonicalEvent_(event) {
// No pretty printing, trailing newline, Unicode normalization, or key sorting.
return JSON.stringify([
'apps-script-custom-event', event.version, event.eventId, event.issuedAt,
event.payload.type, event.payload.requestId, event.payload.note,
]);
}
function readSecret_() {
const secret = PropertiesService.getScriptProperties().getProperty('EVENT_SECRET_HEX');
if (typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret)) {
throw new Error('Set EVENT_SECRET_HEX to the generated 64-character lowercase hex value.');
}
return secret;
}
/** @param {*} event @param {string} secret */
function signEvent_(event, secret) {
// The 64 hex characters are the literal UTF-8 key, NOT decoded hex bytes.
return Utilities.computeHmacSha256Signature(
canonicalEvent_(event), secret, Utilities.Charset.UTF_8,
).map(function (byte) {
return ('0' + (byte & 255).toString(16)).slice(-2);
}).join('');
}
/** @param {string} provided @param {string} expected */
function equalMac_(provided, expected) {
if (typeof provided !== 'string' || typeof expected !== 'string' ||
!/^[0-9a-f]{64}$/.test(provided) || !/^[0-9a-f]{64}$/.test(expected)) {
return false;
}
let difference = 0;
for (let i = 0; i < 64; i += 1) {
difference |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
}
// Fixed 64-position work, not a documented V8 constant-time primitive.
return difference === 0;
}
/** @param {*} event @param {number} now */
function freshEvent_(event, now) {
return now - event.issuedAt <= EVENT_PROTOCOL.freshMs &&
event.issuedAt - now <= EVENT_PROTOCOL.futureMs;
}
/** @param {*} receipt @param {*} event */
function validReceipt_(receipt, event) {
return exactKeys_(receipt, ['eventId', 'acceptedAt', 'expiresAt']) &&
receipt.eventId === event.eventId && Number.isSafeInteger(receipt.acceptedAt) &&
receipt.acceptedAt > 0 && Number.isSafeInteger(receipt.expiresAt) &&
receipt.expiresAt === receipt.acceptedAt + EVENT_PROTOCOL.retentionMs &&
freshEvent_(event, receipt.acceptedAt);
}
appsscript.json
Download file{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"oauthScopes": [],
"webapp": {
"access": "ANYONE_ANONYMOUS",
"executeAs": "USER_DEPLOYING"
}
}
Sign one custom event and retry its unchanged identity
Code.gs
Download fileconst PENDING_PROPERTY = 'EVENT_PENDING_V1';
function checkSenderSetup() {
readSenderConfig_();
console.log('Sender configuration shape checked; no request sent.');
}
function prepareSampleEvent() {
return withSenderLock_(function () {
const config = readSenderConfig_();
const store = PropertiesService.getScriptProperties();
if (store.getProperty(PENDING_PROPERTY) !== null) {
throw new Error('A pending event already exists. Inspect it; do not replace an uncertain identity.');
}
const event = {
version: 1,
eventId: Utilities.getUuid().toLowerCase(),
issuedAt: Date.now(),
payload: {
type: SAMPLE_EVENT_PAYLOAD.type,
requestId: SAMPLE_EVENT_PAYLOAD.requestId,
note: SAMPLE_EVENT_PAYLOAD.note,
},
signature: '',
};
if (!validEventFields_(event)) throw new Error('Correct the bounded fictional payload in Sample.gs.');
event.signature = signEvent_(event, config.secret);
const body = JSON.stringify(event);
readEnvelope_(body);
savePending_(store, {
endpointUrl: config.url, body: body, attemptedAt: null, receipt: null,
});
console.log(JSON.stringify({ state: 'prepared', eventId: event.eventId, issuedAt: event.issuedAt }));
});
}
function sendPendingEvent() {
return withSenderLock_(function () {
const config = readSenderConfig_();
const store = PropertiesService.getScriptProperties();
const pending = requirePending_(store, config);
if (!freshEvent_(pending.event, Date.now())) {
throw new Error('Pending timestamp is outside the send window. Inspect/reconcile; do not re-sign or restamp.');
}
if (pending.stored.attemptedAt === null) {
// Save the possibility of dispatch before the HTTP call. A crash can follow this write.
pending.stored.attemptedAt = Date.now();
savePending_(store, pending.stored);
}
const result = postEnvelope_(config.url, pending.stored.body, pending.event);
if (result.ok) {
const previous = pending.stored.receipt;
if (previous && (previous.acceptedAt !== result.receipt.acceptedAt ||
previous.expiresAt !== result.receipt.expiresAt)) {
throw new Error('The receipt changed. Stop and reconcile the receiver; keep the original pending record.');
}
if (!previous) {
pending.stored.receipt = result.receipt;
savePending_(store, pending.stored);
}
}
// postEnvelope_ permits only known fields; no raw response/body/URL/secret logging.
console.log(JSON.stringify(result));
return result;
});
}
function sendTamperedProbe() {
return sendProbe_('tampered');
}
function sendStaleProbe() {
return sendProbe_('stale');
}
function sendConflictProbe() {
return sendProbe_('conflict');
}
/** @param {string} kind */
function sendProbe_(kind) {
return withSenderLock_(function () {
const config = readSenderConfig_();
const pending = requirePending_(PropertiesService.getScriptProperties(), config);
if (!pending.stored.receipt) throw new Error('Confirm the normal event before running rejection probes.');
if (!freshEvent_(pending.event, Date.now())) {
throw new Error('The original event is too old for these probes. Finish this event before preparing a new example.');
}
// Change only a local parsed copy. The saved body, identity, and receipt stay intact.
const event = pending.event;
let expectedCode;
if (kind === 'stale') {
event.issuedAt -= 2 * EVENT_PROTOCOL.freshMs;
event.signature = signEvent_(event, config.secret);
expectedCode = 'stale';
} else if (kind === 'tampered' || kind === 'conflict') {
event.payload.note = event.payload.note === 'Changed probe note.'
? 'Different probe note.' : 'Changed probe note.';
if (kind === 'conflict') event.signature = signEvent_(event, config.secret);
expectedCode = kind === 'tampered' ? 'unauthorized' : 'conflict';
} else {
throw new Error('Unknown probe.');
}
const body = JSON.stringify(event);
readEnvelope_(body);
const result = postEnvelope_(config.url, body, event);
if (result.ok || result.code !== expectedCode) {
throw new Error('Probe did not receive its expected rejection. Inspect the receiver; no stored sender state was changed.');
}
console.log(JSON.stringify({ probe: kind, code: result.code, eventId: event.eventId }));
return result;
});
}
function inspectPendingEvent() {
return withSenderLock_(function () {
const pending = readPending_(PropertiesService.getScriptProperties());
if (!pending) {
console.log('No pending event.');
return;
}
console.log(JSON.stringify({
state: pending.stored.receipt ? 'confirmed' : pending.stored.attemptedAt === null ? 'prepared' : 'uncertain',
eventId: pending.event.eventId,
requestId: pending.event.payload.requestId,
issuedAt: pending.event.issuedAt,
attemptedAt: pending.stored.attemptedAt,
receipt: pending.stored.receipt,
}));
});
}
function forgetConfirmedEvent() {
return withSenderLock_(function () {
const store = PropertiesService.getScriptProperties();
const pending = readPending_(store);
if (!pending || !pending.stored.receipt) {
throw new Error('No confirmed event to forget. An unconfirmed event requires reconciliation, not a new identity.');
}
store.deleteProperty(PENDING_PROPERTY);
console.log('Confirmed sender record removed; receiver receipt was not changed.');
});
}
function readSenderConfig_() {
const url = PropertiesService.getScriptProperties().getProperty('EVENT_ENDPOINT_URL');
if (!validEndpointUrl_(url)) {
throw new Error('Set EVENT_ENDPOINT_URL to the approved canonical HTTPS /macros/s/deployment-ID/exec URL, without query or suffix.');
}
return { url: url, secret: readSecret_() };
}
/** @param {unknown} url @returns {url is string} */
function validEndpointUrl_(url) {
return typeof url === 'string' &&
/^https:\/\/script\.google\.com\/macros\/s\/[A-Za-z0-9_-]{20,200}\/exec$/.test(url);
}
/** @param {PropertiesService.Properties} store */
function readPending_(store) {
const raw = store.getProperty(PENDING_PROPERTY);
if (raw === null) return null;
const stored = parseBoundedJson_(raw, EVENT_PROTOCOL.maxStoreBytes);
if (!exactKeys_(stored, ['endpointUrl', 'body', 'attemptedAt', 'receipt']) ||
!validEndpointUrl_(stored.endpointUrl) ||
!(stored.attemptedAt === null || (Number.isSafeInteger(stored.attemptedAt) && stored.attemptedAt > 0))) {
throw new Error('Invalid pending record; stop and inspect rather than replacing it.');
}
const event = readEnvelope_(stored.body);
if (stored.receipt !== null &&
(stored.attemptedAt === null || !validReceipt_(stored.receipt, event))) {
throw new Error('Invalid pending receipt; stop and inspect rather than replacing it.');
}
return { stored: stored, event: event };
}
/** @param {PropertiesService.Properties} store @param {*} config */
function requirePending_(store, config) {
const pending = readPending_(store);
if (!pending) throw new Error('Run prepareSampleEvent once before sending.');
if (pending.stored.endpointUrl !== config.url) {
throw new Error('The configured receiver changed. Reconcile the original event before changing its destination.');
}
if (!equalMac_(pending.event.signature, signEvent_(pending.event, config.secret))) {
throw new Error('The pending signature no longer matches the key. Reconcile after rotation; do not silently re-sign.');
}
return pending;
}
/** @param {PropertiesService.Properties} store @param {*} pending */
function savePending_(store, pending) {
const serialized = JSON.stringify(pending);
if (utf8Length_(serialized) > EVENT_PROTOCOL.maxStoreBytes) throw new Error('Pending record exceeds its byte limit.');
store.setProperty(PENDING_PROPERTY, serialized);
}
/** @param {string} url @param {string} body @param {*} event */
function postEnvelope_(url, body, event) {
let response;
try {
response = UrlFetchApp.fetch(url, {
method: 'post',
contentType: 'application/json; charset=utf-8',
payload: body,
followRedirects: true,
muteHttpExceptions: true,
validateHttpsCertificates: true,
timeoutSeconds: 30,
});
} catch (error) {
throw new Error('Transport failed or timed out; acceptance is unknown. Keep the pending event.');
}
const status = response.getResponseCode();
if (status !== 200) throw new Error('HTTP ' + status + '; acceptance is unknown. Keep the pending event.');
const headers = response.getAllHeaders();
const names = Object.keys(headers).filter(/** @param {string} name */ function (name) { return name.toLowerCase() === 'content-type'; });
const type = names.length === 1 ? headers[names[0]] : null;
if (typeof type !== 'string' || type.split(';')[0].trim().toLowerCase() !== 'application/json') {
throw new Error('Expected a JSON response, not an authorization or error page. Acceptance is unknown.');
}
let result;
try {
result = parseBoundedJson_(response.getContentText('UTF-8'), EVENT_PROTOCOL.maxBodyBytes);
} catch (error) {
throw new Error('Response JSON is malformed or too large; acceptance is unknown.');
}
if (result && result.protocol === 1 && result.ok === true &&
exactKeys_(result, ['protocol', 'ok', 'status', 'receipt']) &&
(result.status === 'accepted' || result.status === 'duplicate') && validReceipt_(result.receipt, event)) {
return result;
}
if (result && result.protocol === 1 && result.ok === false &&
exactKeys_(result, ['protocol', 'ok', 'code', 'retryable']) && typeof result.code === 'string' &&
rejectionMatches_(result.code, result.retryable)) {
return result;
}
throw new Error('Unexpected response envelope or receipt; acceptance is unknown.');
}
/** @param {string} code @param {boolean} retryable */
function rejectionMatches_(code, retryable) {
switch (code) {
case 'bad_request':
case 'unauthorized':
case 'stale':
case 'conflict':
return retryable === false;
case 'capacity':
case 'busy':
case 'unavailable':
return retryable === true;
default:
return false;
}
}
/** @param {function(): *} work */
function withSenderLock_(work) {
const lock = LockService.getScriptLock();
if (!lock.tryLock(1000)) throw new Error('Another sender operation is running. Let it finish.');
try {
// This local lock also prevents forgetting/replacing an event during its fetch.
return work();
} finally {
lock.releaseLock();
}
}
Protocol.gs
Download file// Install an identical copy in each independent project. No shared library is used.
const EVENT_PROTOCOL = Object.freeze({
version: 1,
maxBodyBytes: 2048,
freshMs: 5 * 60 * 1000,
futureMs: 30 * 1000,
retentionMs: 24 * 60 * 60 * 1000,
maxReceipts: 8,
maxStoreBytes: 7000,
});
/** @param {unknown} value @param {string[]} keys */
function exactKeys_(value, keys) {
return value !== null && typeof value === 'object' && !Array.isArray(value) &&
Object.keys(value).length === keys.length &&
keys.every(function (key) { return Object.prototype.hasOwnProperty.call(value, key); });
}
/** @param {string} text */
function utf8Length_(text) {
return Utilities.newBlob(text).getBytes().length;
}
/** @param {string} text @param {number} maxBytes @returns {*} */
function parseBoundedJson_(text, maxBytes) {
if (typeof text !== 'string' || text.length === 0 || text.length > maxBytes ||
utf8Length_(text) > maxBytes) {
throw new Error('JSON text is missing or exceeds its byte limit.');
}
try {
return JSON.parse(text);
} catch (error) {
// Do not include a parser message that might quote the input.
throw new Error('Invalid JSON text.');
}
}
/** @param {*} event */
function validEventFields_(event) {
if (event.version !== EVENT_PROTOCOL.version ||
typeof event.eventId !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(event.eventId) ||
!Number.isSafeInteger(event.issuedAt) || event.issuedAt <= 0 ||
!exactKeys_(event.payload, ['type', 'requestId', 'note'])) {
return false;
}
const payload = event.payload;
return payload.type === 'request.note' && typeof payload.requestId === 'string' &&
/^REQ-[0-9]{3}$/.test(payload.requestId) &&
typeof payload.note === 'string' && payload.note.trim().length > 0 &&
payload.note.length <= 120 && !/[\u0000-\u001f\u007f]/.test(payload.note) &&
utf8Length_(payload.note) <= 240;
}
/** @param {string} body */
function readEnvelope_(body) {
const event = parseBoundedJson_(body, EVENT_PROTOCOL.maxBodyBytes);
if (!exactKeys_(event, ['version', 'eventId', 'issuedAt', 'payload', 'signature']) ||
!validEventFields_(event) || typeof event.signature !== 'string' ||
!/^[0-9a-f]{64}$/.test(event.signature)) {
throw new Error('Invalid event envelope.');
}
return event;
}
/** @param {*} event */
function eventFields_(event) {
return {
version: event.version,
eventId: event.eventId,
issuedAt: event.issuedAt,
payload: {
type: event.payload.type,
requestId: event.payload.requestId,
note: event.payload.note,
},
};
}
/** @param {*} event */
function canonicalEvent_(event) {
// No pretty printing, trailing newline, Unicode normalization, or key sorting.
return JSON.stringify([
'apps-script-custom-event', event.version, event.eventId, event.issuedAt,
event.payload.type, event.payload.requestId, event.payload.note,
]);
}
function readSecret_() {
const secret = PropertiesService.getScriptProperties().getProperty('EVENT_SECRET_HEX');
if (typeof secret !== 'string' || !/^[0-9a-f]{64}$/.test(secret)) {
throw new Error('Set EVENT_SECRET_HEX to the generated 64-character lowercase hex value.');
}
return secret;
}
/** @param {*} event @param {string} secret */
function signEvent_(event, secret) {
// The 64 hex characters are the literal UTF-8 key, NOT decoded hex bytes.
return Utilities.computeHmacSha256Signature(
canonicalEvent_(event), secret, Utilities.Charset.UTF_8,
).map(function (byte) {
return ('0' + (byte & 255).toString(16)).slice(-2);
}).join('');
}
/** @param {string} provided @param {string} expected */
function equalMac_(provided, expected) {
if (typeof provided !== 'string' || typeof expected !== 'string' ||
!/^[0-9a-f]{64}$/.test(provided) || !/^[0-9a-f]{64}$/.test(expected)) {
return false;
}
let difference = 0;
for (let i = 0; i < 64; i += 1) {
difference |= provided.charCodeAt(i) ^ expected.charCodeAt(i);
}
// Fixed 64-position work, not a documented V8 constant-time primitive.
return difference === 0;
}
/** @param {*} event @param {number} now */
function freshEvent_(event, now) {
return now - event.issuedAt <= EVENT_PROTOCOL.freshMs &&
event.issuedAt - now <= EVENT_PROTOCOL.futureMs;
}
/** @param {*} receipt @param {*} event */
function validReceipt_(receipt, event) {
return exactKeys_(receipt, ['eventId', 'acceptedAt', 'expiresAt']) &&
receipt.eventId === event.eventId && Number.isSafeInteger(receipt.acceptedAt) &&
receipt.acceptedAt > 0 && Number.isSafeInteger(receipt.expiresAt) &&
receipt.expiresAt === receipt.acceptedAt + EVENT_PROTOCOL.retentionMs &&
freshEvent_(event, receipt.acceptedAt);
}
Sample.gs
Download file// Fictional event data, not a real tracker ID or personal information.
const SAMPLE_EVENT_PAYLOAD = Object.freeze({
type: 'request.note',
requestId: 'REQ-001',
note: 'Check step-free access before Friday.',
});
appsscript.json
Download file{
"timeZone": "Etc/UTC",
"runtimeVersion": "V8",
"oauthScopes": [
"https://www.googleapis.com/auth/script.external_request"
]
}