Downloading Media using Whatsapp’s Cloud API Webhook and uploading it to AWS S3 buckets through NodeJS

Shreyas Sreedhar
16 min readDec 25, 2023

--

For my final semester project of my Web Design and User Experience class at Northeastern Uni, We tackled an interesting problem of Subletting/SubLeasing for International Northeastern Students in Boston.

During this I encountered an intriguing challenge: How do I take Whatsapp messages that contain Images or Videos sent from a WhatsApp user(NEU student) to store it in our Web App at the cost of $0.

There’s plenty of articles on how to send a media to a user via the Whatsapp CloudAPI but barely a handful sources on How to download the media sent by the user. This task, though daunting at first, led me down a path of exploration. In this article, I’ll share how I played around with various tools and techniques to create a streamlined process that costs ₹0 or $0.

It’s basically like Twilio but free.

Using the Whatsapp CloudAPI’s Webhooks a.k.a a Whatsapp Bot that saves this media sent by the user automatically on to an AWS S3 bucket.

WhatsApp shaking hands with an AWS — Generated by DallE 3

TLDR;

Setup Nodejs app to get the incoming JSON data, Parse for wa_id , image , img_idand mime_type . Create another function to make a GET request to graph.facebook.com API with your Whatsapp Auth Token as the bearer token and wa_id and img_id , your response is a lookaside.fbsbx.com API link with the Media URL. Send a request to the lookaside.fbsbx.com API with “response:” as arraybuffer. Save the response which is usually in Binary, convert it to the media type using the inbuilt buffer class. Use the data from conversion and upload it to via S3 with the usual JS methods available, in this case the PutObjectCommand to our S3 bucket. You’re done.

Github Repo — incase you want to jump right into the code.

PreRequesites

The Article written might be crude and out of place at times, please excuse and do let me know on how I can further simplify the code.

The intro was clickbait — Ngrok is technically free but you could host it on sites like Heroku or Vercel to make it permanent.

I’ve also written in the mindset that you the reader already are into building Whatsapp bots or have started it and you also know how to build applications (i.e. not a noob like me) and already have a NodeJS Express App set up and running.

Incase you don’t, plenty of videos/articles out there on how to get started — You would need these to run for this topic.

  • NodeJS
  • Express
  • Body-Parser
  • AWS Account with S3 Bucket set up — Free tier
  • Whatsapp Cloud API account Setup with Temporary Token ready.
  • Ngrok Installed — Free tier
  • VScode or any terminal of your choice.

For the sake of the tutorial — We’re going to use POSTMAN to send messages to the phone number the whatsapp bot is going to message instead of using the Nodejs Server.

Ok — how does it work?

The working of WhatsappCloud API to AWS storage.

The Working:

  1. Whatsapp verifies your secret token and domain name you’ve given in the API Configuration from the facebook developer console.
  2. Whatsapp now verifies the domain name and sets a verification request to it.
  3. You probably would have recieved a message from Whatsapp’s text number.
  4. Let’s respond with a media file. Let’s just say it’s an Photo for now. It’s now sent to the Bot.
  5. The media is now sent to the Whatsapp Server, where it’s being processed.
  6. The response from the User is now in a JSON format that’s being sent via POST to our locally hosted server through Ngrok.
  7. This JSON is then parsed through a function in our index.js file for details like Whatsapp Id, Phone number, Media URL & Media type.
    Our Media is stored in a url from Graph API which is only available for 5 minutes and expires after 5 minutes. It also needs a auth token in the header with it to access.
  8. Now the Media URL which is from the Whatsapp Business Management API ✨ , we make a GET request to it for downloading the file with the auth token in the header.
    NOTE: The Response of this page is itself an Image in Binary (spent a good amount of time in trying to save this page and not the response)
  9. We receive a response from the Whatsapp Business Management API ✨ as Binary data, which we then convert in our NodeJs app and directly send the data to AWS as well as to download it locally.
  10. We now take the data as it is send it to AWS S3 bucket from the @aws-sdk/client-s3 npm package and upload it to our bucket based in a file structure — bucket/<userphonenumer>/<typeofmedia>/WA_<mediaID>
  11. Using the fs npm package, we also store it locally to verify the image sent.

Before you start —

Create two new fields in your .env file in your directory

