Messaging and bots
WhatsApp bot that sizes images with SocialCutter
Receive an image or a link through the WhatsApp Cloud API, generate the formats with SocialCutter and reply with the image or a link, within the 24-hour window.
- Cloud API
- Meta
- webhook
- templates
- SocialCutter
- Python
What this bot does
The user sends a photo or pastes a link into WhatsApp and gets back the image resized for the network they asked for, plus a download link. The conversation runs on Meta’s WhatsApp Cloud API and the cropping is done by SocialCutter.
Saying it plainly: SocialCutter does not publish to social networks. It generates the files with a centered crop — no subject detection, no models involved — and returns one public URL per format. Sending them over WhatsApp is your bot’s job; publishing to Instagram, Facebook or LinkedIn is a separate integration with each platform’s API.
Requirements
| Piece | Detail |
|---|---|
| Meta app | Business type, with the WhatsApp product added |
| WhatsApp Business Account | With the phone number verified and registered for the Cloud API |
| Token | System user token with whatsapp_business_messaging; user tokens expire in 24 hours |
| Webhook | Public HTTPS URL that verifies hub.verify_token and receives the messages field |
| SocialCutter | sc_ key from https://dash.socialcutter.theboomer.dev under Profile → API keys |
| Graph API version | Pin a version (v21.0 and later appear in Meta’s examples) and review it in the docs; versions get retired over time |
export WA_TOKEN="EAAG...system-user-token"
export WA_PHONE_ID="123456789012345" # phone number ID, not the phone number
export WA_VERIFY_TOKEN="a-secret-of-yours"
export SOCIALCUTTER_API_KEY="sc_your_key"
Official docs: https://developers.facebook.com/docs/whatsapp/cloud-api/ plus the message and template guides linked below.
The flow, step by step
- A message arrives. Meta POSTs to your webhook:
messages[0].image.idfor a photo, ormessages[0].text.bodyfor text with a link. Answer 200 within a few seconds and do the work in the background. - Get the file.
GET https://graph.facebook.com/{version}/{media-id}returns a temporary URL. That URL lives only 5 minutes and the download requires the token header; a media id delivered by webhook can be downloaded for 7 days. - Generate the formats. The downloaded file goes to
POST /api/v1/images/process/upload(multipart, 5 MB maximum) withdestinationsandoptions. - Reply. An
imagemessage withimage.linkpointing at the SocialCutter URL (Meta fetches and shows it), or a text message with the download link. Outside the 24-hour window only approved templates are allowed.
The 24-hour window and templates
When the user messages you, a 24-hour customer service window opens: inside it you can send free-form messages (text, image, document, interactive). If the user writes again, the timer resets to 24 hours. Once 24 hours pass with no reply from the user, you can only send pre-approved templates; a plain image is rejected. Meta caches a media link for 10 minutes: if you resend the same URL and want it re-fetched, append a different query parameter.
References: https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages and templates at https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates.
Code: receive, generate, reply
From Meta to SocialCutter:
# 1. Temporary URL for the media the user just sent (lives 5 minutes)
curl -s "https://graph.facebook.com/v21.0/$MEDIA_ID" \
-H "Authorization: Bearer $WA_TOKEN" | jq -r '.url' > media_url.txt
# 2. Download with the token: the URL alone does not return the file
curl -s -o incoming.jpg "$(cat media_url.txt)" -H "Authorization: Bearer $WA_TOKEN"
# 3. Generate the formats (1 use per destination; 5 MB maximum)
curl -s -X POST "https://api.socialcutter.theboomer.dev/api/v1/images/process/upload" \
-H "X-API-Key: $SOCIALCUTTER_API_KEY" \
-F "[email protected];type=image/jpeg" \
-F 'destinations=[{"platform":"instagram","format":"post"},{"platform":"linkedin","format":"post"}]' \
-F 'options={"format":"jpg","quality":88}' > output.json
jq '.image_id // .id, (.outputs[] | {platform, format, width, height, url})' output.json
# 4. Reply with the resized image (link field, HTTPS and public)
curl -s -X POST "https://graph.facebook.com/v21.0/$WA_PHONE_ID/messages" \
-H "Authorization: Bearer $WA_TOKEN" -H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "14155551234",
"type": "image",
"image": {"link": "https://api.socialcutter.theboomer.dev/.../instagram-post.jpg",
"caption": "Instagram post 1080x1080"}
}'
Full Python flow with requests:
import json
import os
import requests
WA_TOKEN = os.environ["WA_TOKEN"]
WA_PHONE_ID = os.environ["WA_PHONE_ID"]
SC_KEY = os.environ["SOCIALCUTTER_API_KEY"]
VERSION = "v21.0"
SC_URL = "https://api.socialcutter.theboomer.dev"
COMMANDS = {
"ig": [{"platform": "instagram", "format": "post"}],
"story": [{"platform": "instagram", "format": "story"}],
"li": [{"platform": "linkedin", "format": "post"}],
}
def download_media(media_id):
url = requests.get(f"https://graph.facebook.com/{VERSION}/{media_id}",
headers={"Authorization": f"Bearer {WA_TOKEN}"}, timeout=30).json()["url"]
r = requests.get(url, headers={"Authorization": f"Bearer {WA_TOKEN}"}, timeout=60)
r.raise_for_status()
return r.content
def generate(image, destinations):
r = requests.post(
f"{SC_URL}/api/v1/images/process/upload",
headers={"X-API-Key": SC_KEY},
files={"file": ("input.jpg", image, "image/jpeg")},
data={"destinations": json.dumps(destinations),
"options": json.dumps({"format": "jpg", "quality": 88})},
timeout=120,
)
r.raise_for_status()
return r.json()["outputs"]
def reply(to, outputs):
for out in outputs:
r = requests.post(
f"https://graph.facebook.com/{VERSION}/{WA_PHONE_ID}/messages",
headers={"Authorization": f"Bearer {WA_TOKEN}"},
json={"messaging_product": "whatsapp", "to": to, "type": "image",
"image": {"link": out["url"],
"caption": f"{out['platform']} {out['format']} · {out['width']}x{out['height']}"}},
timeout=60,
)
r.raise_for_status()
if r.json().get("error"):
print("Meta rejected the send:", r.json()["error"])
def on_message(msg):
to = msg["from"]
if "image" in msg:
image = download_media(msg["image"]["id"])
elif "text" in msg: # link pasted into the chat
image = requests.get(msg["text"]["body"].strip(), timeout=60).content
else:
return
destinations = COMMANDS.get(msg.get("button", {}).get("text", "ig"), COMMANDS["ig"])
reply(to, generate(image, destinations))
Cap the number of destinations per message: every destination is 1 use, and a reply with five formats multiplies the bill. For links, validate that they are http/https and from a domain you trust before downloading them.
Typical errors
| Code or symptom | Cause | Fix |
|---|---|---|
131047 (re-engagement) | The 24-hour window closed | Send an approved template, not a free-form image |
190 | Invalid or expired token | Use a system user token and rotate it |
100 mentioning “media” | Media id expired (7 days) or the temporary URL passed 5 minutes | Request a fresh URL and download immediately |
| Image never shows in the chat | Meta cannot download the link | Check HTTPS, public access with no authentication, and no shortener |
413 from SocialCutter | File over 5 MB | Recompress before uploading; the limit is per image |
401 from SocialCutter | Missing or revoked sc_ key | Check X-API-Key; only one active key per account |
| Webhook stays unverified | Wrong hub.verify_token | Repeat the verification with the right token and return the hub.challenge as plain text |
Privacy
SocialCutter outputs are served as public URLs without authentication: anyone with the link can open the image, so treat them as public material and drop or replace anything you do not want circulating. Images sent by the user are downloaded from Meta and travel to the SocialCutter API to generate the crops. Meta applies its own media retention policy; review it before processing customer photos. Tell users in the chat itself that the image is processed by an external service.
Cost
- 1 use per destination (platform and format) per image; repeated destinations are not charged twice.
- Failed processings are refunded.
- Every plan includes API and MCP: Free 3 uses/day, Basic 10, Pro 30, Agency 100.
Next steps
- Automation: Automate image resizing with n8n
- Python: Process images with the SocialCutter API from Python
- Terminal: Process images with the API from the terminal (curl)
- Agents: Use SocialCutter from your LLM or editor with MCP
- Documentation: https://docs.socialcutter.theboomer.dev
Frequently asked questions
Does SocialCutter publish to social networks?
No. SocialCutter generates each format with a centered crop and returns one public URL per output. Sending the image back to the user over WhatsApp is your bot's job with the Cloud API; publishing to a social network is a separate step with that platform's API.
How do I receive the images users send?
Meta's webhook delivers an image message with a media id. You then request the temporary URL with GET /{media-id}, which only lives 5 minutes and requires the token on the download request.
Can I reply with the image itself?
Yes, with an image message whose image.link points at the public SocialCutter URL. Meta downloads the file, so the URL must be HTTPS, publicly reachable and not a shortener; image.id also works if you upload the file to Meta's media endpoint first.
Why does Meta reject my reply?
Most often the 24-hour customer service window has closed: outside it you can only send approved templates. Other causes are a link Meta cannot download or an expired token.
What does processing one image cost?
1 use per destination, that is, per platform and format combination. Instagram post plus LinkedIn post for the same image is 2 uses, and repeated destinations in the same request are not charged twice.