Multiple Instagram accounts routed to one webhook endpoint — entry.id routing architecture, per-client token management, error isolation diagram
In this guide: How Instagram multi-account webhooks work · entry.id routing pattern · Token management per account · Database schema for multi-account · Client data isolation · Error handling and circuit breakers · Onboarding a new client account · SocialHook as the managed layer

How Instagram multi-account webhooks work under the hood

When you register a webhook URL in your Facebook App and subscribe it to Instagram events, that single URL receives events from every Instagram Business Account that has granted your App the instagram_manage_messages permission. This is by design — Meta's webhook architecture is fundamentally multi-tenant. One App, one webhook URL, unlimited accounts.

The mechanism: when a customer DMs any connected Instagram account, Meta fires an HTTP POST to your webhook URL. The request body contains an entry array, where each element represents one account's events. The entry[0].id field is the Instagram Business Account ID (IGID) that generated the event.

This is the complete picture of what you need to build a multi-account system:

  • One Facebook App with one webhook registration (one URL, one verify token, one App Secret)
  • One Page Access Token per Instagram account — each token is specific to the Facebook Page linked to that Instagram account
  • A routing table that maps each Instagram Business Account ID to its Page Access Token, client data, and database context
  • Client isolation so that one client's errors, rate limits, or data never affect another's

The entry.id field: the routing key for everything

Every Instagram webhook event you receive — whether a DM, story reply, story mention, or reaction — contains the Instagram Business Account ID at entry[0].id. This is the field that routes the event to the correct client. Store it as your primary key in the accounts table. Look it up on every incoming event. Never process an event without first resolving which client it belongs to.

