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 keep a receipt. Sending the same event again returns the original receipt and creates no new record. The receiver stores only the event and its receipt in Script Properties. Each request body is at most 2,048 UTF-8 bytes.

This chapter builds on runtime and permissions, Fetch data from web APIs, and web apps. A sender project posts to a receiver project over HTTP. Each request carries an event ID and an HMAC-SHA256 signature, a message authentication code computed from the event's fields and a shared key. The receiver stores receipts in a list, the receipt ledger, and checks event IDs to distinguish retries from new events.

Choose google.script.run or an HTTP endpoint

An HTML-service page hosted by Apps Script can call a server-side function through google.script.run. That API works only inside an Apps Script HTML-service page. The browser names the server function directly and receives its result through success and failure callbacks.

google.script.run is asynchronous and can complete calls out of order, so a client must sequence dependent work in callbacks or promises. withUserObject(value) passes a client-side value to both callbacks without sending it to the server.

Use doGet(e) and doPost(e) when the caller sends an HTTP request: curl, fetch, another Apps Script project, or an external webhook sender. An Apps Script project has one top-level doGet and one doPost. They return HtmlOutput or TextOutput. The web app deployment's access setting decides who can call them, and its execution-identity setting decides which account they run as.

Needgoogle.script.rundoGet or doPost
CallerApps Script HTML-service pageBrowser, command line, server, or webhook sender
Server selectionCalls a permitted function by namedoGet handles every GET, and doPost handles every POST
Result modelAsynchronous success and failure callbacksHTTP response body, MIME type, and platform status behavior
Client contextwithUserObject keeps a client-only valueSend everything the server needs in the signed request body
DeploymentNone, because Apps Script already serves the pageA web app deployment, whose access setting decides who can call it
TestingBrowser UI plus server handlerscurl, UrlFetchApp, or another HTTP client
Download-like responsePoor fit for arbitrary response bytesA text body from ContentService, where downloadAsFile(filename) can suggest a filename. The response arrives through a redirect (see Test a JSON POST with curl)

With either option, validate every input on the server and keep responses small.

Choose a request format both projects can handle

An HTTP endpoint accepts a defined request and returns a defined response. A webhook is an HTTP request that pushes an event to such a URL when something happens. In this chapter, the sender dispatches a custom request.note event when you run a function in the Apps Script editor.

Because you control both projects, you can put the signature inside the JSON body. The documented web-app event object exposes the POST body's text (e.postData.contents) and MIME type (e.postData.type), but no inbound request headers. Likewise, a TextOutput sets body text and MIME type, and it has no methods to set a custom HTTP status code or response headers.

The receiver therefore returns a JSON response envelope. ok: true with a matching receipt means acceptance, and ok: false with an error code means an application rejection. The sender still checks the HTTP status code first, because Google can reject access or fail a request before doPost runs.

A valid shared-key HMAC shows that a holder of that key produced the signed fields and that they were not modified in transit. The sender and receiver hold the same key, so both can compute valid signatures. Use a distinct random key for each sender-receiver pair. See RFC 2104 for the HMAC construction and key requirements.

Limit what the public URL exposes

The web app in the web apps chapter could be opened only by you. This receiver must accept requests that carry no interactive browser session. UrlFetchApp does not forward a browser login cookie, so the receiver authenticates each request with the body HMAC instead.

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, and 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 fixed service name and protocol version. The inspection and pruning functions run from the editor and are not reachable over HTTP.

Install two separate projects

Create two standalone projects from script.google.com/home with New project, named Custom event receiver and Custom event sender. Separate projects give each side its own Script Properties and its own lock.

  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 matching manifest below.

  4. Save both projects. The two Protocol.gs files are identical.

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 prints 64 lowercase hexadecimal characters, which encode 256 bits of random key material. Store the same value in both projects. Script Properties are separate for each project, so repeat these steps in the sender and in the receiver:

  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 uses the 64 hex characters of EVENT_SECRET_HEX as a UTF-8 HMAC key string. It does not decode them into raw bytes. Both projects must use the key the same way.

Script Properties keep secrets out of source files, but anyone with edit access to the script project can read or log them.

Deploy only the receiver