TOKEN and MYTOKEN

TOKEN="<MyWhatsappCloudAPIAccessToken>"
MYTOKEN="<AnyVerificationString>"

Get the Whatsapp Cloud API token from the API Setup Page > Copy the temporary access token, paste it in TOKEN

Create any String under MYTOKEN we basically need this for Whatsapp to verify the domain URL. anything here works. I’m going to use MYTOKEN="SHREYAS"

Access token from developers.facebook.com console

Run your nodejs app by using this command in the terminal.

node index.js

Open a Separate Intergrated Terminal of VSCode or a seprate terminal, Run Ngrok to create a Forwarding URL to our NodeJS app. I’ve used the port number 8080 use any port number you want but make sure it’s the same one index.js is running on.

ngrok http 8080 

You would see something like this pop up in your terminal.

Under the forwarding you’d have a URL. This is your WebApp that’s hosted on the Web for now.

Copy the whole string. https://...........ngrok-free.app

NOTE: This changes everytime you run ngrok but hey, this costs $0, gotta compromise on something.

Let’s go back to our developers.facebook.com page and navigate to the API config section from the sidebar.

You should land at something like this.

Depending on the useage, you either have edit or create webhook on your portal. Click on it.

Add the URL you just copied from the ngrok terminal here.

make sure to add /webhook or the /<pathname> You’ve given to your GET method in your app here. it’s pretty important.

Add the verify token you entered in your .env file here. In my case it’s just “SHREYAS”

Click on Verify and save.

You’ll something like this Pop up. If it says 200 OK, congratulations, you’ve now connected Whatsapp API to your Application.

Okay, Now let’s actually begin.

  1. In your index.js file, lets begin by adding code to parse the incoming JSON data i.e incoming Whatsapp Message sent to the bot.

You assign a variable to request the body.


