Skip to content
Skip to chapter

Chapter 23

User interfaces

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.

By the end

Send a fictional request note, authenticate its signed fields, and distinguish acceptance, duplicate receipt, rejection, and an uncertain response without performing a downstream action.

Chapter navigation

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.

Needgoogle.script.rundoGet or doPost
CallerApps Script HTML-service pageBrowser, command line, server, or webhook sender
Server selectionCalls a permitted function by nameOne GET and one POST entrypoint route requests
Result modelAsynchronous success and failure callbacksHTTP response body, MIME type, and platform status behavior
Client contextwithUserObject keeps a client-only valuePut authenticated, validated data in the request contract
DeploymentPage already runs in an Apps Script HTML surfaceWeb-app deployment and audience are part of the API
TestingBrowser UI plus server handlerscurl, UrlFetchApp, or another HTTP client
Download-like responsePoor fit for arbitrary response bytesContentService 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.

DecisionChoice for this exampleConsequence
Receiver execution identityMe, represented by USER_DEPLOYINGThe receiver runs as the deploying account.
Receiver audienceAnyone, including callers not signed in, represented by ANYONE_ANONYMOUSRequests reach doPost without Google sign-in; the handler authenticates the payload itself.
Receiver project accessKeep code-editor access privateEndpoint callers cannot view or edit the script project or its properties.
Receiver Google data scopesAn empty oauthScopes arrayThe receiver needs no Sheets, Gmail, Drive, or outbound-request permissions.
Sender accessRun only from its private editorRequires 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.

  1. In the receiver, replace Code.gs and add a Script file named Protocol (Protocol.gs).

  2. In the sender, replace Code.gs and add Script files named Protocol (Protocol.gs) and Sample (Sample.gs).

  3. In each project's Project Settings, enable Show "appsscript.json" manifest file in editor and replace appsscript.json with the corresponding manifest below.

  4. Save both projects. Keep them in separate projects so each has its own Script Properties and lock. Both projects use identical Protocol.gs files.

Receiver manifest:

appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [],
  "webapp": {
    "access": "ANYONE_ANONYMOUS",
    "executeAs": "USER_DEPLOYING"
  }
}

Download appsscript.json

Sender manifest:

appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request"
  ]
}

Download appsscript.json

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:

Sample.gs
// 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.',
});

Download Sample.gs

Generate and store one shared secret

Generate a 32-byte random hex string locally with OpenSSL:

Source
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:

  1. Open Project Settings > Script Properties.

  2. Click Add script property (or Edit script properties > Add script property).

  3. Set the property name to EVENT_SECRET_HEX.

  4. 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:

  1. In the receiver, choose Deploy > New deployment, select Web app, and enter a description.

  2. Set Execute as to Me (USER_DEPLOYING) and Who has access to Anyone, even anonymous (ANYONE_ANONYMOUS), then click Deploy.

  3. Copy the /exec URL from the deployment dialog. In the sender's Script Properties, add EVENT_ENDPOINT_URL with that exact https://script.google.com/macros/s/.../exec URL.

  4. In the sender editor, run checkSenderSetup and approve the script.external_request consent prompt. checkSenderSetup validates 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:

Source
{"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:

Source
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:

FieldRequired value
versionNumeric 1.
eventIdLowercase 36-character UUID v4 string created when the event is prepared.
issuedAtPositive safe integer containing Date.now() in epoch milliseconds at preparation time.
payloadObject with type, requestId, and note meeting the constraints below.
signature32-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:

  1. 'apps-script-custom-event'

  2. version

  3. eventId

  4. issuedAt

  5. payload.type

  6. payload.requestId

  7. payload.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:

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);
}

Download Protocol.gs

Sender Protocol.gs:

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);
}

Download Protocol.gs

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.

Code.gs
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);
}