In the receiver editor, run checkReceiverSetup. It checks the format of EVENT_SECRET_HEX and reads the receipt ledger without writing to it. On a fresh project it logs configurationShapeChecked: true and storedReceipts: 0.

Next, deploy the receiver and configure the sender:

  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 checks the URL and secret formats locally and sends nothing.

Use the /exec URL. The /dev URL requires editor sign-in, and the script.googleusercontent.com redirect URL described in the next section works only once. See the web-app guide and Content Service redirect rules.

To verify that the endpoint answers without sign-in, open the /exec URL in an incognito browser tab. It returns:

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. {"foo":"bar"} is not a valid signed envelope, so the receiver returns {"protocol":1,"ok":false,"code":"bad_request","retryable":false}. Omit -X POST and --post302. Forcing POST across the 302 redirect sends a POST to script.googleusercontent.com, which expects GET.

Agree on every authenticated field

The sender transmits UTF-8 JSON with Content-Type: application/json; charset=utf-8. A string's .length counts UTF-16 code units, and a non-ASCII character can take more UTF-8 bytes than it has code units. Both projects measure UTF-8 byte lengths with Utilities.newBlob(text).getBytes().length.

The envelope has exactly five fields:

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', and payload.requestId must match /^REQ-[0-9]{3}$/. payload.note must contain some non-whitespace text, no ASCII control characters, and at most 120 UTF-16 code units and 240 UTF-8 bytes. The full POST body cannot exceed 2,048 UTF-8 bytes. The receiver rejects any property not listed here.

canonicalEvent_ serializes a seven-element JSON array in fixed order:

  1. 'apps-script-custom-event'

  2. version

  3. eventId

  4. issuedAt

  5. payload.type

  6. payload.requestId

  7. payload.note

signEvent_ passes that compact JSON array string to Utilities.computeHmacSha256Signature(canonical, secret, Utilities.Charset.UTF_8) and converts the returned byte array to 64 lowercase hex characters. See the Utilities reference. Signing the canonical array authenticates the parsed field values regardless of the whitespace or key order in the outer JSON.

equalMac_ first checks that both strings are 64 lowercase hex characters. It then compares the character codes at all 64 positions, combining the differences with bitwise OR. A mismatch does not stop the comparison early. See OWASP's authentication guidance on early-exit string comparisons and OWASP's REST security guidance on transport protection.

Receiver Protocol.gs:

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. On a POST, it fills in e.postData.contents, e.postData.type, e.postData.length, and e.contentLength. See the web-app event documentation, which also notes that the platform reserves the query parameters c and sid.

doPost rejects a query string, a path suffix, a missing or oversized body, a non-JSON MIME type, malformed JSON, and invalid envelope fields before it checks the HMAC. Only after equalMac_ succeeds does the receiver check timestamp freshness, acquire the script lock, and read or write EVENT_RECEIPTS_V1. An unauthenticated request never acquires the lock or touches Script Properties.

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 for the lock. While it holds the lock, the handler:

  1. Parses the receipt ledger.

  2. Checks freshness again.

  3. Drops expired receipts in memory.

  4. Applies the duplicate, conflict, and capacity rules.

  5. Calls setProperty once if it accepts a new event.

The handler releases the lock in a finally block. See the Properties guide and Properties reference.

The property write and the HTTP response do not succeed or fail together. If the network drops after setProperty, the receipt is stored but the sender cannot tell. Resending the identical signed envelope within the freshness window returns duplicate with the stored receipt, so the sender learns that the first request was accepted.

Separate freshness from retention

Freshness is how long the receiver accepts a signed envelope after it is signed. issuedAt must be no more than five minutes in the past and no more than 30 seconds ahead of the receiver's clock. The receiver checks this before and again after it acquires the script lock.

Retention is how long the receiver stores an accepted eventId. Each receipt records acceptedAt and an expiresAt time 24 hours later. The ledger holds at most eight unexpired receipts and caps its serialized JSON at 7,000 UTF-8 bytes, under the 9 KB per-property quota.

Incoming eventReceiver decision
Valid MAC, fresh time, new ID, available capacityStore the event and receipt, and 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, and 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.

