Instagram WebSocket
Connect to wss://insta-ws.tweetstream.io/ws with your TweetStream API key to receive new posts, reels, carousels, and stories from the Instagram accounts on your watchlist.
Quick start
You need an active Instagram Tracking subscription or trial, at least one Instagram account on your dashboard watchlist, and your TweetStream API key. Run the example below. Instagram feed connected confirms authentication, and events print when a watched account publishes.
| Setting | Value | Notes |
|---|---|---|
| Endpoint | wss://insta-ws.tweetstream.io/ws | Separate from the X feed endpoints |
| Subprotocol | tweetstream.instagram.v1 | Required when you send any subprotocol, the auth token included. Other subprotocols get HTTP 400 |
| Auth protocol | tweetstream.auth.token.<API_KEY> | Your existing TweetStream API key |
Run the TypeScript smoke test
typescript// bun add ws
// TWEETSTREAM_API_KEY=ts_... bun run instagram.ts
import WebSocket from "ws";
const apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) {
throw new Error("Missing TWEETSTREAM_API_KEY");
}
const ws = new WebSocket("wss://insta-ws.tweetstream.io/ws", [
"tweetstream.instagram.v1",
`tweetstream.auth.token.${apiKey}`,
]);
ws.on("open", () => {
console.log("Instagram feed connected");
});
ws.on("message", (raw) => {
const event = JSON.parse(raw.toString());
if (event.t === "instagram" && (event.op === "content" || event.op === "update")) {
console.log(event.op, event.d.kind, event.d.author.username, event.d.url);
}
});
ws.on("unexpected-response", (_request, response) => {
console.error("Connection rejected", response.statusCode, response.statusMessage);
response.resume();
process.exitCode = 1;
});
ws.on("close", (code, reason) => {
console.log("Instagram feed closed", code, reason.toString());
});Authenticate
The Instagram WebSocket accepts the same API key and the same credential forms as the X feed. If you send the key as tweetstream.auth.token.<API_KEY>, also offer tweetstream.instagram.v1. Keep the key on your server and out of public browser code.
| Context | Header or protocol | Notes |
|---|---|---|
| WebSocket | tweetstream.instagram.v1 + tweetstream.auth.token.<API_KEY> | Preferred |
| WebSocket fallback | Authorization: Bearer <API_KEY> or ?apiKey=<API_KEY> | Use the query form only when your runtime cannot set headers |
Read the envelope
Every frame is one JSON envelope shaped like the X feed. Route by t, then op, and ignore operations you do not recognize without closing the socket.
| Field | Type | Meaning |
|---|---|---|
v | 1 | Envelope version |
t | instagram or control | Channel |
op | content, update, or auth_pong | Operation |
id | string | The media ID. An update event reuses the ID of the content event it replaces |
ts | number | When TweetStream sent the frame, in Unix milliseconds |
d | object | Payload for the operation |
Instagram envelope types
Show full definitions (72 lines)
type InstagramKind = 'post' | 'reel' | 'carousel' | 'story' | 'unknown';
type InstagramMedia = {
// Position in the media array, starting at 0.
index: number;
type: 'image' | 'video' | 'unknown';
url: string;
// Present only on an image that is a video's cover.
cover?: true;
};
type InstagramContent = {
// Instagram media ID; also the envelope id.
id: string;
// Present when known; the post URL uses it.
shortcode?: string;
kind: InstagramKind;
url: string;
author: {
id?: string;
// Lowercase, without a leading @.
username: string;
};
// Empty string when the post has no caption.
caption: string;
// May be empty or partial; see the media notes.
media: Array<InstagramMedia>;
// Publish time in Unix milliseconds, or null when it is not known.
createdAt: number | null;
// True when createdAt is an estimate.
createdAtEstimated: boolean;
// When TweetStream received the post, in Unix milliseconds.
receivedAt: number;
// Content events only. True when this post reached TweetStream well after its
// publish time; the post is still new to you. A missing flag does not mean
// the post is recent: when createdAt is set, compare it with receivedAt.
late?: true;
};
type InstagramContentEnvelope = {
v: 1;
t: 'instagram';
op: 'content';
id: string;
ts: number;
d: InstagramContent;
};
type InstagramUpdateEnvelope = {
v: 1;
t: 'instagram';
// Insert, or replace the stored event with the same id. Sent at most once per id.
op: 'update';
id: string;
ts: number;
// Never carries late: keep the value from the content event, or compare
// createdAt with receivedAt if you never got one.
d: Omit<InstagramContent, 'late'>;
};
type InstagramAuthPongEnvelope = {
v: 1;
t: 'control';
op: 'auth_pong';
ts: number;
d: Record<string, never>;
};
type InstagramEnvelope =
| InstagramContentEnvelope
| InstagramUpdateEnvelope
| InstagramAuthPongEnvelope;Content kinds
d.kind says what was published when it is known. d.url links to the content on Instagram, or to the author's profile when no shortcode is known.
| Kind | Meaning | URL shape |
|---|---|---|
post | A feed post with one image or video | instagram.com/p/<shortcode>/ |
reel | A reel | instagram.com/reel/<shortcode>/ |
carousel | A feed post with several images or videos | instagram.com/p/<shortcode>/ |
story | A story | instagram.com/stories/<username>/<id>/ |
unknown | The kind was not established. The event still carries the ID, author, URL, and any media found, so treat it as new content from that account | instagram.com/p/<shortcode>/, or the author's profile when no shortcode is known |
Media order and covers
indexis the item's position inmedia, starting at 0.mediafollows the post's slide order when that order is known. In a carousel that mixes photos and videos, the photos may come first.cover: truemarks an image that is a video's cover. Other items omit the field.typeisimage,video, orunknownwhen the type was not established.mediacan be empty or list fewer items than the post has. A laterupdatemay bring the rest. Openurlfor the full post.- Media URLs can expire. Download the files you need soon after the event arrives.
Timestamps
| Field | Type | Meaning |
|---|---|---|
createdAt | number or null | Publish time in Unix milliseconds. null when it is not known |
createdAtEstimated | boolean | true when createdAt is an estimate |
receivedAt | number | When TweetStream received the content, in Unix milliseconds |
late | true, or absent | true when this post reached TweetStream well after its publish time. The post is still new to you. Only content events carry it, never update. A missing late does not mean the post is recent: when createdAt is set, compare it with receivedAt |
Example events
Each example is a complete frame as it arrives on the socket.
Carousel
json{
"v": 1,
"t": "instagram",
"op": "content",
"id": "3988012345678901234",
"ts": 1772000001060,
"d": {
"id": "3988012345678901234",
"shortcode": "DdsPyK1GrTK",
"kind": "carousel",
"url": "https://www.instagram.com/p/DdsPyK1GrTK/",
"author": {
"id": "25025320",
"username": "examplebrand"
},
"caption": "Three frames from launch day",
"media": [
{
"index": 0,
"type": "image",
"url": "https://scontent-iad3-1.cdninstagram.com/v/1.jpg"
},
{
"index": 1,
"type": "video",
"url": "https://scontent-iad3-1.cdninstagram.com/v/2.mp4"
},
{
"index": 2,
"type": "image",
"url": "https://scontent-iad3-1.cdninstagram.com/v/2.jpg",
"cover": true
}
],
"createdAt": 1772000000000,
"createdAtEstimated": false,
"receivedAt": 1772000001050
}
}Reel
json{
"v": 1,
"t": "instagram",
"op": "content",
"id": "3987848477383929083",
"ts": 1772000001060,
"d": {
"id": "3987848477383929083",
"shortcode": "DdXrw_IjSz7",
"kind": "reel",
"url": "https://www.instagram.com/reel/DdXrw_IjSz7/",
"author": {
"id": "25025320",
"username": "examplebrand"
},
"caption": "New drop",
"media": [
{
"index": 0,
"type": "video",
"url": "https://scontent-iad3-1.cdninstagram.com/v/reel.mp4"
}
],
"createdAt": 1772000000000,
"createdAtEstimated": false,
"receivedAt": 1772000001050
}
}{
"v": 1,
"t": "instagram",
"op": "content",
"id": "3988000000000000001",
"ts": 1772000001060,
"d": {
"id": "3988000000000000001",
"kind": "story",
"url": "https://www.instagram.com/stories/examplebrand/3988000000000000001/",
"author": {
"id": "25025320",
"username": "examplebrand"
},
"caption": "",
"media": [
{
"index": 0,
"type": "image",
"url": "https://scontent-iad3-1.cdninstagram.com/v/story.jpg"
}
],
"createdAt": 1772000000000,
"createdAtEstimated": true,
"receivedAt": 1772000001050
}
}{
"v": 1,
"t": "instagram",
"op": "content",
"id": "3987848477383929083",
"ts": 1772000001060,
"d": {
"id": "3987848477383929083",
"shortcode": "DdXrw_IjSz7",
"kind": "unknown",
"url": "https://www.instagram.com/p/DdXrw_IjSz7/",
"author": {
"id": "25025320",
"username": "examplebrand"
},
"caption": "Kind not established",
"media": [
{
"index": 0,
"type": "unknown",
"url": "https://scontent-iad3-1.cdninstagram.com/v/first.jpg"
}
],
"createdAt": null,
"createdAtEstimated": false,
"receivedAt": 1772000001050
}
}Late post
json{
"v": 1,
"t": "instagram",
"op": "content",
"id": "3987848477383929083",
"ts": 1772000001060,
"d": {
"id": "3987848477383929083",
"shortcode": "DdXrw_IjSz7",
"kind": "post",
"url": "https://www.instagram.com/p/DdXrw_IjSz7/",
"author": {
"id": "25025320",
"username": "examplebrand"
},
"caption": "Behind the scenes",
"createdAt": 1771999101050,
"media": [
{
"type": "image",
"url": "https://scontent-iad3-1.cdninstagram.com/v/late.jpg",
"index": 0
}
],
"createdAtEstimated": false,
"late": true,
"receivedAt": 1772000001050
}
}Updates
An update event carries a complete d payload for its id. If you already stored an event with that id, replace it with the new one instead of merging fields. If you have not seen that id, for example because you connected after the first event, treat the update as new content. TweetStream sends at most one update per event, and only when it has a more complete version, for example one that adds media or a known kind. Updates arrive on the WebSocket only. Discord posts are not edited.
Carousel update
json{
"v": 1,
"t": "instagram",
"op": "update",
"id": "3988012345678901234",
"ts": 1772000001060,
"d": {
"id": "3988012345678901234",
"shortcode": "DdsPyK1GrTK",
"kind": "carousel",
"url": "https://www.instagram.com/p/DdsPyK1GrTK/",
"author": {
"id": "25025320",
"username": "examplebrand"
},
"caption": "Three frames from launch day",
"createdAt": 1772000000000,
"media": [
{
"type": "image",
"url": "https://scontent-iad3-1.cdninstagram.com/v/1.jpg",
"index": 0
},
{
"type": "video",
"url": "https://scontent-iad3-1.cdninstagram.com/v/2.mp4",
"index": 1
},
{
"type": "image",
"url": "https://scontent-iad3-1.cdninstagram.com/v/2.jpg",
"index": 2,
"cover": true
}
],
"createdAtEstimated": false,
"receivedAt": 1772000001050
}
}Check authentication
Send { "op": "auth_ping" } when your application needs an authentication check. A valid connection answers with control/auth_pong and an empty data object.
{
"op": "auth_ping"
}{
"v": 1,
"t": "control",
"op": "auth_pong",
"ts": 1772000001060,
"d": {}
}Manage accounts over HTTP
Use your API key to change the same watchlist the dashboard shows. Send accounts as one username or an array of up to 100, with or without @. A 207 means some accounts changed and others were rejected; rejected gives the reason for each one. Adding an account that is already on the list returns duplicate, and removing one that is not on it returns not_tracked. Neither counts as a failure, so retrying a request is safe.
| Method | Path | Result |
|---|---|---|
GET | /api/instagram/accounts | Your accounts, each live, pending, or paused, and your limit |
POST | /api/instagram/add-account | Adds accounts. Over your limit they come back as capacity |
DELETE | /api/instagram/remove-account | Removes accounts and lists them in removed |
Add Instagram accounts
typescriptconst apiKey = process.env.TWEETSTREAM_API_KEY;
if (!apiKey) throw new Error("Missing TWEETSTREAM_API_KEY");
const response = await fetch("https://api.tweetstream.io/api/instagram/add-account", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
accounts: ["nasa", "@natgeo"],
}),
});
if (!response.ok && response.status !== 207) {
const detail = await response.text();
throw new Error(`Add failed (${response.status}): ${detail}`);
}
const { accounts, limit, rejected } = await response.json();
console.log(`${accounts.length}/${limit} accounts`, rejected);Connections and accounts
- Each Instagram Tracking subscription includes 3 Instagram WebSocket connections, or 1 during the trial. They are separate from your X plan's connections.
- Add or remove Instagram accounts at any time, in the dashboard or over HTTP. Account changes never change your bill.
- Events can also go to a Discord channel. Set that up in the dashboard.