app.post("/webhook", (req, res) => {

let body_content = req.body;

We get a response. Let’s now dig inside the response to get what we want.

Click on the “Web Interface” link in your ngrok terminal to inspect the incoming responses.

You’d see something like this.

This is the Response Json we get

{
"object": "whatsapp_business_account",
"entry": [
{
"id": "171111111111325",
"changes": [
{
"value": {
"messaging_product": "whatsapp",
"metadata": {
"display_phone_number": "15550716952",
"phone_number_id": "171370726059401"
},
"contacts": [
{
"profile": {
"name": "Shreyas S"
},
"wa_id": "91999999999"
}
],
"messages": [
{
"from": "919999999999",
"id": "wamid.HBgMOTE5NzQyNDg2MjcxFQIAFhgUM0EzMDg3MzA0QTNBRDM2QzhGQTcA",
"timestamp": "1703462814",
"type": "image",
"image": {
"mime_type": "image/jpeg",
"sha256": "Oz7aRmRmALR++ZDmS8LqkZxsjFDSwpTZYyXUAQ9ZlRk=",
"id": "725847798869820"
}
}
]
},
"field": "messages"
}
]
}
]
}

From this, we’d require a list of things

#1 Whatsapp ID of the user

#2 Message Content the user has sent.

#3 Image type

#4 Image Id

Let’s get this by

 if (body_content.object) {
console.log("Body has an Object and is Inside it");

//Getting the Whatsapp senders ID
let wappid = body_content.entry[0].changes[0].value.contacts[0].wa_id;

let messages = body_content.entry[0].changes[0].value.messages;
}

Now assuming there’s more than 1 messages, let’s traverse through all the messages we get by a for loop.

for (let i = 0; i < messages.length; i++) {
...
}

Inside the for loop, Let’s go to the content of the first message that we get till the total number of messages sent.

  let message = messages[i];
console.log(message);

Since we’re just dealing with just images and videos, we can directly assign a If condition to check if we’ve gotten a image or a video, do the neccesary steps if neither then exit.

 for (let i = 0; i < messages.length; i++) {
let message = messages[i];
console.log(message);
// Check if the message type is "image"
if (message.type === "image") {
let imageInfo = message.image;

// Accessing the type of media send and image id
let mime_type = imageInfo.mime_type;
let id = imageInfo.id;
console.log("mime_type:", mime_type);
console.log("id:", id);


}
//Check if the message type is "Video"
else if (message.type === "video") {
let imageInfo = message.video;


let mime_type = imageInfo.mime_type;
let id = imageInfo.id;


console.log("mime_type:", mime_type);
console.log("id:", id);

}
}

We’ve now have image type a.ka mime_type and image id a.ka id

Now let’s create a async function called sendGetrequest with a try-catch o send a request to the Facebook Business Management API to download the Image for us.

async function sendGetRequest(id, wappid) {

try {


} catch (error) {
console.error('Error saving image from sendgetrequest:', error.message);
}
}

Let’s start by defining the Base API. the API we need to get images from facebook is https://graph.facebook.com/v18.0/<yourimageid>

BUT — you can only access this API is your auth token is sent together in the header, so we then modify the header for the GET request by adding our auth token under the Authorization part.

 const response = await axios.get(newurl, {
headers: {
"Authorization": "Bearer " + token // Add your Token to the header of the API request
}
})

We might get a response if this runs, if we get a response we send it to another function to download the image.

We usualy get a lookaside.fbsx.com API url as a response with few other parameters. We Parse that JSON as well.

if (response.data && response.data.url) {

//Get the Image Url
const mediaURL = response.data.url;
//Get the Image type, need it for saving in AWS S3

const mediaMimeType = response.data.mime_type;

console.log(" Response from Graph V.18 - image: " + mediaURL);
console.log(" Mime type: " + mediaMimeType);

sendImgDownload(mediaURL, mediaMimeType, wappid, id);



}

We now add few checkups and functionalities to this function sendGetRequest

async function sendGetRequest(id, wappid) {
newurl = "https://graph.facebook.com/v18.0/" + id;
try {
const response = await axios.get(newurl, {
headers: {
"Authorization": "Bearer " + token // Add your Token to the header of the API request
}
})
console.log(response);
//if you want to see the response you get.

if (response.data && response.data.url) {

//Get the Image Url
const mediaURL = response.data.url;
//Usually - lookaside.fb
//Get the Image type, need it for saving in AWS S3

const mediaMimeType = response.data.mime_type;

console.log(" Response from Graph V.18 - image: " + mediaURL);
console.log(" Mime type: " + mediaMimeType);

sendImgDownload(mediaURL, mediaMimeType, wappid, id);



} else {
console.log("Unexpected response format:", response.data);
}
} catch (error) {
console.error('Error saving image from sendgetrequest:', error.message);
}
}

Now let’s also update our POST /webhook call by calling our sendGetRequest function into it.

// webhook through POST

app.post("/webhook", (req, res) => {

let body_content = req.body;

// console.log(JSON.stringify(body_content, null, 2));


//Conditional statement to check if the body_content (the request body) contains an object property.
if (body_content.object) {
console.log("Body has an Object and is Inside it");
console.log(body_content)
//Getting the Whatsapp senders ID
let wappid = body_content.entry[0].changes[0].value.contacts[0].wa_id;

let messages = body_content.entry[0].changes[0].value.messages;

// Iterate through messages array
for (let i = 0; i < messages.length; i++) {
let message = messages[i];
console.log(message);
// Check if the message type is "image"
if (message.type === "image") {
let imageInfo = message.image;

// Accessing the type of media send and image id
let mime_type = imageInfo.mime_type;
let id = imageInfo.id;
console.log("mime_type:", mime_type);
console.log("id:", id);

//sending a request to GraphAPI with Image Id and Whatsapp Sender ID
sendGetRequest(id, wappid)
}
//Check if the message type is "Video"
else if (message.type === "video") {
let imageInfo = message.video;


let mime_type = imageInfo.mime_type;
let id = imageInfo.id;


console.log("mime_type:", mime_type);
console.log("id:", id);
//sending a request to GraphAPI with Video Id and Whatsapp Sender ID
sendGetRequest(id, wappid)
}
}

res.sendStatus(200);
} else {
res.sendStatus(404);
}



});

Now let’s begin writing our sendImgDownload function to get the URL of our image (lookaside.fbsbx) and download the image.

We shall also send this file to our AWS function to upload it to our S3 bucket.

async function sendImgDownload(mediaURL, mediaMimeType, wappid, id) {}
  • mediaURl is our lookaside.fbsbx url.
  • medaMimeType is our final media type as per stored by FB on their servers.
  • wappId is the same whatsapp ID
  • id is our image_Id

Inside our function, we try and wait for a response from the mediaURL we get.
Upon accessing this URL with our auth token in the header, our response is usually a BINARY Image. Do not try to access the response, traverse it to save the image instead just save the GET response you get, which is usually BINARY DATA. Hence for the Response type, we give ‘arraybuffer’ as it is.

Why is that you ask? I honestly don’t know why but i do know it works. (source: some YT tutorial i watched)

filename = `WA_${id}`;
// filename = "testname"
let data = '';
try {
const response = await axios.get(mediaURL, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': mediaMimeType,
},
responseType: 'arraybuffer', // This is important for binary data
});

// Check if the response contains data
if (response.data) {.....}
}
catch (error) {
console.error('Error sending to AWS:', error.message);
}
}

