---
title: "Instagram WebSocket"
canonical_url: "https://tweetstream.io/docs/instagram-websocket"
last_updated: 2026-10-14
---

# 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.

Canonical URL: https://tweetstream.io/docs/instagram-websocket

## 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

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

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

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

### Story with an estimated publish time

```json
{
  "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
  }
}
```

### Kind not established

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

### Authentication ping

```json
{
  "op": "auth_ping"
}
```

### Authentication pong

```json
{
  "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

```typescript
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.
