# 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.
- URL: https://socialcutter.theboomer.dev/en/guides/whatsapp/
- Idioma: en
- Familia: mensajeria
- Actualizado: 2026-09-24
- Palabras clave: WhatsApp, 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 |

```bash
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

1. **A message arrives.** Meta POSTs to your webhook: `messages[0].image.id` for a photo, or `messages[0].text.body` for text with a link. Answer 200 within a few seconds and do the work in the background.
2. **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.
3. **Generate the formats.** The downloaded file goes to `POST /api/v1/images/process/upload` (multipart, 5 MB maximum) with `destinations` and `options`.
4. **Reply.** An `image` message with `image.link` pointing 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:

```bash
# 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 "file=@incoming.jpg;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`:

```python
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](/en/guides/n8n/)
- Python: [Process images with the SocialCutter API from Python](/en/guides/python/)
- Terminal: [Process images with the API from the terminal (curl)](/en/guides/curl/)
- Agents: [Use SocialCutter from your LLM or editor with MCP](/en/guides/mcp/)
- Documentation: https://docs.socialcutter.theboomer.dev