Download Code.gs

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 eventReceiver decision
Valid MAC, fresh time, new ID, available capacityStore the event and receipt; return accepted.
Valid MAC, fresh time, retained ID, identical signed fieldsReturn duplicate with the original receipt; do not write or extend retention.
Valid MAC, fresh time, retained ID, different payload or timestampReturn conflict; keep the original record.
Valid MAC but stale or excessively future timeReturn stale, even if an original receipt remains in storage.
Valid MAC, fresh new ID, eight live recordsReturn 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.

Code.gs
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();
  }
}

Download Code.gs

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 response Content-Type is JSON, and the body is at most 2,048 UTF-8 bytes.

  • The body parses as a valid protocol 1 response envelope.

  • For ok: true, status is accepted or duplicate and the returned receipt matches the pending eventId.

  • For ok: false, code is a recognized error string with a boolean retryable field.

Read the result without erasing uncertainty

ResultWhat to do
accepted or duplicate with the matching receiptThe sender saves confirmation. An unchanged repeat returns the same receipt while fresh.
bad_requestCheck body, MIME type, version, and both Protocol.gs files.
unauthorizedCheck EVENT_SECRET_HEX in both projects and the signing input.
staleStop retrying this envelope; inspect the receiver's retained receipts.
conflictThe eventId is already stored with different signed fields. Compare sender and receiver records.
busyAnother execution holds the receiver's script lock. Retry the unchanged envelope while still fresh.
capacityEight unexpired receipts are stored. Inspect retention rather than deleting live receipts.
unavailable, transport error, non-200 HTTP, HTML, malformed JSON, or mismatched receiptOutcome 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:

  1. In the sender, run prepareSampleEvent. inspectPendingEvent reports prepared, and inspectReceipts in the receiver still shows [].

  2. Run sendPendingEvent. It returns ok: true and status: "accepted", updates the sender state to confirmed, and stores one receipt for REQ-001 in the receiver.

  3. Run sendPendingEvent again without editing anything. It returns status: "duplicate" with the same eventId, acceptedAt, and expiresAt, and the receiver still holds one receipt.

  4. Run sendTamperedProbe, which modifies the note in memory while keeping the original signature. The receiver returns unauthorized and leaves the stored receipt unchanged.

  5. Run sendStaleProbe, which subtracts ten minutes from issuedAt and computes a valid signature for that older timestamp. The receiver passes HMAC verification and rejects the request as stale.

  6. Run sendConflictProbe, which changes the note and re-signs the envelope with the original eventId and issuedAt. The receiver rejects it as conflict.

  7. Run sendPendingEvent once more to confirm the stored envelope still returns duplicate, then run forgetConfirmedEvent to 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-Signature over the raw request payload.

  • Twilio sends X-Twilio-Signature computed 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:

  1. In the sender, run forgetConfirmedEvent if a confirmed event remains in EVENT_PENDING_V1.

  2. In the receiver, archive the web app deployment under Deploy > Manage deployments > Archive deployment so the /exec URL stops accepting requests.

  3. In Project Settings > Script Properties, delete EVENT_SECRET_HEX and EVENT_RECEIPTS_V1 in the receiver, and delete EVENT_SECRET_HEX, EVENT_ENDPOINT_URL, and EVENT_PENDING_V1 in the sender.

  4. 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.

Next, build a card-based Google Workspace add-on.

Project files

Complete source files for this chapter’s examples.

Receive an authenticated custom event and retain its receipt
Code.gs
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);
}

Protocol.gs

Download file
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);
}

appsscript.json

Download file
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [],
  "webapp": {
    "access": "ANYONE_ANONYMOUS",
    "executeAs": "USER_DEPLOYING"
  }
}

Sign one custom event and retry its unchanged identity
Code.gs
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();
  }
}

Protocol.gs

Download file
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);
}

Sample.gs

Download file
Sample.gs
// 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
appsscript.json
{
  "timeZone": "Etc/UTC",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.external_request"
  ]
}