Skip to docs content

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.

Set up Instagram Tracking
Open in chat
Reviewed against product code on October 14, 2026

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.

SettingValueNotes
Endpointwss://insta-ws.tweetstream.io/wsSeparate from the X feed endpoints
Subprotocoltweetstream.instagram.v1Required when you send any subprotocol, the auth token included. Other subprotocols get HTTP 400
Auth protocoltweetstream.auth.token.<API_KEY>Your existing TweetStream API key
// 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.

ContextHeader or protocolNotes
WebSockettweetstream.instagram.v1 + tweetstream.auth.token.<API_KEY>Preferred
WebSocket fallbackAuthorization: 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.

FieldTypeMeaning
v1Envelope version
tinstagram or controlChannel
opcontent, update, or auth_pongOperation
idstringThe media ID. An update event reuses the ID of the content event it replaces
tsnumberWhen TweetStream sent the frame, in Unix milliseconds
dobjectPayload 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.

KindMeaningURL shape
postA feed post with one image or videoinstagram.com/p/<shortcode>/
reelA reelinstagram.com/reel/<shortcode>/
carouselA feed post with several images or videosinstagram.com/p/<shortcode>/
storyA storyinstagram.com/stories/<username>/<id>/
unknownThe kind was not established. The event still carries the ID, author, URL, and any media found, so treat it as new content from that accountinstagram.com/p/<shortcode>/, or the author's profile when no shortcode is known

Media order and covers

  • index is the item's position in media, starting at 0. media follows the post's slide order when that order is known. In a carousel that mixes photos and videos, the photos may come first.
  • cover: true marks an image that is a video's cover. Other items omit the field.
  • type is image, video, or unknown when the type was not established.
  • media can be empty or list fewer items than the post has. A later update may bring the rest. Open url for the full post.
  • Media URLs can expire. Download the files you need soon after the event arrives.

Timestamps

FieldTypeMeaning
createdAtnumber or nullPublish time in Unix milliseconds. null when it is not known
createdAtEstimatedbooleantrue when createdAt is an estimate
receivedAtnumberWhen TweetStream received the content, in Unix milliseconds
latetrue, or absenttrue 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.

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.

{
  "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.

MethodPathResult
GET/api/instagram/accountsYour accounts, each live, pending, or paused, and your limit
POST/api/instagram/add-accountAdds accounts. Over your limit they come back as capacity
DELETE/api/instagram/remove-accountRemoves accounts and lists them in removed
const 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.