snapcap

snapcap — Web API recon

- Snap's mobile anti-fraud (Argos + Play Integrity + device fingerprinting) blocks every emulator.

Pivot: April 2026. Mobile/redroid/Frida approach abandoned after exhaustive validation. Web-Snap (web.snapchat.com) is the new target. This document captures everything we learned during recon so the Node bridge implementation has a complete spec to build against.

Why we pivoted

  • Snap's mobile anti-fraud (Argos + Play Integrity + device fingerprinting) blocks every emulator. Verified across BlueStacks, NoxPlayer, MEmu, LDPlayer, AVD with Google Play, Waydroid, redroid, Anbox, Cuttlefish — all fail.
  • The "device-trust" check sits in front of normal authentication. The "Due to repeated failed attempts or other unusual activity, your access to Snapchat is temporarily disabled" message is universal — same string Waydroid users get, same string custom-ROM users get.
  • Web Snap (web.snapchat.com) uses a different anti-fraud surface (browser fingerprint, JA3) but logged in immediately on first try with the same burner account that the mobile container couldn't use.
  • v1 product positioning therefore: Node API server that calls Snap's web gRPC-Web endpoints directly, no browser at runtime. Browser used only for one-time recon/login.

v1 scope

User goals:

  1. Post photo/video to MY_STORY
  2. Add friends
  3. Send messages with photos/videos to friends
  4. Receive photos/videos — deferred to v3/v4 (requires Fidelius E2E decryption work)

All v1 operations are server-mediated: keys travel in the protobuf body in plaintext to the server. No Fidelius E2E required for sends. Pure Node fetch + protobuf is sufficient.

Architecture overview

┌─────────────────┐
│ Node bridge     │  fetch + ws — pure Node, no browser at runtime
│ (snapcap server)│
└────────┬────────┘
         │ HTTPS / gRPC-Web (HTTP/2 or HTTP/3)
         │ WebSocket (real-time push, deferred for v1)

         ├─→ accounts.snapchat.com    (login, sets __Host-sc-a-auth-session cookie)
         ├─→ session.snapchat.com     (WebAttestationService — once per session)
         ├─→ web.snapchat.com         (gRPC-Web API surface — main API host)
         ├─→ cf-st.sc-cdn.net         (CDN PUT for media uploads, AWS-signed URLs)
         └─→ aws.duplex.snapchat.com  (WebSocket for real-time push, deferred)

Auth model

Two-layer OAuth-like:

TokenFormatLifetimeWhere to send
__Host-sc-a-auth-session cookiebase64-encoded protobuf-ish, prefix hCgwKCj...Long-lived (days)Cookie on accounts.snapchat.com and web.snapchat.com
Bearer access tokenbase64-encoded protobuf-ish, prefix hCgwKCj...Short-livedAuthorization: Bearer <token> header on gRPC-Web POSTs

Important: the cookie and Bearer have the same prefix (hCgwKCj) but different payloads. They are NOT the same value — the Bearer is derived from the cookie via a session-bootstrap flow.

Login flow (one-time, captured manually via real Chrome)

1.  GET  https://accounts.snapchat.com/accounts/sso?client_id=web-calling-corp--prod
        → 303 redirect, Set-Cookie: __Host-sc-a-auth-session=<value>; HttpOnly; Secure
        (the actual login form submission lives upstream of this; the cookie carries the session)

2.  POST https://session.snapchat.com/snap.security.WebAttestationService/BootstrapAttestationSession
        body: 352 bytes protobuf (gRPC-Web framing)
        → establishes session attestation

3.  POST https://web.snapchat.com/snapchat.fidelius.FideliusIdentityService/InitializeWebKey
        body: 162 bytes protobuf
        → exchanges public keys; sets up Fidelius E2E identity material in localStorage

4.  POST https://web.snapchat.com/web-chat-session/refresh?client_id=web-calling-corp--prod
        body: 0 bytes (heartbeat)
        Authorization: Bearer <token>
        cookie: __Host-sc-a-auth-session=<value>
        → keeps the Bearer fresh; can be polled periodically

