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": { }
}| Field | Type | Description |
|---|---|---|
event | string | Always tweet.created for a real delivery. A test send from POST /webhook/{id}/test uses webhook.test instead and carries no tweet. |
monitor_id | string | The monitor that matched. |
handle | string | The normalized handle being watched, lowercased and without the @. |
tweet | object | The full tweet, the same object the read endpoints return. |
Headers
Every delivery to your own endpoint carries:
| Header | Meaning |
|---|---|
x-twitterapis-event | tweet.created |
x-twitterapis-delivery-id | Unique per HTTP attempt. Changes on every retry. |
x-twitterapis-event-id | Stable per event. The same across all retries of one tweet, so use this one to deduplicate. |
x-twitterapis-attempt | Attempt number, starting at 1. |
x-twitterapis-signature | HMAC-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
- 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.
- Treat the URLs as untrusted input. They are passed through from X. They are normally
pbs.twimg.comorvideo.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
Mediafield 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.
Related
Delete Webhook DELETE
Soft-deletes the webhook: it stops receiving deliveries immediately and disappears from List Webhooks, but delivery history referencing it is retained rather than cascade-deleted. Cost: Free per call.
Overview
Spaces: Read an X Space by id: title, host, speakers, topics, timing and audience counts, for a Space that is scheduled, running or already ended.