Now lets use the response to convert the Binary data to an media file and send this to AWS.
Inside the If condition — we check if the mediaMimetype starts with “image/jpeg”. We usually get medaMimetype as “image/jpeg”.

We take the “jpeg” part of the URL add it to filename to give it filesync to let it know the extension we want to convert the Binary data to and save it locally.

We also send the same details over to our AWS S3 function sendtoaws

Here’s the if condition for image and videos.

 if (mediaMimeType.startsWith("image/")) {
file_extension = filename + "." + mediaMimeType.split('/')[1]
typeoffile = mediaMimeType.split('/')[0]

somedata = Buffer.from(response.data, 'binary')
// Save the binary data to a variable

//Sending it to aws
sendtoaws(file_extension, mediaMimeType, somedata, wappid, typeoffile);

await fs.writeFileSync(file_extension, Buffer.from(response.data, 'binary'));

console.log(`Media saved to ${file_extension} successfully.`);

} else if (mediaMimeType.startsWith("video/")) {
file_extension = filename + "." + mediaMimeType.split('/')[1]
typeoffile = mediaMimeType.split('/')[0]

somedata = Buffer.from(response.data, 'binary')

// Save the binary data to a file
sendtoaws(file_extension, mediaMimeType, somedata, wappid, typeoffile);
await fs.writeFileSync(file_extension, Buffer.from(response.data, 'binary'));
console.log(`Media saved to ${file_extension} successfully.`);
}

The complete sendImgDownload function:

async function sendImgDownload(mediaURL, mediaMimeType, wappid, id) {
filename = `WA_${id}`;
// filename = "testname"
let data = '';
try {
const response = await axios.get(mediaURL, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': mediaMimeType,
},
responseType: 'arraybuffer', // This is important for binary data
});

// Check if the response contains data
if (response.data) {

// Splitting the mimetype to save in AWS

if (mediaMimeType.startsWith("image/")) {
file_extension = filename + "." + mediaMimeType.split('/')[1]
typeoffile = mediaMimeType.split('/')[0]

somedata = Buffer.from(response.data, 'binary')
// Save the binary data to a variable

//Sending it to aws
sendtoaws(file_extension, mediaMimeType, somedata, wappid, typeoffile);

await fs.writeFileSync(file_extension, Buffer.from(response.data, 'binary'));

console.log(`Media saved to ${file_extension} successfully.`);

} else if (mediaMimeType.startsWith("video/")) {
file_extension = filename + "." + mediaMimeType.split('/')[1]
typeoffile = mediaMimeType.split('/')[0]

somedata = Buffer.from(response.data, 'binary')

// Save the binary data to a file
sendtoaws(file_extension, mediaMimeType, somedata, wappid, typeoffile);
await fs.writeFileSync(file_extension, Buffer.from(response.data, 'binary'));
console.log(`Media saved to ${file_extension} successfully.`);
}
} else {
console.error('Empty response data received.');
}
} catch (error) {
console.error('Error sending to AWS:', error.message);
}
}

Now let’s begin to push the image to our AWS S3 Bucket.
Let’s create a new file called awss3.js and put in the boiler plate for uploading our image but with the file structure we want.

const {
S3Client,
PutObjectCommand
} =require( "@aws-sdk/client-s3");


require('dotenv').config();

Let’s initialise our bucket here. Store the the S3 Access Keys in your .env file.

  //Adding the passwords 
