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:
- Post photo/video to MY_STORY
- Add friends
- Send messages with photos/videos to friends
- 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:
| Token | Format | Lifetime | Where to send |
|---|---|---|---|
__Host-sc-a-auth-session cookie | base64-encoded protobuf-ish, prefix hCgwKCj... | Long-lived (days) | Cookie on accounts.snapchat.com and web.snapchat.com |
| Bearer access token | base64-encoded protobuf-ish, prefix hCgwKCj... | Short-lived | Authorization: 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 periodicallyFor v1 bridge: simplest auth bootstrap
For the Node bridge, we sidestep the login dance entirely:
- Operator logs into web.snapchat.com once via real Chrome
- Operator extracts the
__Host-sc-a-auth-sessioncookie value (via DevTools or our extraction script) - Operator pastes cookie into bridge config
- Bridge uses cookie to call
web-chat-session/refresh, gets Bearer - Bridge uses Bearer for all subsequent gRPC-Web calls
- 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
| Endpoint | Purpose | Notes |
|---|---|---|
messagingcoreservice.MessagingCoreService/CreateContentMessage | Send anything — text DM, media DM, story, group msg | Recipient field determines target; see schema below |
messagingcoreservice.MessagingCoreService/UpdateContentMessage | Edit/react to a message | |
messagingcoreservice.MessagingCoreService/UpdateConversation | Mute, archive, etc. | |
messagingcoreservice.MessagingCoreService/SendTypingNotification | Typing indicator | |
messagingcoreservice.MessagingCoreService/QueryMessages | Fetch message history | |
messagingcoreservice.MessagingCoreService/QueryConversations | Fetch conversation list | |
messagingcoreservice.MessagingCoreService/SyncConversations | Refresh conversations | |
messagingcoreservice.MessagingCoreService/DeltaSync | Incremental message sync | |
messagingcoreservice.MessagingCoreService/BatchDeltaSync | Bulk sync after reconnect | |
messagingcoreservice.MessagingCoreService/GetGroups | Group conversation metadata |
Friends / social
| Endpoint | Purpose |
|---|---|
snapchat.friending.server.FriendAction/AddFriends | Add a friend by username/ID |
snapchat.friending.server.FriendRequests/IncomingFriendSync | Pending incoming friend requests |
com.snapchat.atlas.gw.AtlasGw/SyncFriendData | Friend list sync |
com.snapchat.atlas.gw.AtlasGw/GetSnapchatterPublicInfo | Public profile lookup |
Search / discovery
| Endpoint | Purpose |
|---|---|
web.snapchat.com/search/search | Search users/conversations |
snapchat.lists.api.Lists/FetchLists | Fetch lists (probably friend categories) |
Media
| Endpoint | Purpose |
|---|---|
snapchat.content.v2.MediaDeliveryService/getUploadLocations | Get 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
| Endpoint | Purpose |
|---|---|
web-chat-session/refresh | Refresh Bearer token / heartbeat |
snapchat.cdp.cof.CircumstancesService/targetingQuery | Feature flags |
com.snapchat.deltaforce.external.DeltaForce/DeltaSync | Long-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 itselfRequest 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 bodyThe 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 pipelineweb.snapchat.com/web-blizzard/web— Blizzard analyticsgcp.api.snapchat.com/web/metrics— metricssentry.sc-prod.net/api/158/...— error reportingapi-kit.snapchat.com/com.snap.camerakit.v3.Metrics/SetOperationalMetrics— Camera Kit metrics*/SetBusinessEventscf-st.sc-cdn.net/3d/render— 3D render assets (Bitmoji, lenses)
Recon artifacts saved in this repo
| File | Purpose |
|---|---|
recon-bin/www.snapchat.com.har | First HAR (sanitized — cookies stripped). Use unsanitized version below for replay specs. |
recon-bin/www.snapchat.com.unsanitized.har | Steady-state HAR with everything: send media DM, sync friends, etc. (~18 MB) |
recon-bin/www.snapchat.com.unsanitized.login.har | Full login flow capture (~67 MB) |
recon-bin/www.snapchat.com.unsanitized.post_to_story.har | Just the 3-call story post (~786 KB) — minimal example |
recon-bin/story-create-content-message.req.bin | Decoded protobuf payload of the story-publish CreateContentMessage |
recon-bin/story-create-content-message.resp.bin | Decoded protobuf payload of the story-publish response |
recon-bin/proto-decoder.py | Python raw-protobuf decoder used to reverse the schemas |
recon-bin/har-summarize.py | HAR analysis script — endpoint catalog, body extraction |
Open questions / TODO before/during build
- 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
TextContentinstead ofMediaContent. 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.MediaDeliveryService/getUploadLocationsrequest body shape (15 bytes only — should decode quickly).- WebSocket frame format — deferred to v3 receive work, but worth one quick decode of one frame for posterity.
- Bearer token expiration — what error does an expired token return? Probably 401 with
grpc-statusheader. Test in development. - Token refresh response —
/web-chat-session/refreshreturned 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)
| Day | Task |
|---|---|
| 1 | Node 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 |
| 2 | Implement CreateContentMessage for stories: AES-CBC media encrypt, getUploadLocations, PUT to CDN, POST CreateContentMessage with MY_STORY recipient. Validate end-to-end with real account. |
| 3 | Same path for media DMs (just swap recipient UUID); AddFriends; AtlasGw/SyncFriendData for friend list; search/search |
| 4 | Wire 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 ownsnapUserId)com.snapchat.atlas.gw.AtlasGw/SyncFriendData— friend list + statusessnapchat.friending.server.FriendRequests/IncomingFriendSync— pending friend requests
Stories
df-mixer-prod/soma/batch_stories— friend stories (thefor-friendsfeed)df-spotlight-prod/batch_stories— spotlight stories (TikTok-equivalent)context/spotlight— spotlight context/feed selection
Messaging
messagingcoreservice.MessagingCoreService/QueryConversations— list conversationsmessagingcoreservice.MessagingCoreService/SyncConversations— syncmessagingcoreservice.MessagingCoreService/DeltaSync— delta updates (per conversation)messagingcoreservice.MessagingCoreService/BatchDeltaSync— batch the abovemessagingcoreservice.MessagingCoreService/GetGroups— group chatsmessagingcoreservice.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 groupscom.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 CDNstatic.snapchat.com/accounts/_next/*— Next.js bundlewww.snapchat.com/web/version.json?…— version probe
Endpoints to skip
web-blizzard/web/send— analytics (skipped per existing convention)gcp.api.snapchat.com/*— telemetrysentry.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:
- Stories — server-mediated, schema fully captured. Easy first write.
- Media DMs — server-mediated per existing recon (AES key in plaintext within the protobuf body). Adds the 3-call upload flow but no Fidelius.
- Friend operations + search — gRPC-Web reads, no E2E.
- Text DMs and any receive — deferred to v3+, requires reverse-engineering Fidelius.
Snap user UUIDs observed
perdyjamie(sender):8fee42df-e549-5727-a893-034382ccab89Jamie Nichols(operator's real account):527be2ff-aaec-4622-9c68-79d200b8bdc1
Useful for testing — these are stable per-account.