CloudFort Logo

Sending Emails with Amazon SES API V2 in Google Apps Script (SigV4)

By Waqar Ahmad on March 26, 2026

Amazon SES in Google Apps Script

Sending Emails with Amazon SES API V2 in Google Apps Script (SigV4)

At CloudFort, we often encounter scenarios where we need to bridge the gap between simple automation tools like Google Apps Script and robust infrastructure services like Amazon Simple Email Service (SES).

In fact, we recently implemented this exact integration for our popular Google Forms add-on, Smart Certificates. Smart Certificates allows users to automate professional certificate generation directly from form responses, and using Amazon SES ensures that those critical emails land in the inbox every time, regardless of volume.

You can find more of our specialized tools for Google Workspace at our portal: GWAddons.com.

While Google Apps Script (GAS) provides a built-in MailApp, it has strict daily quotas. For high-volume, transactional, or more professional email delivery, Amazon SES is the industry standard. However, AWS uses a complex authentication mechanism called Signature Version 4 (SigV4).

In this post, we’ll dive into how to implement the Amazon SES API V2 SendEmail action in GAS using pure JavaScript and native Google Utilities.

Why SES API V2?

The SES API V2 (specifically the REST-based endpoints) offers several advantages over the older Query-based API:

  1. JSON Support: Native JSON payloads are easier to handle in JavaScript.
  2. Granular Control: Better management of configuration sets and list management.
  3. Future-Proof: V2 is the recommended path for all new AWS integrations.

The Challenge: Signature Version 4

AWS requires every request to be signed. This isn't just a simple API key in a header; it involves:

  1. Creating a Canonical Request (a standardized string of your request details).
  2. Creating a String to Sign (combining the algorithm, date, and scope).
  3. Calculating a Signing Key through a chain of HMAC-SHA256 operations (Date -> Region -> Service -> Request).
  4. Generating the final Signature.

The Implementation

In Google Apps Script, we don't have access to the official AWS SDK or standard Node.js crypto modules. Instead, we use Utilities.computeDigest and Utilities.computeHmacSignature.

One common pitfall in GAS is that computeHmacSignature expects specific types. If you pass a standard JavaScript array where it expects a Java-style Byte[], the script will throw a "parameters don't match" error. Our implementation handles this by ensuring all intermediate keys are treated as byte arrays correctly.

The Solution

Below is the complete, self-contained implementation. You can drop this directly into your .gs file.

/**
 * Google Apps Script implementation for Amazon SES API V2 (SendEmail)
 * Following the AWS Signature Version 4 pattern.
 */

const AWS_ACCESS_KEY = 'YOUR_ACCESS_KEY';
const AWS_SECRET_KEY = 'YOUR_SECRET_KEY';
const AWS_REGION = 'us-east-1'; 

/**
 * Main function to send an email via SES V2
 */
function sendEmailViaSES() {
  const emailData = {
    FromEmailAddress: "no-reply@cloudfort.in", 
    Destination: {
      ToAddresses: ["client@example.com"]
    },
    Content: {
      Simple: {
        Subject: { Data: "CloudFort Infrastructure Update" },
        Body: {
          Html: { Data: "<h1>System Update</h1><p>Your cloud resources have been optimized.</p>" }
        }
      }
    }
  };

  try {
    const response = sendSESV2Email(emailData);
    if (response.getResponseCode() === 200) {
      Logger.log("Email sent successfully!");
    } else {
      Logger.log("Error: " + response.getContentText());
    }
  } catch (e) {
    Logger.log("Critical Failure: " + e.toString());
  }
}

function sendSESV2Email(payload) {
  const service = 'ses';
  const method = 'POST';
  const uri = '/v2/email/outbound-emails';
  const host = `email.${AWS_REGION}.amazonaws.com`;
  
  return AWS_v4_request(service, AWS_REGION, method, uri, host, payload);
}

function AWS_v4_request(service, region, method, uri, host, payloadObj) {
  const Crypto = loadCrypto();
  const now = new Date();
  
  const amzDate = Utilities.formatDate(now, "GMT", "yyyyMMdd'T'HHmmss'Z'");
  const dateStamp = amzDate.substr(0, 8);
  
  const payload = JSON.stringify(payloadObj);
  const hashedPayload = Crypto.SHA256(payload);
  
  const canonicalHeaders = 
    "content-type:application/json\n" +
    "host:" + host + "\n" +
    "x-amz-date:" + amzDate + "\n";
  
  const signedHeaders = "content-type;host;x-amz-date";
  
  const canonicalRequest = 
    method + "\n" +
    uri + "\n" +
    "" + "\n" + 
    canonicalHeaders + "\n" +
    signedHeaders + "\n" +
    hashedPayload;

  const algorithm = "AWS4-HMAC-SHA256";
  const credentialScope = dateStamp + "/" + region + "/" + service + "/aws4_request";
  const stringToSign = 
    algorithm + "\n" +
    amzDate + "\n" +
    credentialScope + "\n" +
    Crypto.SHA256(canonicalRequest);

  const signingKey = getSignatureKey(Crypto, AWS_SECRET_KEY, dateStamp, region, service);
  const signature = Crypto.HMAC(Crypto.SHA256, stringToSign, signingKey, { asBytes: false });

  const authHeader = algorithm + " " +
    "Credential=" + AWS_ACCESS_KEY + "/" + credentialScope + ", " +
    "SignedHeaders=" + signedHeaders + ", " +
    "Signature=" + signature;

  const options = {
    method: method,
    headers: {
      "Authorization": authHeader,
      "X-Amz-Date": amzDate,
      "Content-Type": "application/json"
    },
    payload: payload,
    muteHttpExceptions: true
  };

  return UrlFetchApp.fetch("https://" + host + uri, options);
}

function getSignatureKey(Crypto, key, dateStamp, regionName, serviceName) {
  const kDate = Crypto.HMAC(Crypto.SHA256, dateStamp, "AWS4" + key, { asBytes: true });
  const kRegion = Crypto.HMAC(Crypto.SHA256, regionName, kDate, { asBytes: true });
  const kService = Crypto.HMAC(Crypto.SHA256, serviceName, kRegion, { asBytes: true });
  const kSigning = Crypto.HMAC(Crypto.SHA256, "aws4_request", kService, { asBytes: true });
  return kSigning;
}

function loadCrypto() {
  const Crypto = {
    util: {
      bytesToHex: (bytes) => bytes.map(b => ("0" + (b & 0xFF).toString(16)).slice(-2)).join(""),
      stringToBytes: (str) => Utilities.newBlob(str).getBytes()
    },
    SHA256: (data) => Crypto.util.bytesToHex(Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, data, Utilities.Charset.UTF_8)),
    HMAC: (algo, data, key, options) => {
      let k = (typeof key === "string") ? Crypto.util.stringToBytes(key) : key;
      let d = (typeof data === "string") ? Crypto.util.stringToBytes(data) : data;
      const sig = Utilities.computeHmacSignature(Utilities.MacAlgorithm.HMAC_SHA_256, d, k);
      return (options && options.asBytes) ? sig : Crypto.util.bytesToHex(sig);
    }
  };
  return Crypto;
}

Wrapping Up

By using the native Utilities service in GAS, we avoid large external dependencies and keep our scripts lightweight and fast. This pattern isn't just for SES—you can adapt the AWS_v4_request logic to talk to almost any AWS service (DynamoDB, S3, Lambda, etc.) directly from your Google Sheets or Workspace add-ons.

At CloudFort, we believe in building bridges between the world's best cloud ecosystems. Check out our other add-ons at GWAddons.com to see how we leverage these technologies to supercharge Google Workspace.

Happy coding!