let s3cli = new S3Client({
credentials:{
accessKeyId : process.env.S3_AccKEY, //Change access key from .env file
secretAccessKey : process.env.S3_SecAccKEY //Change Secret Access key from .env file
},
region : "us-east-2"//Change region here
});

Lets create the async function and use the same parameters we’ve sent over from the index.js file and with a try catch.

Let’s also use the PutObjectCommand from the AWS-S3 client package that allows us to upload objects onto S3


const upload = async (key,mimetype,bindata,wappid, typeoffile)=>{
var folder = wappid;
var subFolder = typeoffile;
let putobj = new PutObjectCommand({
Bucket: "mybucket", //Add your Bucket Name here
Key: `${folder}/${subFolder}/${key}`, //assigns media into separate folders according to the media type
Body: bindata,
"ContentType" :mimetype //Mediatype
})

try{
await s3cli.send(putobj);
console.log("In the putobj method");
}
catch(err){
console.log(err);
}
}

Here’s a breakdown of what the code does:

  1. It defines an asynchronous function named upload that accepts the following parameters:
  • key: The object key (filename) for the S3 object.
  • mimetype: The content type or media type of the object being uploaded.
  • bindata: The binary data to be uploaded.
  • wappid: A variable that likely represents a folder or "bucket" name in your S3 bucket.
  • typeoffile: A variable that represents a subfolder or subdirectory within the S3 bucket where the object will be stored.
  1. Inside the function, it sets the folder variable to the value of wappid, and the subFolder variable to the value of typeoffile. These variables will be used to organise the S3 objects into different folders within your S3 bucket.
  2. It creates a PutObjectCommand instance to specify the parameters for uploading an object to S3. The parameters are:
  • Bucket: The name of the S3 bucket where the object will be stored.
  • Key: The object key (path) within the bucket, combining the folder, subFolder, and key to create a unique path for the object.
  • Body: The binary data to be uploaded.
  • "ContentType": The content type to let the S3 store as.

So we are now sorting our files as

<OurBucketName>/<whatsappid>/<typeoffile>/<filename_fileid.<mediatype>

which would look like

mybucket/919999999999/image/WA_393939202039.jpeg

That’s it. We just need to export this S3 file and we’d be done with the tutorial.

module.exports= {
upload
}

The complete AWS S3 javascript File would be as follows — :

const {
S3Client,
PutObjectCommand
} =require( "@aws-sdk/client-s3");


require('dotenv').config();

//Adding the passwords
let s3cli = new S3Client({
credentials:{
accessKeyId : process.env.S3_AccKEY, //Change access key from .env file
secretAccessKey : process.env.S3_SecAccKEY //Change Secret Access key from .env file
},
region : "us-east-2"//Change region here
});

console.log("AWS S3 file loaded");


const upload = async (key,mimetype,bindata,wappid, typeoffile)=>{
var folder = wappid;
var subFolder = typeoffile;
let putobj = new PutObjectCommand({
Bucket: "mybucketname", //Add your Bucket Name here
Key: `${folder}/${subFolder}/${key}`, //assigns media into separate folders according to the media type
Body: bindata,
"ContentType" :mimetype //Mediatype
})

try{
await s3cli.send(putobj);
console.log("In the putobj method");
}
catch(err){
console.log(err);
}
}

module.exports= {
upload
}

and our complete index.js file as follows.

console.log("Hello from Indexjs");


const express = require("express");
const body_parser = require("body-parser");
const axios = require("axios");
const fs = require("fs");
const s3 = require("./s3"); //Adding the aws s3 connection file.

require('dotenv').config();


const app = express().use(body_parser.json());


const token = process.env.TOKEN;
const mytoken = process.env.MYTOKEN;


//Remove "8080 ||" if you're hosting it in heroku or elsewhere

app.listen(8080 || process.env.PORT, () => {
console.log("Website is Running");
});


//To Verify if through GET is working - Needed for FB to verify if /webhook works
app.get("/webhook", (req, res) => {
let mode = req.query["hub.mode"];
let challange = req.query["hub.challenge"];
let token = req.query["hub.verify_token"];
if (mode && token) {
if (mode === "subscribe" && token === mytoken) {
res.status(200).send(challange);
} else {
res.status(403);
}
}
});

// webhook through POST

