TwitterAPIs Docs
API ReferenceMonitoring

Twitter Webhook Payload: Media, Images and Video

The exact JSON body a monitor POSTs to your webhook when a watched account posts, the signature headers that come with it, and how media (photos, video, animated GIFs) is represented.

When a monitor sees a new post from a handle you are watching, it POSTs a JSON body to every webhook that monitor is pointed at. This page is the shape of that body.

Envelope

{
  "event": "tweet.created",
  "monitor_id": "mon_4b1e7a",
  "handle": "jack",
  "tweet": { }
}
FieldTypeDescription
eventstringAlways tweet.created for a real delivery. A test send from POST /webhook/{id}/test uses webhook.test instead and carries no tweet.
monitor_idstringThe monitor that matched.
handlestringThe normalized handle being watched, lowercased and without the @.
tweetobjectThe full tweet, the same object the read endpoints return.

Headers

Every delivery to your own endpoint carries:

HeaderMeaning
x-twitterapis-eventtweet.created
x-twitterapis-delivery-idUnique per HTTP attempt. Changes on every retry.
x-twitterapis-event-idStable per event. The same across all retries of one tweet, so use this one to deduplicate.
x-twitterapis-attemptAttempt number, starting at 1.
x-twitterapis-signatureHMAC-SHA256 over the timestamp and the raw body, keyed with the secret shown once at webhook creation.

Media: photos, video and GIFs

Media is included. When the post carries any, the tweet object has both of these, holding the same array:

  • tweet.extended_entities.media, the standard Twitter location.
  • tweet.media, a top-level alias, so code written against either accessor works.

A text-only post has neither key at all, rather than an empty array, so test for presence instead of assuming it is there.

A photo entry:

{
  "type": "photo",
  "media_url_https": "https://pbs.twimg.com/media/GxxxxxxWkAAxxxx.jpg",
  "url": "https://t.co/abc123",
  "ext_alt_text": "Alt text, present only when the author wrote one"
}

Video and animated GIF entries carry everything above plus video_info:

{
  "type": "video",
  "media_url_https": "https://pbs.twimg.com/ext_tw_video_thumb/.../img/xxxx.jpg",
  "url": "https://t.co/abc123",
  "video_info": {
    "duration_millis": 30066,
    "variants": [
      { "content_type": "video/mp4", "url": "https://video.twimg.com/.../720x1280/xxx.mp4", "bitrate": 2176000 },
      { "content_type": "video/mp4", "url": "https://video.twimg.com/.../480x852/xxx.mp4", "bitrate": 832000 }
    ]
  }
}

type is photo, video or animated_gif. For video and animated_gif, media_url_https is the poster frame, not the video: pick a playable file from video_info.variants, usually the highest bitrate your bandwidth allows. Photos have no video_info.

Two things to build around

  1. We send URLs, not bytes. Nothing about the delivery is binary. If you need to keep a copy of an image or a video, fetch it from the URL on your side.
  2. Treat the URLs as untrusted input. They are passed through from X. They are normally pbs.twimg.com or video.twimg.com, but validate the host before you fetch or render one.

A worked handler:

def handle(payload):
    tweet = payload["tweet"]
    for m in tweet.get("media", []):
        if m["type"] == "photo":
            download(m["media_url_https"])
        else:
            best = max(
                (v for v in m["video_info"]["variants"] if v["content_type"] == "video/mp4"),
                key=lambda v: v.get("bitrate", 0),
            )
            download(best["url"])

Slack and Discord destinations

If the webhook URL you registered is a Slack incoming webhook (hooks.slack.com) or a Discord one (discord.com/api/webhooks), we detect that from the URL and send that platform's own message format instead of the envelope above, so the message renders as a real Slack or Discord post rather than a wall of JSON.

Both carry the author, the post text, a link to the post, and the post's images:

  • Slack gets one image block per attachment, up to the four X allows, using the author's alt text where they wrote one.
  • Discord gets the first attachment as the embed image, plus a Media field naming the total when the post carried more than one, because a Discord embed holds exactly one image.

For a video or an animated GIF, both show the poster frame and link through to X for playback.

Two limits are worth knowing. Neither platform verifies our HMAC, so the signature headers are omitted for those two destinations. And an attachment whose URL is not https on an X media host is dropped from the rendering rather than passed through, since we are the ones putting it in your channel. Point the monitor at an endpoint you control if you want the full payload, video_info.variants included.

On this page