For v1 bridge: simplest auth bootstrap

For the Node bridge, we sidestep the login dance entirely:

  1. Operator logs into web.snapchat.com once via real Chrome
  2. Operator extracts the __Host-sc-a-auth-session cookie value (via DevTools or our extraction script)
  3. Operator pastes cookie into bridge config
  4. Bridge uses cookie to call web-chat-session/refresh, gets Bearer
  5. Bridge uses Bearer for all subsequent gRPC-Web calls
  6. Bridge auto-refreshes Bearer when needed

Eventually we may automate login (POST credentials to accounts.snapchat.com form endpoint) but for v1 manual cookie extraction is fine.

API surface — verified endpoints

All POST, all application/grpc-web+proto, all to web.snapchat.com unless noted.

Messaging

EndpointPurposeNotes
messagingcoreservice.MessagingCoreService/CreateContentMessageSend anything — text DM, media DM, story, group msgRecipient field determines target; see schema below
messagingcoreservice.MessagingCoreService/UpdateContentMessageEdit/react to a message
messagingcoreservice.MessagingCoreService/UpdateConversationMute, archive, etc.
messagingcoreservice.MessagingCoreService/SendTypingNotificationTyping indicator
messagingcoreservice.MessagingCoreService/QueryMessagesFetch message history
messagingcoreservice.MessagingCoreService/QueryConversationsFetch conversation list
messagingcoreservice.MessagingCoreService/SyncConversationsRefresh conversations
messagingcoreservice.MessagingCoreService/DeltaSyncIncremental message sync
messagingcoreservice.MessagingCoreService/BatchDeltaSyncBulk sync after reconnect
messagingcoreservice.MessagingCoreService/GetGroupsGroup conversation metadata

Friends / social

EndpointPurpose
snapchat.friending.server.FriendAction/AddFriendsAdd a friend by username/ID
snapchat.friending.server.FriendRequests/IncomingFriendSyncPending incoming friend requests
com.snapchat.atlas.gw.AtlasGw/SyncFriendDataFriend list sync
com.snapchat.atlas.gw.AtlasGw/GetSnapchatterPublicInfoPublic profile lookup

Search / discovery

EndpointPurpose
web.snapchat.com/search/searchSearch users/conversations
snapchat.lists.api.Lists/FetchListsFetch lists (probably friend categories)

Media

EndpointPurpose
snapchat.content.v2.MediaDeliveryService/getUploadLocationsGet a pre-signed AWS-style CDN URL to PUT media to
PUT cf-st.sc-cdn.net/d/<id>?x-amz-...Upload encrypted media bytes (uses AWS Signature V4 in query string; no Authorization header needed)

Other

EndpointPurpose
web-chat-session/refreshRefresh Bearer token / heartbeat
snapchat.cdp.cof.CircumstancesService/targetingQueryFeature flags
com.snapchat.deltaforce.external.DeltaForce/DeltaSyncLong-poll style real-time fallback (alternative to WebSocket)

WebSocket (real-time push)

URL:                wss://aws.duplex.snapchat.com/snapchat.gateway.Gateway/WebSocketConnect
Origin:             https://www.snapchat.com
Sec-WebSocket-Protocol: snap-ws-auth, <Bearer token>

The Bearer token rides the Sec-WebSocket-Protocol subprotocol field — on upgrade, server returns Sec-WebSocket-Protocol: snap-ws-auth to confirm. Status 101 on success.

WebSocket frames captured in the login HAR (webSocketMessages field). Frame format not yet decoded — deferred to receive-implementation phase (v3+).

CreateContentMessage protobuf schema (decoded)

This is the most important schema — handles stories, DMs (text + media), and group messages.

Wire framing

Every gRPC-Web POST body is framed:

byte 0:           compression flag (always 0x00 in observed traffic)
bytes 1-4:        big-endian uint32 = length of protobuf payload
bytes 5..N+5:     the protobuf payload itself

