Tracking Mailgun Events


I no longer use nor recommend this setup, see my new setup.


I previously hosted my own email server, but found that I don’t particularly like administering one. But I still use my public fronting email to send and receive email, as a custom domain for Gmail. My solution to this utilizes MailGun’s free tier. I won’t cover how I setup the forwarding, as there are many guides. This solution works quite well, except when an email you sent (or was sent to you) fails to be delivered. While MailGun logs the failure, it does not notify you.

This post covers how I hooked up MailGun Events Webhooks to a Google Apps Script to send email notifications for dropped messages and hard bounces. Since I require TLS connections with valid certificates, MailGun (correctly) refuses to deliver my email to poorly run email servers. An unfortunate instance of this was my mortgage lender not receiving my emails, and it wasn’t until I checked my logs that I discovered the problem. Obviously, I needed notifications!


MailGun has an extensive API, and most of their customers likely use it exclusively. But users like me don’t bother when setting up our Gmail forwarding; setup the routes and SMTP credentials and we forget we’re even using MailGun. My first thought was to periodically query their events API and send an email notification when a new error occurred.

But this polling was unnecessary, considering that their webhooks will send me an HTTP POST for the events I’m trying to monitor. MailGun makes this really easy: they even provide an API test service. Under routes you can “Send A Sample POST” to any URL. Even better, you can generate a POST receiver at Take the URL, enter it in the field, then refresh the “Postbin” to see an example event. But now we need something of our own to receive this POST and actually email us the event.

Google Script

I created a trivial web app using Google Script to receive and process the POST. This service lets you write JavaScript in your browser like a Google Doc, and deploy it with a click of a button. No servers, no networking, no cost. You write a function doPost(e) { ... }, and it receives the POST at the URL Google provides. My function looks like this:

var API_KEY = "key-abcd1234...";
var EMAIL = "[email protected]";

function doPost(e) {
    // The output is not consumed, but required by the Google Scripts API.
    var output = ContentService.createTextOutput("Post received, thanks!");
    var params = e["parameter"];
    if (!verify(API_KEY, params["token"], params["timestamp"], params["signature"])) {
        // TODO: Notify or log if you want.
        return output;
    var subject = "MailGun " + params["event"] + " a message!";
    var body = params;
    try {
        var headers = JSON.stringify(JSON.parse(params["message-headers"]), null, 2);
        body =
            "Domain: " + params["domain"] + "\n" +
            "Description: " + params["description"] + "\n" +
            "Recipient: " + params["recipient"] + "\n" +
            "Message ID: " + params["Message-Id"] + "\n" +
            "Headers: " + headers + "\n";
    } catch (e) {
        body += "\n" + e.message;
    MailApp.sendEmail(EMAIL, subject, body);
    return output;

Besides the easy deployment, Google Script also provides a decent set of script services. Unfortunately, I ran into problems with it while writing my verification code. I don’t want any POST to this URL to possibly send me an email, and MailGun provides a signature and documentation on how to verify the incoming POST. The original verify() function I wrote looked like this:

function verify(key, token, timestamp, signature) {
    var computed = Utilities.computeHmacSha256Signature(timestamp + token, key);
    return computed == signature;

But look at Byte[] computeHmacSha256Signature(String value, String key), it returns a byte array. Specifically, one like this: [90, -45, -75, 57, ...]. Okay, what’s the problem? Surely we can take a Byte[] and convert it to a String, or vice versa and get a valid comparison. This is not as easy as it appears.

This issue suggests using Utilities.newBlob(bytes).getDataAsString(); to convert us from Byte[] to String, but (like noted in the issue) it has a problem with negative values in the byte array. Rather than work around this problem by, say, including and using jsSHA (which I actually tried, successfully), let’s instead understand the actual problem.

What we really need is a refresher on the computer science fundamentals: two’s complement and hexadecimal representation of bytes. The negative integers in the array are a misinterpretation of the computed bytes, which are in two’s complement. If we can understand the data computeHmacSha256Signature() is giving us, then we can convert it correctly.

Let’s take a look at the first two bytes of this array [90, -45, ...].


Positive integers in two’s complement are unchanged.

90 in binary is 0b1011010.

But we’re dealing with bytes, so we need exactly eight bits.

Pad the left with zeroes until we have the byte 0101 1010.

Each half of a byte (four bits) is known as a “nibble”, and each nibble spans the decimal range [0, 15], or the hexadecimal range [0, F].

Left nibble 0101 in hex is 5.

Right nibble 1010 in hex is A.

Hence the byte 90 in hex is 0x5A.


Negative integers in two’s complement are the “one’s complement plus one” of the absolute value.

45 in binary is 0b1011010. The byte is 0010 1101.

But our byte was -45, not 45. So the first step is to take the one’s complement (i.e. invert the bytes).

One’s complement is 1101 0010.

The next step is to simply add one (arithmetically): 1101 0011.

Now we have the two’s complement byte representation of -45.

Left nibble 1101 in hex is D.

Right nibble 0011 in hex is 3.

Hence the byte -45 in hex is 0xD3

This is very different from the number -45 in hex, which is simply -0x2D. It is this misinterpretation of the byte values that is the root of our problem.

[90, -45, …]

Proceeding to convert the rest of the byte array in this manner would yield the full hexadecimal representation of our computed signature:


Now we just need to do this programmatically.

function toHexString(bytes) {
  return {
    return ('0' + (byte & 0xFF).toString(16)).slice(-2);

This code seems odd, doesn’t it? Why can’t we just call byte.toString(16)? The problem comes down to padding and signs. For each byte in the array, we need to convert exactly two nibbles into two hexadecimal digits.

So the first operation we apply is byte & 0xFF, or, “bitwise AND with 1111 1111”. This operation appears to be a no-op, after all 1101 0011 & 1111 1111 = 1101 0011, but it ensures we’re looking only at the least significant (rightmost) byte, and zeroes every bit to the left. When dealing with Google Script’s Byte this is necessary to prevent toString(16) from converting the integer -45 instead of the byte 1101 0011. I’m still uncertain why this happens; I expected a Byte to be treated exactly as a byte, but the behavior is as though it was a multi-byte integer.

So after we take (byte & 0xFF).toString(16) we have the hex string 2d. Now padding comes into play. Take for example the byte 13, or 0000 01101. In hex, this is 0xD, and more precisely, (13).toString(16) returns d. But we started with two nibbles, and need two hex digits, because the byte must be represented as 0d. So we pad it by prepending '0', and then slice(-2) to obtain exactly the two digits we’re looking for.

Now that we can convert the Byte[] hash to a hex string, we can fix the verify() function:

function verify(key, token, timestamp, signature) {
    var computed = toHexString(Utilities.computeHmacSha256Signature(timestamp + token, key));
    return computed == signature;

Putting it all together

This following embedded Gist is the final code I currently use in my Google Script (with the email and API key replaced). I spent more time than I care to admit making the email look at least a bit pretty, and writing test code to debug the script. The Logger class only logs when running functions locally, so I had to mock the input from a POST.

Deploying the script is easy: click Publish then Deploy as web app..., set Who has access to the app to Anyone, even anonymous, and copy the URL into the each event’s webhook you want to monitor (I monitor Dropped Messages and Hard Bounces, but used Delivered Messages for integration testing).