I'm trying to use the Twilio Programmable Fax API to securely receive faxes in PDF format using AWS Lambda (Node.js environment) and API Gateway.
There are two Lambda functions involved to receive the faxed document. The first one is a webhook configured in the Twilio admin, and the second is specified in my TwiML response to Twilio from the first webhook.
Here is the implementation of my webhook:
module.exports.faxSentSecure = async (event, context) => {
const twiml = `
<Response>
<Receive action="https://bd3j05rkk8.execute-api.ca-central-1.amazonaws.com/dev/v1/webhooks/twilio/fax/received/secure" storeMedia="false"/>
</Response>
`;
const response = {
statusCode: 200,
headers: {
"Content-Type": "text/xml",
},
body: twiml,
};
console.log(response);
return response;
};
Shortly after responding with the above TwiML, Twilio will call the endpoint I've specified in the action parameter.
A few things to note at this point:
- For compliance reasons, in my TwiML response above I'm specifying
storeMedia="true"which causes Twilio avoid media storage and to call my specified action endpoint with the fax data asmultipart/form-datawhere the actual PDF data is embedded in a parameter calledMedia. - Apparently in order to parse
multipart/form-datausing AWS Lambda functions, according to my research I need to specify it as a binary type in my API gateway settings. With this configuration, after I respond with the above TwiML, my Lambda function gets called and thebodyparameter of theeventobject I receive is a Base 64 encoded string (as opposed to UTF-8 if the binary type was not specified).
The issue I'm running into is when I try to parse out the PDF data to be stored somewhere (in my case it's AWS S3), I end up with a corrupted/unreadable PDF document.
Here is the implementation of my second webhook which receives the faxed document:
"use strict";
const querystring = require("querystring");
const fetch = require("node-fetch");
const stream = require("stream");
const Busboy = require("busboy");
const AWS = require("aws-sdk");
const s3 = new AWS.S3();
module.exports.faxReceivedSecure = async (event, context) => {
console.log(JSON.stringify(event));
console.log(JSON.stringify(event.headers));
console.log(JSON.stringify(event.body));
// decode the multipart form
const base64Body = event.body; // base64 body from API gateway
const decodedBody = Buffer.from(base64Body, "base64").toString("utf8");
let form;
try {
form = await parseForm(event.headers, decodedBody);
console.log("PARSED FORM", form);
} catch (parseError) {
console.log("PARSE ERROR: ", parseError);
}
// write the PDF to disk
if (form) {
const filename = form.Filename;
// this results in corrupted PDF data
const media = form.Media;
const s3UploadResult = await s3
.putObject({
Bucket: process.env.BUCKET,
Key: filename,
Body: media,
})
.promise();
console.log("S3 UPLOAD RESULT: ", s3UploadResult);
const response = {
statusCode: 200,
body: JSON.stringify({
message: "Fax Received!",
s3UploadResult: s3UploadResult,
}),
};
return response;
} else {
const response = {
statusCode: 404,
body: JSON.stringify({
message: "Unable to parse fax data!",
}),
};
return response;
}
};
const parseForm = (headers, body) => {
return new Promise((resolve, reject) => {
let form = {};
const contentType = headers["Content-Type"] || headers["content-type"];
const busboy = new Busboy({ headers: { "content-type": contentType } });
busboy.on("field", (fieldname, val) => {
form[fieldname] = val;
});
busboy.on("file", (fieldname, file, filename, encoding, mimetype) => {
console.log(
"File [%s]: filename=%j; encoding=%j; mimetype=%j",
fieldname,
filename,
encoding,
mimetype
);
form.Fieldname = fieldname;
form.Filename = filename;
form.Encoding = encoding;
form.Mimetype = mimetype;
file.on("data", (data) => {
console.log("File [%s] got %d bytes", fieldname, data.length);
form[fieldname] = data;
});
file.on("end", () => console.log("File [%s] Finished", fieldname));
});
busboy.on("finish", () => {
resolve(form);
});
busboy.on("error", (err) => {
reject(err);
});
busboy.end(body);
});
};
And the resolved object from the parseForm(headers, body) function looks like this:
{
NumPages: '2',
BitRate: '14400',
Resolution: 'fine',
FaxSid: 'FX467437b3f418ee4dfac5e0838906f226',
Fieldname: 'Media',
Filename: 'fax_FX467437b3f418ee4dfac5e0838906f226_AC8a237b34db1faa8c9dd6a8ee96ca38ae.pdf',
Encoding: '7bit',
Mimetype: 'application/pdf',
To: '+18332003898',
AccountSid: 'AC8a237b34db1faa8c9dd6a8ee96ca38ae',
FaxStatus: 'received',
RemoteStationId: 'FaxZero.com',
From: '+12014799379',
ApiVersion: 'v1',
Media: <Buffer 25 50 44 46 2d 31 2e 31 20 0a 25 c3 a2 c3 a3 c3 8f c3 93 0a 31 20 30 20 6f 62 6a 0a 3c 3c 20 0a 2f 54 79 70 65 20 2f 43 61 74 61 6c 6f 67 20 0a 2f 50 ... 33847 more bytes>,
Status: 'received'
}
I'm using busboy to parse the multipart form, and as you can see from the above output I'm able to extract all the data from the form. You can even see that the Media parameter is extracted as a buffer object.
However when I now try to store this buffer object either by writing it to disk (using a local express test server) or by uploading it to S3 all I get is a corrupted looking PDF.
The only issue I can think of that's happening here is that I'm incorrectly decoding the body data and causing the corruption of the PDF data.
I've been scratching my head for days on this and it's driving me nuts. Help would be greatly appreciated.