Request schema

message CreateContentMessageRequest {
  Uuid          conversation_id    = 1;  // Uuid wraps a 16-byte UUID
  uint64        client_message_id  = 2;  // unique per send (snowflake-ish)
  Recipients    recipients         = 3;
  Content       content            = 4;
  // 5 reserved
  Sender        sender             = 6;
  // 7 reserved
  ContextId     context            = 8;  // 16-byte UUID identifying client/session
}

message Uuid {
  bytes uuid = 1;  // exactly 16 bytes
}

message Recipients {
  // field 2 (or sometimes 1) wraps each recipient
  Recipient r = 2;  // (multiple = multi-recipient broadcast)
}

message Recipient {
  Uuid          target_id          = 1;  // ← critical:
                                          //   Stories:   01010101010101010101010101010101 (all 0x01)
                                          //   DMs:       <friend's conversation UUID>
                                          //   Groups:    <group conversation UUID>
  RecipientMeta meta               = 2;  // optional
}

message Content {
  // text-only DM:  field 4 is empty, separate text field used (TBD — need to capture text-only DM)
  // media DM/story: field 4 contains MediaContent
  MediaContent  media              = 4;
  UploadRef     upload             = 5;
  // 6 reserved (empty bytes)
  uint32        type               = 7;  // 2 = media DM, 3 = story (observed values)
  // 9 reserved
}

message MediaContent {
  MediaItem item = 11;  // wrapper
}

message MediaItem {
  uint32          content_type     = 8;   // 2 = image (other types: video, audio TBD)
  QualityHint     quality          = 4;
  MediaDescriptor descriptor       = 5;
  Attribution     attribution      = 13;  // referrer URL, render hints
  Timestamp       timestamp        = 17;  // unix ms
  // 22: { 1: varint 7 } — purpose unclear
}

message MediaDescriptor {
  MediaInfo info = 1;
}

message MediaInfo {
  Dimensions       dims              = 5;  // { width: int, height: int }
  // 18: bytes (empty observed)
  EncryptionKeys   keys              = 4;
  ExtraCrypto      extra             = 19;
  uint32           version           = 20; // observed: 3
  uint32           flag              = 22; // observed: 1
}

message Dimensions {
  uint32 width  = 1;
  uint32 height = 2;
}

message EncryptionKeys {
  string aes_key_b64 = 1;  // 32 bytes (AES-256), base64 — 44 chars
  string iv_b64      = 2;  // 16 bytes (AES IV),   base64 — 24 chars
}

message ExtraCrypto {
  bytes hash_or_key = 1;  // 32 bytes
  bytes nonce       = 2;  // 16 bytes
}

message UploadRef {
  // wraps reference to the CDN PUT we just did
  message Inner {
    string upload_id_full   = 1;  // e.g. "CX1poQxotdvVfv2Cthh7z_1"
    UploadMeta meta         = 2;
  }
  Inner inner = 1;  // (nesting omitted in protobuf wire — use same shape)
}

message UploadMeta {
  string upload_id_base   = 2;  // matches CDN URL path: cf-st.sc-cdn.net/d/<base>
  bytes flag              = 6;  // observed: 0x04
  uint32 a                = 9;
  uint32 b                = 10;
  uint32 c                = 12;
}

message Sender {
  SenderInfo info = 1;
}

message SenderInfo {
  string username_session = 1;  // "<username>~<session-uuid>", e.g. "perdyjamie~d03d8e41-68c7-..."
  // field 12 has a fixed32 — purpose TBD
}

message ContextId {
  Uuid id = 1;  // 16-byte UUID
}

Response schema

message CreateContentMessageResponse {
  uint64    client_message_id_echo = 1;  // matches request field 2
  Result    result                 = 2;
}

message Result {
  Uuid    target_id_echo = 1;  // echoes request recipient
  uint32  status         = 2;  // 1 = success
  PublishedRef published = 13;
}

