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 retain a receipt. Sending the same event again retrieves the original receipt rather than creating another record. The receiver stores only the bounded event and its receipt in Script Properties.
This chapter builds on runtime and permissions, connecting services, and web apps. Here, both callers are server-side Apps Script projects communicating over HTTP with an HMAC-SHA256 message authentication code, a stable event ID, and a receipt ledger that distinguishes 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 is available only inside an Apps Script HTML-service page, where the browser names the server function directly and receives its result through success and failure callbacks.
google.script.run is asynchronous and can resolve calls out of order, so a client must sequence dependent work in callbacks or promises. withUserObject(value) passes client-side context to both callbacks without sending that value to the server.
Use doGet(e) and doPost(e) when the caller speaks HTTP: curl, fetch, another Apps Script project, or an external webhook sender. An Apps Script project has one top-level doGet and one doPost, which return HtmlOutput or TextOutput under the web app deployment's access and execution-identity settings.
| 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 | One GET and one POST entrypoint route requests |
| Result model | Asynchronous success and failure callbacks | HTTP response body, MIME type, and platform status behavior |
| Client context | withUserObject keeps a client-only value | Put authenticated, validated data in the request contract |
| Deployment | Page already runs in an Apps Script HTML surface | Web-app deployment and audience are part of the API |
| Testing | Browser UI plus server handlers | curl, UrlFetchApp, or another HTTP client |
| Download-like response | Poor fit for arbitrary response bytes | ContentService can return a response body, subject to redirects |
Both paths cross a trust boundary and require server-side input validation and bounded responses. ContentService.createTextOutput(text).downloadAsFile(filename) can suggest a download filename for a text response, subject to ContentService redirects.
Choose a contract both ends can support
An HTTP endpoint accepts a defined request and returns a defined response; a webhook pushes an event to that URL when something happens. In this chapter, the sender dispatches a custom request.note event when you run a function in the editor.
Because you control both projects, you can place the authentication proof 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 not an inbound request-header map. Likewise, a TextOutput sets body text and MIME type, without methods to set custom HTTP status codes or response headers.
Accordingly, the receiver returns a JSON response envelope where ok: true with a matching receipt indicates acceptance, and ok: false with an error code indicates 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 shared-key HMAC proves that the signed fields were produced by a holder of that key and were not modified in transit. Both the sender and receiver hold the same key and can compute valid tags. Use a distinct random key for each sender-receiver pair. See RFC 2104 for the HMAC construction and key requirements.
Keep the public surface small
Unlike the preceding self-only browser web app, this receiver must accept requests that do not carry an interactive browser session. UrlFetchApp does not forward a browser login cookie, so the receiver authenticates requests 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; 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 static service name and protocol version. Inspection and pruning functions run from the script editor and are not exposed over HTTP.
Install two separate projects
Create two standalone projects from script.google.com/home using New project, named Custom event receiver and Custom event sender.
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 corresponding manifest below.Save both projects. Keep them in separate projects so each has its own Script Properties and lock. Both projects use identical
Protocol.gsfiles.
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 outputs 64 lowercase hexadecimal characters representing 256 bits of random key material. Configure the same generated value in both projects:
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 passes those 64 ASCII hex characters as the UTF-8 HMAC key string rather than decoding them into raw bytes; both projects must use the same representation.
Script Properties keep secrets out of source files, though any user with edit access to the script project can read or log them. Because Script Properties are isolated per project, set EVENT_SECRET_HEX in both the sender and receiver.
Deploy only the receiver
In the receiver editor, run checkReceiverSetup. It validates the format of EVENT_SECRET_HEX and reads the receipt ledger without writing, logging configurationShapeChecked: true and storedReceipts: 0 on a fresh project.
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.checkSenderSetupvalidates the URL and secret formats locally.
Use the /exec URL rather than /dev (which requires editor sign-in) or the one-time script.googleusercontent.com redirect URL returned by ContentService; see the web-app guide and Content Service redirect rules.
To verify unauthenticated reachability, 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. Because {"foo":"bar"} is not a valid signed envelope, 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. Because multi-byte UTF-8 characters take more bytes than their UTF-16 .length, both projects check UTF-8 byte lengths with Utilities.newBlob(text).getBytes().length.
The envelope requires 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', payload.requestId must match /^REQ-[0-9]{3}$/, and payload.note must be non-whitespace text without ASCII control characters, up to 120 UTF-16 code units and 240 UTF-8 bytes. The full POST body cannot exceed 2,048 UTF-8 bytes, and unknown properties are rejected.
canonicalEvent_ serializes a seven-element JSON array in fixed order:
'apps-script-custom-event'versioneventIdissuedAtpayload.typepayload.requestIdpayload.note
Utilities.computeHmacSha256Signature(canonical, secret, Utilities.Charset.UTF_8) computes the digest over that compact JSON array string, and signEnvelope_ converts the signed byte array to 64 lowercase hex characters; see the Utilities reference. Signing the canonical array authenticates the parsed field values independent of outer JSON whitespace or key order.
equalMac_ verifies that both strings are 64 lowercase hex characters and bitwise-ORs character-code differences across all 64 positions before returning. See OWASP's authentication guidance on avoiding 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, populating e.postData.contents, e.postData.type, e.postData.length, and e.contentLength on POST; see the web-app event documentation (which also notes that query parameters c and sid are reserved by the platform).
doPost rejects query strings, path suffixes, missing or oversized bodies, non-JSON MIME types, malformed JSON, and invalid envelope fields before checking the HMAC. Only after equalMac_ succeeds does the receiver check timestamp freshness, acquire the script lock, and read or write EVENT_RECEIPTS_V1. Unauthenticated requests never acquire the lock or touch 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 to enter the critical section, where the handler parses the ledger, rechecks freshness, drops expired entries in memory, evaluates duplicate/conflict/capacity rules, and calls setProperty once for a newly accepted event before releasing the lock in finally. See the Properties guide and Properties reference.
Because the property write and the HTTP response are not a single atomic transaction, a network drop after setProperty can leave the sender uncertain even though the receipt was stored. Retrying the identical signed envelope within the freshness window resolves that uncertainty by returning duplicate with the stored receipt.
Separate freshness from retention
Freshness bounds how long a signed envelope is accepted over the network: issuedAt must be no more than five minutes in the past and no more than 30 seconds ahead of the receiver's clock, checked both before and after acquiring the script lock.
Retention bounds how long the receiver remembers an accepted eventId: each receipt records acceptedAt and expiresAt (24 hours later). The ledger holds at most eight unexpired receipts and caps the serialized JSON at 7,000 UTF-8 bytes, staying under the 9 KB per-property quota.
| Incoming event | Receiver decision |
|---|---|
| Valid MAC, fresh time, new ID, available capacity | Store the event and receipt; 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; 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. |
Keeping the five-minute freshness window shorter than the 24-hour retention window lets a sender retry an unacknowledged request with the exact original envelope for up to five minutes. Re-signing the same eventId with a new issuedAt triggers conflict rather than duplicate. Once the five-minute window expires, stop retrying and compare inspectPendingEvent on the sender with inspectReceipts on the receiver.
Expired receipts are filtered out when a new event is written or when you run pruneExpiredReceipts in the editor. Receipt storage uses Script Properties rather than CacheService because cache entries can be evicted before their expiration time.
Preserve the sender's identity through uncertainty
prepareSampleEvent generates an eventId and issuedAt once, signs the envelope, and stores the wire JSON alongside the target URL in EVENT_PENDING_V1. Calling prepareSampleEvent again while a pending record exists throws an error rather than overwriting the unconfirmed event.
sendPendingEvent verifies that EVENT_ENDPOINT_URL and EVENT_SECRET_HEX still match the prepared envelope, checks that the event has not aged past the freshness window, and writes attemptedAt before its first UrlFetchApp.fetch call so an interrupted execution leaves a record that dispatch may have occurred.
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 and muteHttpExceptions: true (and timeoutSeconds: 30; see the UrlFetchApp reference and HTTPResponse reference).
A response is accepted only after passing four checks:
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 recognized error string with a booleanretryablefield.
Read the result without erasing uncertainty
| Result | What to do |
|---|---|
accepted or duplicate with the matching receipt | The sender saves confirmation. An unchanged repeat returns the same receipt while fresh. |
bad_request | Check 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; inspect the receiver's retained receipts. |
conflict | The eventId is already stored with different signed fields. Compare 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 rather than deleting live receipts. |
unavailable, transport error, non-200 HTTP, HTML, malformed JSON, or mismatched receipt | Outcome is uncertain. Retry the exact pending envelope while fresh, or reconcile with inspectReceipts. |
If the receiver stored the receipt but the response was lost, repeating sendPendingEvent within five minutes returns duplicate and transitions the sender's pending state from uncertain to confirmed. After the five-minute 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. Complete steps 1-7 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 modifies the note in memory while keeping 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 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 the stored envelope still returnsduplicate, then runforgetConfirmedEventto clear the sender's confirmed slot.
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. It receives a new eventId and stores a second receipt in the receiver. Restore Sample.gs and run forgetConfirmedEvent afterward.
Do not register this as a standard provider webhook
Third-party webhook providers sign requests using their own header and raw-body conventions rather than this JSON envelope:
Slack places the timestamp and HMAC signature in HTTP request headers over a versioned raw body string.
Stripe sends
Stripe-Signatureover the raw request payload.Twilio sends
X-Twilio-Signaturecomputed from the target URL and request parameters.
Because Apps Script's doPost(e) does not expose inbound HTTP headers, verifying a provider signature that lives in a header requires an external HTTP proxy or Worker that validates the provider's header and raw body before forwarding a signed request to Apps Script.
Rotate, reconcile, and withdraw the endpoint
To rotate EVENT_SECRET_HEX, stop new sends, finish or reconcile any pending event in the sender, generate a new 64-character hex secret with openssl rand -hex 32, update EVENT_SECRET_HEX in both projects, and run checkReceiverSetup and checkSenderSetup before preparing new events.
To update receiver code, choose Deploy > Manage deployments > Edit, select New version, and deploy so the /exec URL stays unchanged; 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 Trash, and optionally 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"
]
}