A sender can retry an unacknowledged request with the exact original envelope for up to five minutes. If the receiver already accepted the event, a retry within that freshness window returns duplicate without creating a second record. The receiver retains receipts for 24 hours. Re-signing the same eventId with a new issuedAt returns conflict, not duplicate. Once the five-minute window closes, stop retrying and compare inspectPendingEvent on the sender with inspectReceipts on the receiver.

The receiver drops expired receipts when it writes a new event, or when you run pruneExpiredReceipts in the editor. Receipts live in Script Properties rather than CacheService because a cache entry can be evicted before its expiration time.

Keep the same event ID when a send fails

prepareSampleEvent generates an eventId and issuedAt once, signs the envelope, and stores the wire JSON together with the target URL in EVENT_PENDING_V1. Calling prepareSampleEvent again while a pending record exists throws an error and leaves the unconfirmed event in place.

sendPendingEvent verifies that EVENT_ENDPOINT_URL and EVENT_SECRET_HEX still match the prepared envelope and that the event is still within the freshness window. It writes attemptedAt before its first UrlFetchApp.fetch call, so if the execution is interrupted, the record shows that the request may have been sent.

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, muteHttpExceptions: true, and timeoutSeconds: 30. See the UrlFetchApp reference and HTTPResponse reference.

The sender accepts a response only after four checks pass:

  • The HTTP status is 200, the 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 known error string with a boolean retryable field.

Interpret each response code

ResultWhat to do
accepted or duplicate with the matching receiptThe sender saves the confirmation. An unchanged repeat returns the same receipt while fresh.
bad_requestCheck the body, MIME type, version, and both Protocol.gs files.
unauthorizedCheck EVENT_SECRET_HEX in both projects and the signing input.
staleStop retrying this envelope, and inspect the receiver's retained receipts.
conflictThe eventId is already stored with different signed fields. Compare the 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. Do not delete live receipts.
unavailable, transport error, non-200 HTTP, HTML, malformed JSON, or mismatched receiptThe outcome is uncertain. Retry the exact pending envelope while it is fresh, or compare with inspectReceipts.

When a retry within the five-minute window returns duplicate, the sender changes its pending state from uncertain to confirmed. After the window closes, compare inspectPendingEvent in the sender with inspectReceipts in the receiver by eventId and issuedAt.

Observe success, repetition, and rejection

Start with a receiver whose inspectReceipts returns an empty array and a sender with no pending event. Steps 3 to 7 reuse the event from step 1, so complete all seven steps within five minutes of running prepareSampleEvent:

  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 changes the note in memory but keeps 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 then 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 that the stored envelope still returns duplicate, then run forgetConfirmedEvent to clear the sender's confirmed record.

To test a second event ID, edit Sample.gs to use requestId: 'REQ-002' and a new note, save, and run prepareSampleEvent followed by sendPendingEvent. The event gets a new eventId, and the receiver stores a second receipt. Afterward, restore Sample.gs and run forgetConfirmedEvent.

Do not register this as a standard provider webhook

Third-party webhook providers sign requests with their own headers and raw-body formats. They do not use this JSON envelope:

  • Slack puts the timestamp and HMAC signature in HTTP request headers, computed over a versioned raw body string.

  • Stripe sends Stripe-Signature, computed over the raw request payload.

  • Twilio sends X-Twilio-Signature, computed from the target URL and request parameters.

Apps Script's doPost(e) does not expose inbound HTTP headers. To verify a provider signature that lives in a header, use an external HTTP proxy or Worker. Have it check the provider's header and raw body, then forward a signed request to Apps Script.

Rotate, reconcile, and withdraw the endpoint

To rotate EVENT_SECRET_HEX:

  1. Stop new sends.

  2. Confirm any pending event in the sender, or compare it with the receiver's receipts.

  3. Generate a new 64-character hex secret with openssl rand -hex 32.

  4. Update EVENT_SECRET_HEX in both projects.

  5. Run checkReceiverSetup and checkSenderSetup before preparing new events.

To update receiver code without changing the /exec URL, choose Deploy > Manage deployments > Edit, select New version, and deploy. See the deployments guide.

When you finish testing:

  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 the trash. You can also revoke the sender's OAuth grant on your Google Account's third-party connections page.

Next, Build a personal 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"
  ]
}