message PublishedRef {
  Uuid    target_id_echo = 1;
  string  published_id   = 2;  // server-issued story/message ID, e.g. "Unvi_6rsRiKcaH..."
}

Magic constants

MY_STORY recipient UUID = 0x01010101010101010101010101010101 (16 bytes of 0x01)

The 3-call story / media-DM flow

1. POST  web.snapchat.com/snapchat.content.v2.MediaDeliveryService/getUploadLocations
   body: ~15 bytes protobuf (probably {content_type: image, size_estimate: ...})
   → returns:  pre-signed AWS URL to PUT to + upload_id

2. PUT   cf-st.sc-cdn.net/d/<upload_id>?x-amz-acl=public-read
                                       &X-Amz-Algorithm=AWS4-HMAC-SHA256
                                       &X-Amz-Date=<date>
                                       &<more aws sig v4 params>
   body: AES-256-CBC-encrypted media bytes
   → 200 OK (no Authorization header needed; URL is pre-signed)

3. POST  web.snapchat.com/messagingcoreservice.MessagingCoreService/CreateContentMessage
   Authorization: Bearer <token>
   body: protobuf with
         - recipient = MY_STORY UUID for stories, friend conv UUID for DMs
         - encryption_keys = {AES key, IV} we used in step 2 (PLAINTEXT in body — server has them)
         - upload_ref = upload_id from step 1
         - dimensions = (width, height)
         - timestamp_ms
   → response: { client_msg_id_echo, status, published_id }

Total round trip: ~500 ms in the captured story-post.

Media encryption (AES-256-CBC)

Per-message random key + IV, generated client-side, sent in plaintext within the gRPC body to Snap's server. Server has the keys. Decryption happens server-side or proxied to recipients.

// Pseudocode for the bridge
import { randomBytes, createCipheriv } from 'crypto';
const aesKey = randomBytes(32);                       // AES-256
const iv     = randomBytes(16);                       // CBC IV
const cipher = createCipheriv('aes-256-cbc', aesKey, iv);
const encryptedMedia = Buffer.concat([cipher.update(mediaBytes), cipher.final()]);
// → PUT encryptedMedia to CDN URL
// → Send aesKey.toString('base64') + iv.toString('base64') in the protobuf body

The ExtraCrypto message (field 19) also has 32-byte + 16-byte fields whose purpose is unclear. They're random per-message in observed traffic — possibly an HMAC or a second key for a separate cipher mode. TODO on first implementation: try sending a story with random ExtraCrypto and see if it goes through. If not, dig deeper.

Telemetry / non-essential endpoints (skip these in bridge)

These appear in HARs but the bridge doesn't need them:

  • web.snapchat.com/graphene/web — telemetry pipeline
  • web.snapchat.com/web-blizzard/web — Blizzard analytics
  • gcp.api.snapchat.com/web/metrics — metrics
  • sentry.sc-prod.net/api/158/... — error reporting
  • api-kit.snapchat.com/com.snap.camerakit.v3.Metrics/SetOperationalMetrics — Camera Kit metrics
  • */SetBusinessEvents
  • cf-st.sc-cdn.net/3d/render — 3D render assets (Bitmoji, lenses)

Recon artifacts saved in this repo

FilePurpose
recon-bin/www.snapchat.com.harFirst HAR (sanitized — cookies stripped). Use unsanitized version below for replay specs.
recon-bin/www.snapchat.com.unsanitized.harSteady-state HAR with everything: send media DM, sync friends, etc. (~18 MB)
recon-bin/www.snapchat.com.unsanitized.login.harFull login flow capture (~67 MB)
recon-bin/www.snapchat.com.unsanitized.post_to_story.harJust the 3-call story post (~786 KB) — minimal example
recon-bin/story-create-content-message.req.binDecoded protobuf payload of the story-publish CreateContentMessage
recon-bin/story-create-content-message.resp.binDecoded protobuf payload of the story-publish response
recon-bin/proto-decoder.pyPython raw-protobuf decoder used to reverse the schemas
recon-bin/har-summarize.pyHAR analysis script — endpoint catalog, body extraction

