SARAH

Instagram (superadmin)

Experimental Instagram inbox for Patmos operators — not a merchant CRM feature

Last updated: 2026-09-20

Instagram inbox (experimental)

This is a Patmos superadmin experiment. It does not appear in Company settings, merchant CRM, or cashier POS.

Who can use it

Only users in the Sarah superadmin table. Shop admins and cashiers are redirected away from /superadmin/instagram and receive 403 on the inbox APIs. Those APIs prove the cookie JWT with the anon-key SSR client, then check the superadmin table — they do not treat the x-user header as the session.

Enablement is Connect Instagram (a connected_channels row). There is no env list of company ids and no hardcoded shop.

Meta apps

Connect is Instagram Login (https://www.instagram.com/oauth/authorize) with client_id = Patmos Channels Instagram App ID (INSTAGRAM_APP_ID=1223099003236295). That id is the Instagram product of the shared Patmos Channels connector (legacy dashboard name Landscape-IG, being renamed). It is not Landscape-the-product.

The Facebook app (META_APP_ID=1386194269924434) is Facebook Login / WhatsApp only. Meta rejects it as Instagram Login client_id.

The dialog requests instagram_business_basic and instagram_business_manage_messages. Token exchange uses INSTAGRAM_APP_SECRET if set, otherwise META_APP_SECRET.

The authorize URL always sends force_reauth=true and enable_fb_login=false so Connect uses Instagram professional credentials instead of a Facebook-linked personal account.

@sarah.patmos has no Facebook Page and does not need one.

URLs on Patmos Channels Instagram Login

UseURL
OAuth callback (prod NEXT_PUBLIC_URL)
https://www.sarah.ar/api/integrations/meta/instagram/callback
OAuth callback (apex, also allowlisted)
https://sarah.ar/api/integrations/meta/instagram/callback

These sit alongside landscap.app on the same Instagram Login settings.

Do not change the Patmos Channels webhook callback URL to sarah.ar if it currently points at landscap.app — that would break Landscape. After Connect, Sarah reads the profile with official

GET graph.instagram.com/{version}/me?fields=user_id,username,id
(id is IGSID, user_id is the professional 1784… id). If /me 400s, it falls back to GET /{ig-user-id} using the stored IGSID. DMs are polled at
graph.instagram.com/{version}/{professional-user-id}/conversations
when user_id is stored — not /{IGSID}/conversations and not /me/conversations. The conversations list poll is slim (participants + last-message preview). Opening a thread requests messages with attachments{id,mime_type,name,size} (Instagram Login fields; not Facebook Page platform=instagram). Every Graph GET aborts after 8 seconds, including the response body read. The inbox API returns { conversations } from the database first and schedules Graph sync with after() (empty array is ok). The panel aborts the list fetch after 15 seconds, shows stored threads or “Todavía no hay conversaciones” instead of staying on “Cargando…”, and retries the list once if the first paint is empty. The connection block is compact (account, Connect, read-only; callback/webhook in a details row). The inbox fills remaining height: narrow list + message pane with a participant header. When META_IG_SEND_ENABLED is on, a selected thread shows a composer (POST /api/superadmin/instagram/messages → Instagram Login POST graph.instagram.com/{IG_ID}/messages). When the flag is off (default), the inbox stays read-only. Opening the inbox refreshes the profile only when the professional id is not already stored, so an existing Connect does not need to connect again. The panel only shows threads for the currently connected Instagram channel. Each bubble is labeled: outbound is the connected @handle, inbound is the conversation participant. Audio DMs play through GET /api/superadmin/instagram/attachments?id= (superadmin cookie only; Graph file_url is resolved with the channel token and streamed — the browser never receives access_token). The Sarah route /api/webhooks/meta still verifies HMAC if Meta posts to it. The Channels hub fans out to POST /api/webhooks/channels (https://www.sarah.ar/api/webhooks/channels) with X-Channels-Signature.

Env

See instagram-inbox.env.example and .env.example. Instagram Login client_id is INSTAGRAM_APP_ID (default 1223099003236295). Token secret is INSTAGRAM_APP_SECRET or META_APP_SECRET. Also required: META_WEBHOOK_VERIFY_TOKEN, NEXT_PUBLIC_URL. META_IG_SEND_ENABLED defaults false; true / 1 / yes unlocks in-app replies.

Webhook HMAC (X-Hub-Signature-256) uses META_APP_SECRET and is fail-closed (403). Persist failures after a valid HMAC return 500. GET hub.challenge returns the challenge string.

The Channels consumer HMAC (X-Channels-Signature) uses CHANNELS_CONSUMER_SECRET — same env name as Landscape. Fail-closed (403 invalid_signature). After a valid signature, Instagram DMs persist on the existing inbox path and Mode C uses the shared ig_mid claim. The minute cron /api/cron/instagram-inbox-sync stays as backup poll.

Patmos tester account: @sarah.patmos, IG user id 17841470676628629. The inbox itself is not gated to that account. The Mode C sales agent is: receiving channel @sarah.patmos and sender @israelwegierski only.

Mode C sales agent (@sarah.patmos)

Flag META_IG_SALES_AGENT_ENABLED (default off). Off = human inbox only, no auto-reply and no shadow.

When on, the agent runs only when an inbound human DM arrives at @sarah.patmos and the sender is @israelwegierski (Deby / Debora Introini, co-founder of Sarah: short, commercial, answer first). Never admits being AI / a bot / an assistant. «Sos AI?», «quién sos?», «cómo te llamás?» → human identity + audio/WhatsApp CTA. «Quiero info» is a product ask, not ambiguity: cloud / no-install, 7-day trial asking name + email + phone, then «¿Querés que te grabe un audio? Dejame tu WhatsApp y te mando un mensaje.» (collects a number so Deby can write them — Mode C cannot send WhatsApp audio; if they leave the number, she says she will write / send the audio, never «a alguien» or the team). It never asks «which area of the software… sales, inventory, billing?». Price / qué vale / cuánto sale / planes get the public list in the same reply (Básico $38.000 ARS/month and Avanzado $76.000 ARS/month). «the team confirms» only for a custom discount or unpublished quote. Never stall («let me check the docs», «quote from memory»). The claim stores send_after (first message 60–300 s; active-thread follow-up 15–60 s) and does not Graph-send in that invoke — the minute cron drains. It is scheduled after webhook persist (/api/webhooks/channels and /api/webhooks/meta), after Graph inbox sync (list poll or opening a thread), and on the minute cron /api/cron/instagram-inbox-sync so replies do not require opening /superadmin/instagram. The Meta webhook stays optional. Only newly inserted inbound rows are passed; existing mids are not replayed. Cron, Graph sync, and both webhooks share one claim on ig_sales_agent_logs.ig_mid before any Graph send (at most one reply per inbound Meta message id). Other senders are logged and get no reply. It never invents other amounts: the public list is always quoted. Handoff (tag + no auto-reply) when the user asks for a human, hard-objects, confidence is low, or they are closing a sale. Every turn is stored in ig_sales_agent_logs and listed at /superadmin/ig-sales-shadow. Send uses the existing path and still requires META_IG_SEND_ENABLED. No outbound campaigns. WhatsApp / Landscape are unchanged.

WhatsApp stays on Twilio (/api/whatsapp). This inbox does not replace it.