Raw webhook payload — entry.id is your routing key
{ "object": "instagram", "entry": [{ "id": "987654321098765", // ← THIS IS YOUR ROUTING KEY // = Instagram Business Account ID of the account that got the DM // = the same ID used in POST /{this_id}/messages to send replies "time": 1747231892, "messaging": [{ "sender": { "id": "12345678901234" }, // IGSID of the customer "recipient": { "id": "987654321098765" }, // same as entry.id "message": { "text": "Hello!" } }] }] } // entry[0].id tells you: which of your client's Instagram accounts received this DM // Use it to look up: Page Access Token, client_id, database shard, handler
entry can contain multiple elements in one POST. If two of your client accounts receive DMs within the same delivery batch, Meta may bundle them in a single POST with two elements in the entry array. Always loop over all entries, not just entry[0]. Each element has its own id field pointing to a different Instagram account.

Architecture: one URL, many clients

Multi-account webhook routing architecture
→→→
One webhook URL
yourserver.com/webhook
→→→
Routes by
entry[0].id
Client A handler
Token A · DB shard A
Client B handler
Token B · DB shard B
Client C handler
Token C · DB shard C
Client D handler
Token D · DB shard D

Token management: one token per account

Each Instagram Business Account requires its own Page Access Token — generated for the Facebook Page linked to that account. This token is what you use to call the Instagram Send API for that specific account. You cannot use Client A's token to send messages on Client B's account.

Token storage guidelines:

  • Encrypt at rest. Page Access Tokens are sensitive credentials. Store them encrypted in your database (AES-256), not as plaintext. Decrypt only when needed for an API call.
  • Never log tokens. Ensure your webhook handler, error logger, and analytics pipeline never log the raw token value.
  • Token rotation. Long-lived Page Access Tokens don't expire by default, but rotate them periodically (quarterly) and whenever a client offboards. Immediately revoke tokens for accounts that disconnect from your platform.
  • One token per account, loaded at request time. Don't cache tokens in memory indefinitely — load from the database at request time so revocation takes effect immediately.
Node.js — encrypted token store
tokenStore.js
const crypto = require('crypto'); const ENC_KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY, 'hex'); // 32 bytes function encryptToken(token) { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', ENC_KEY, iv); const encrypted = Buffer.concat([cipher.update(token), cipher.final()]); return iv.toString('hex') + ':' + encrypted.toString('hex'); } function decryptToken(stored) { const [ivHex, encHex] = stored.split(':'); const iv = Buffer.from(ivHex, 'hex'); const decipher = crypto.createDecipheriv('aes-256-cbc', ENC_KEY, iv); return Buffer.concat([decipher.update(Buffer.from(encHex, 'hex')), decipher.final()]).toString(); } async function getAccountToken(igBusinessId) { const account = await db.query( 'SELECT encrypted_token FROM ig_accounts WHERE ig_business_id = $1 AND active = true', [igBusinessId] ); if (!account.rows[0]) return null; return decryptToken(account.rows[0].encrypted_token); } module.exports = { encryptToken, decryptToken, getAccountToken };

Database schema for multi-account management

Three tables cover the complete multi-account data model. The ig_accounts table is the routing table — looked up on every incoming event. The igsids table stores user identifiers scoped to each account, ensuring total isolation between clients.

ig_accounts table — the routing registry

ColumnTypeNotes
ig_business_id PKVARCHAR(64)entry[0].id from webhook — also path param in Send API. Store as string.
client_id FKUUIDReferences clients table. Every query filters by this.
encrypted_tokenTEXTAES-256 encrypted Page Access Token. Decrypt at request time only.
ig_usernameVARCHAR(64)Human-readable label. @handle of the Instagram account.
activeBOOLEANSet false when client offboards. Prevents processing events from disconnected accounts.
created_atTIMESTAMPTZWhen the account was connected.
token_rotated_atTIMESTAMPTZTrack token age for rotation policy enforcement.

igsids table — per-account user identifiers

ColumnTypeNotes
igsid PK partVARCHAR(64)Instagram-Scoped ID of the user. Scoped to THIS account only.
ig_business_id PK part FKVARCHAR(64)Composite PK with igsid. Same user DM-ing two clients = two different IGSIDs.
client_id FKUUIDDenormalized for fast client-scoped queries.
first_seenTIMESTAMPTZFirst DM from this IGSID to this account.
last_messageTIMESTAMPTZFor 24-hour window tracking per-account.
metadataJSONBAny client-specific data (email, name, Shopify customer ID, etc.)

The multi-account routing handler

This is the webhook POST handler that correctly processes events from any number of connected Instagram accounts. Every production concern is covered: multiple entries in one POST, unknown account IDs, inactive accounts, and clean context isolation per client.

Node.js — multi-account Instagram webhook router
multiAccountRouter.js
const { getAccountToken } = require('./tokenStore'); const { verifySignature } = require('./verifySignature'); app.post('/webhook', verifySignature, async (req, res) => { res.sendStatus(200); // always acknowledge immediately const body = req.body; if (body.object !== 'instagram') return; // Process ALL entries — Meta may batch multiple accounts in one POST for (const entry of body.entry) { const igBusinessId = entry.id; // ← THE ROUTING KEY // Load account config — resolves token, client_id, and active status const account = await db.query( 'SELECT * FROM ig_accounts WHERE ig_business_id = $1 AND active = true', [igBusinessId] ); if (!account.rows[0]) { // Unknown or disconnected account — log and skip console.warn(`Unknown/inactive IG account: ${igBusinessId}`); continue; } const { client_id, encrypted_token, ig_username } = account.rows[0]; const pageToken = decryptToken(encrypted_token); // Build client-scoped context — injected into all handlers const ctx = { igBusinessId, // used for Send API path clientId: client_id, pageToken, // for sending replies accountName: ig_username, sendDM: (igsid, text) => sendInstagramDM(igBusinessId, pageToken, igsid, text), logEvent: (data) => analytics.log({ ...data, client_id, igBusinessId }), }; // Process each messaging event within this account's entry for (const event of (entry.messaging || [])) { // Process in try/catch so one account's errors don't affect others try { await processEvent(event, ctx); } catch (err) { // Log with client context — never re-throw (would skip remaining accounts) console.error(`Error processing event for ${ig_username} [${igBusinessId}]:`, err.message); await errorTracker.capture(err, { igBusinessId, client_id }); } } } }); // The client-context-aware Send API wrapper async function sendInstagramDM(igBusinessId, pageToken, igsid, text) { const res = await fetch( `https://graph.facebook.com/v21.0/${igBusinessId}/messages?access_token=${pageToken}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient: { id: igsid }, message: { text } }) } ); if (!res.ok) { const err = await res.json(); throw new Error(`DM send failed [${igBusinessId}]: ${err.error?.message}`); } return await res.json(); }

Client data isolation: preventing cross-contamination

In a multi-tenant system, the worst failure mode is one client's data appearing in another client's context. Every database query that touches user data — conversation history, IGSID records, analytics — must be filtered by client_id AND ig_business_id.

Node.js — client-scoped database operations
clientDB.js
// ✗ Wrong — no client scope, could return any account's conversations const convos = await db.query('SELECT * FROM conversations WHERE igsid = $1', [igsid]); // ✓ Correct — always scope by ig_business_id AND client_id const convos = await db.query( 'SELECT * FROM conversations WHERE igsid = $1 AND ig_business_id = $2 AND client_id = $3', [igsid, ctx.igBusinessId, ctx.clientId] ); // ─── Pattern: pass ctx everywhere, never pass raw IDs ──────────────────── // Upsert IGSID record scoped to this account async function upsertUser(igsid, ctx) { return db.query(` INSERT INTO igsids (igsid, ig_business_id, client_id, first_seen, last_message) VALUES ($1, $2, $3, NOW(), NOW()) ON CONFLICT (igsid, ig_business_id) DO UPDATE SET last_message = NOW() RETURNING *`, [igsid, ctx.igBusinessId, ctx.clientId] ); } // Log a conversation message scoped to this account async function logMessage(igsid, text, direction, ctx) { return db.query( 'INSERT INTO messages (igsid, ig_business_id, client_id, text, direction, created_at) VALUES ($1,$2,$3,$4,$5,NOW())', [igsid, ctx.igBusinessId, ctx.clientId, text, direction] ); }

Error handling and circuit breakers

In a single-account webhook system, an error can bubble up and stop all processing. In a multi-account system, an error for Client A must never prevent events from processing for Clients B, C, and D. Two patterns are essential:

Per-account try/catch (already shown above): Wrap each account's event processing in its own try/catch inside the entry loop. Log errors with client context. Never re-throw — a re-thrown error inside the loop would abort all remaining entries.

Circuit breaker per account: If an account repeatedly produces errors (invalid token, rate limit, App permission revoked), stop processing that account's events temporarily rather than hammering a broken endpoint on every event.

Node.js — per-account circuit breaker
circuitBreaker.js
const errorCounts = new Map(); // igBusinessId → { count, firstErrorAt } const CIRCUIT_THRESHOLD = 5; // open circuit after 5 errors const RESET_AFTER_MS = 10 * 60 * 1000; // reset after 10 minutes function isCircuitOpen(igBusinessId) { const state = errorCounts.get(igBusinessId); if (!state) return false; // Auto-reset after RESET_AFTER_MS if (Date.now() - state.firstErrorAt > RESET_AFTER_MS) { errorCounts.delete(igBusinessId); return false; } return state.count >= CIRCUIT_THRESHOLD; } function recordError(igBusinessId) { const state = errorCounts.get(igBusinessId) || { count: 0, firstErrorAt: Date.now() }; state.count++; errorCounts.set(igBusinessId, state); if (state.count === CIRCUIT_THRESHOLD) { console.error(`⚡ Circuit opened for ${igBusinessId} after ${CIRCUIT_THRESHOLD} errors`); // Alert your ops team via Slack/PagerDuty here } } // In the router's per-account loop: if (isCircuitOpen(igBusinessId)) { console.warn(`Circuit open for ${igBusinessId} — skipping`); continue; } try { await processEvent(event, ctx); } catch (err) { recordError(igBusinessId); console.error(`Error for ${igBusinessId}:`, err.message); }

Onboarding a new client account

Every time a new client connects their Instagram account to your platform, you need to execute a specific sequence of steps. Building this as a formal onboarding flow (rather than manual database inserts) makes scaling to many clients manageable:

  1. OAuth or manual token collection: Either implement Facebook OAuth (for self-serve) or manually generate a Page Access Token in the Meta Developer Portal. The token must include instagram_manage_messages, pages_messaging, and pages_read_engagement permissions.
  2. Resolve the Instagram Business Account ID: Call GET /me?fields=instagram_business_account with the Page Access Token. The instagram_business_account.id in the response is the value you store as ig_business_id in your accounts table.
  3. Verify the token has messaging access: Test with a GET /{ig_business_id}?fields=id,name call. If it succeeds, the token has the permissions needed.
  4. Insert into your accounts table: Store the encrypted token, client_id, and ig_business_id. Set active = true.
  5. Your webhook already receives their events. No re-registration required. The existing webhook subscription on your Facebook App automatically starts delivering events for any account that grants your App permissions. Your router's database lookup handles the new account automatically.

SocialHook: the managed multi-account layer

Building and maintaining a multi-account webhook router — with encryption, circuit breakers, client isolation, and onboarding flows — is weeks of infrastructure work that isn't your core product. SocialHook handles all of it as managed infrastructure.

Connect all your client Instagram accounts to one SocialHook workspace. Events from every client account arrive at your single webhook endpoint in the normalized format, with the client context already resolved:

SocialHook — multi-account events already routed
// Every event from any connected account: { "platform": "instagram", "event": "message.received", "ig_business_id": "987654321098765", // ← already extracted "account_name": "clientA_brand", // ← human-readable label "from": "12345678901234", // ← IGSID "message": { "type": "text", "body": "Hello!" }, "signature_verified": true } // Your handler uses ig_business_id for routing — no DB lookup needed app.post('/webhook', express.json(), async (req, res) => { res.sendStatus(200); const { ig_business_id, account_name, from, message } = req.body; // Route to client-specific handler by ig_business_id await handlers[ig_business_id]?.handle(from, message); // Or simply: use ig_business_id as the client partition key in your own DB });

Common questions

Can one Instagram webhook URL receive events from multiple accounts?
Yes. Meta's webhook architecture is multi-tenant by design. One Facebook App registration delivers events from all Instagram accounts that have granted your App instagram_manage_messages permission. Events from different accounts arrive in the same POST body, differentiated by entry[0].id — which is the Instagram Business Account ID. Your router reads this field and dispatches to the correct client handler.
How do I identify which Instagram account sent a webhook event?
Check entry[0].id in the webhook payload. This is the Instagram Business Account ID (IGID) that received the DM. It is also the numeric ID you use as the path parameter when calling the Send API: POST /{entry[0].id}/messages. Store a mapping of this ID to your client's Page Access Token, client_id, and database context. Look it up on every incoming event.
Do I need to re-register my webhook when adding a new Instagram account?
No. Your webhook registration on your Facebook App is static — it doesn't change per-account. When a new Instagram account grants your App instagram_manage_messages permission, their events automatically start arriving at your registered webhook URL. You just need to insert their credentials into your accounts routing table. Your existing router handles the rest via the entry[0].id lookup.
Can entry contain multiple accounts in one POST?
Yes. If multiple connected accounts receive messages within the same delivery batch, Meta may bundle them in one POST with multiple elements in the entry array. Always loop over all entries — for (const entry of body.entry) — not just entry[0]. Each element has its own id pointing to a different Instagram Business Account.
How do I prevent one client's errors from affecting other clients?
Wrap each account's event processing in its own try/catch inside the entry loop. Log the error with client context. Never re-throw — a re-thrown error inside the loop aborts all remaining entries. For repeated errors on one account, implement a per-account circuit breaker that skips that account temporarily. Both patterns are shown in the error handling section above.
What is the correct database schema for multi-account Instagram webhook data?
Three key tables: clients (one row per business client), ig_accounts (one row per Instagram account, with encrypted Page Access Token and ig_business_id as primary key), and igsids (composite primary key of igsid + ig_business_id — this ensures IGSID namespace isolation between accounts). Every query on user data must filter by both ig_business_id AND client_id.

All client accounts.
One webhook. Zero routing code.

Connect every client's Instagram account to SocialHook. Events arrive at your endpoint pre-routed by account, HMAC verified, normalized. Add a new client account in minutes — no code changes, no re-registration, no new webhook URLs. $50/month regardless of how many accounts you manage.

No credit card required · $50/month after trial · Instagram + Messenger + WhatsApp