Open questions / TODO before/during build

  1. Text-only DM body shape — we captured media DMs and the story, but not a plain-text DM. First recon task on day 1 of build: send a text DM during a fresh capture and decode its body. Probably field 4 has a TextContent instead of MediaContent.
  2. ExtraCrypto (MediaInfo field 19) — purpose of the 32+16 byte blobs unclear. May be HMAC or second cipher. Test by sending without and observing failures.
  3. MediaDeliveryService/getUploadLocations request body shape (15 bytes only — should decode quickly).
  4. WebSocket frame format — deferred to v3 receive work, but worth one quick decode of one frame for posterity.
  5. Bearer token expiration — what error does an expired token return? Probably 401 with grpc-status header. Test in development.
  6. Token refresh response/web-chat-session/refresh returned 200 with empty body and no Set-Cookie in our capture. Either: (a) it just keeps the same token alive server-side, (b) the new token comes via a different mechanism (Set-Cookie on a different domain?). Need to test in development what happens when the original token actually expires.

Build plan (4 days to v1)

DayTask
1Node project scaffold; gRPC-Web framing helper; generic protobuf encode/decode (use protobufjs or hand-rolled — schemas are small enough); cookie/Bearer session loader; web-chat-session/refresh heartbeat
2Implement CreateContentMessage for stories: AES-CBC media encrypt, getUploadLocations, PUT to CDN, POST CreateContentMessage with MY_STORY recipient. Validate end-to-end with real account.
3Same path for media DMs (just swap recipient UUID); AddFriends; AtlasGw/SyncFriendData for friend list; search/search
4Wire into the existing clients/typescript/ API surface (SnapcapClient.postStory, sendMessage, addFriend, listFriends); error handling; first integration test

2026-04-27 post-login recon — the live endpoint catalog

After the SDK's first successful login, we dumped 25 seconds of activity from the chat client at https://www.snapchat.com/web (logged in as perdyjamie). 335 outgoing requests captured; 61 carry Authorization: Bearer …. Sanitized JSON dump: recon-bin/post-login-endpoints.json.

Authenticated gRPC-Web endpoints (host: web.snapchat.com)

All are POST with content-type: application/grpc-web+proto, x-grpc-web: 1, referer: https://www.snapchat.com/.

Identity / friends

  • com.snapchat.atlas.gw.AtlasGw/GetSnapchatterPublicInfo — public info for one or more user IDs (probably how the chat client gets your own avatar + display name; pass your own snapUserId)
  • com.snapchat.atlas.gw.AtlasGw/SyncFriendData — friend list + statuses
  • snapchat.friending.server.FriendRequests/IncomingFriendSync — pending friend requests

Stories

  • df-mixer-prod/soma/batch_stories — friend stories (the for-friends feed)
  • df-spotlight-prod/batch_stories — spotlight stories (TikTok-equivalent)
  • context/spotlight — spotlight context/feed selection

Messaging

  • messagingcoreservice.MessagingCoreService/QueryConversations — list conversations
  • messagingcoreservice.MessagingCoreService/SyncConversations — sync
  • messagingcoreservice.MessagingCoreService/DeltaSync — delta updates (per conversation)
  • messagingcoreservice.MessagingCoreService/BatchDeltaSync — batch the above
  • messagingcoreservice.MessagingCoreService/GetGroups — group chats
  • messagingcoreservice.MessagingCoreService/BatchUpdateContentMessages — bulk message update (read receipts, edits)

Auth refresh

  • accounts.snapchat.com/web-chat-session/refresh?client_id=web-calling-corp--prod — bearer token refresh. Issued by the chat client periodically; uses the cookie to issue a fresh Bearer.

Crypto / Fidelius

  • snapchat.fidelius.FideliusIdentityService/InitializeWebKey — Fidelius E2E key init (relevant only to RECEIVE; can skip for v1 send-only flows)