app.post("/webhook", (req, res) => {

let body_content = req.body;

// console.log(JSON.stringify(body_content, null, 2));


//Conditional statement to check if the body_content (the request body) contains an object property.
if (body_content.object) {
console.log("Body has an Object and is Inside it");
console.log(body_content)
//Getting the Whatsapp senders ID
let wappid = body_content.entry[0].changes[0].value.contacts[0].wa_id;

let messages = body_content.entry[0].changes[0].value.messages;

// Iterate through messages array
for (let i = 0; i < messages.length; i++) {
let message = messages[i];
console.log(message);
// Check if the message type is "image"
if (message.type === "image") {
let imageInfo = message.image;

// Accessing the type of media send and image id
let mime_type = imageInfo.mime_type;
let id = imageInfo.id;
console.log("mime_type:", mime_type);
console.log("id:", id);

//sending a request to GraphAPI with Image Id and Whatsapp Sender ID
sendGetRequest(id, wappid)
}
//Check if the message type is "Video"
else if (message.type === "video") {
let imageInfo = message.video;


let mime_type = imageInfo.mime_type;
let id = imageInfo.id;


console.log("mime_type:", mime_type);
console.log("id:", id);
//sending a request to GraphAPI with Video Id and Whatsapp Sender ID
sendGetRequest(id, wappid)
}
}

res.sendStatus(200);
} else {
res.sendStatus(404);
}



});

async function sendGetRequest(id, wappid) {
newurl = "https://graph.facebook.com/v18.0/" + id;
try {
const response = await axios.get(newurl, {
headers: {
"Authorization": "Bearer " + token // Add your Token to the header of the API request
}
})
console.log(response);
//if you want to see the response you get.

if (response.data && response.data.url) {

//Get the Image Url
const mediaURL = response.data.url;
//Usually - lookaside.fb
//Get the Image type, need it for saving in AWS S3

const mediaMimeType = response.data.mime_type;

console.log(" Response from Graph V.18 - image: " + mediaURL);
console.log(" Mime type: " + mediaMimeType);

sendImgDownload(mediaURL, mediaMimeType, wappid, id);



} else {
console.log("Unexpected response format:", response.data);
}
} catch (error) {
console.error('Error saving image from sendgetrequest:', error.message);
}
}

async function sendImgDownload(mediaURL, mediaMimeType, wappid, id) {
filename = `WA_${id}`;
// filename = "testname"
let data = '';
try {
const response = await axios.get(mediaURL, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': mediaMimeType,
},
responseType: 'arraybuffer', // This is important for binary data
});

// Check if the response contains data
if (response.data) {

// Splitting the mimetype to save in AWS

if (mediaMimeType.startsWith("image/")) {
file_extension = filename + "." + mediaMimeType.split('/')[1]
typeoffile = mediaMimeType.split('/')[0]

somedata = Buffer.from(response.data, 'binary')
// Save the binary data to a variable

//Sending it to aws
sendtoaws(file_extension, mediaMimeType, somedata, wappid, typeoffile);

await fs.writeFileSync(file_extension, Buffer.from(response.data, 'binary'));

console.log(`Media saved to ${file_extension} successfully.`);

} else if (mediaMimeType.startsWith("video/")) {
file_extension = filename + "." + mediaMimeType.split('/')[1]
typeoffile = mediaMimeType.split('/')[0]

somedata = Buffer.from(response.data, 'binary')

// Save the binary data to a file
sendtoaws(file_extension, mediaMimeType, somedata, wappid, typeoffile);
await fs.writeFileSync(file_extension, Buffer.from(response.data, 'binary'));
console.log(`Media saved to ${file_extension} successfully.`);
}
} else {
console.error('Empty response data received.');
}
} catch (error) {
console.error('Error sending to AWS:', error.message);
}
}

async function sendtoaws(file_extension, mediaMimeType, somedata, wappid, typeoffile) {
//Uploading stuff to the S3 bucket
await s3.upload(file_extension, mediaMimeType, somedata, wappid, typeoffile);
console.log("Image uploaded to AWS S3 successfully");

}

That’s it folks.

Congratulations, you can now successfully store WhatsApp images from WhatsApp Webhooks.

Feel free to reach out to me incase of any doubts or clarifications.

Thanks for reading :)

--

--