CameraKit / Lenses

  • com.snap.camerakit.v3.Lenses/GetGroup — fetch lens groups
  • com.snap.camerakit.v3.Metrics/GetInitializationConfig, SetOperationalMetrics — lens telemetry (skip)

Read receipts

  • web.snapchat.com/readreceipt-server/viewhistory

Targeting / feature flags

  • snapchat.cdp.cof.CircumstancesService/targetingQuery — feature gate / experiment targeting

Unauthenticated/CDN endpoints

  • cf-st.sc-cdn.net/* — 237 hits; story media + thumbnails (AWS-signed URLs, no auth on these)
  • bolt-gcdn.sc-cdn.net/* — 7 hits; spotlight video CDN
  • static.snapchat.com/accounts/_next/* — Next.js bundle
  • www.snapchat.com/web/version.json?… — version probe

Endpoints to skip

  • web-blizzard/web/send — analytics (skipped per existing convention)
  • gcp.api.snapchat.com/* — telemetry
  • sentry.sc-prod.net/* — error reporting

Required headers (mimic the chat client when bypassing Playwright)

content-type: application/grpc-web+proto
x-grpc-web: 1
authorization: Bearer <bearer>
referer: https://www.snapchat.com/
x-snap-client-user-agent: SnapchatWeb/13.79.0 PROD (linux 0.0.0; chromium 147.0.7727.15)
sec-ch-ua: "Chromium";v="147", "Not.A/Brand";v="8"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Linux"

The x-snap-client-user-agent value should match the OS+browser we present elsewhere — the chat client builds it from the actual platform. For our SDK, derive from the SnapFingerprint.platform field.


2026-04-27 finding: text DMs are Fidelius-encrypted

Captured a real text-DM CreateContentMessage POST body (recon-bin/text-dm-create-content-message.req.bin, 418 bytes including 5-byte gRPC-Web frame header). Decoded structure:

1: msg                                            # recipient
  1: bytes(16) <recipient snap_user_id>
2: varint <message timestamp/snowflake>
3: msg                                            # sender + saved-message-references
  1: msg
    1: msg
      1: bytes(16) <sender snap_user_id>
    2: varint 30
  99: msg                                         # ~looks like saved-message refs
    5: msg
      1: msg(43) {1: bytes(5) … 2: varint 10 3: bytes(32) …}
      …(repeats per saved entry)
4: msg                                            # ENCRYPTED CONTENT
  2: varint 1
  3: msg                                          # crypto envelope (ECIES-ish)
    5: msg
      1: bytes(12) <AES-GCM nonce / IV>
      2: bytes(16) <wrapping or auth tag>
      3: bytes(33) <compressed-EC public key>
      4: varint 10
  4: bytes(64) <encrypted message body>           # the text, not in plaintext
  7: varint 2
8: msg
  1: msg
    1: bytes(16) <message UUID>

Implication for v1: the open question in the original recon ("text-only DM body shape") resolves not to "different field 4 layout for text content" but to "field 4 holds an end-to-end-encrypted blob produced by Fidelius." Text DMs ride the same E2E system Snap reserved for receive — so sending and receiving text are blocked by the same wasm crypto worker (messaging-wasm-worker).

This means the v1 pragmatic order is:

  1. Stories — server-mediated, schema fully captured. Easy first write.
  2. Media DMs — server-mediated per existing recon (AES key in plaintext within the protobuf body). Adds the 3-call upload flow but no Fidelius.
  3. Friend operations + search — gRPC-Web reads, no E2E.
  4. Text DMs and any receive — deferred to v3+, requires reverse-engineering Fidelius.

Snap user UUIDs observed

  • perdyjamie (sender): 8fee42df-e549-5727-a893-034382ccab89
  • Jamie Nichols (operator's real account): 527be2ff-aaec-4622-9c68-79d200b8bdc1

Useful for testing — these are stable per-account.

On this page