# Building an Instagram Messaging Integration
A language-agnostic walkthrough of every concern you need to handle when you let business accounts on Instagram receive and reply to direct messages through your own application: OAuth login, webhook subscription, signature verification, inbound delivery, outbound sending, and periodic token refresh.
The exact API surface described here is Instagram Graph API v26.0 (current since 2026-07-29) at graph.instagram.com, accessed via the Instagram Business Login flow — not the deprecated Basic Display API and not the older Page-mediated Messenger flow that routes through graph.facebook.com. Where the two diverge, this is called out.
## 1. Who this is for
You are building a customer-communication app — a help desk, a CRM, a chatbot — and you want operators in your app to send and receive Instagram DMs on behalf of one or more connected business accounts. End users send messages from their personal Instagram app; your app needs them in its UI within a second or two; your operators reply from your UI; their replies need to land in the customer's Instagram inbox.
Also, most of the concepts here apply to other scenarios where you need to handle messaging for business accounts on social platforms. AI bots can work by the same scenario as well. But if you are making AI-powered messaging platform, please reconsider what you are doing in this world.
The Business Login flow gives you exactly that: DMs, two-way, on connected Instagram Business (or Creator) accounts. It does not give you:
- public comments on posts
- Stories publishing
- the user's followers list
- DMs from non-business accounts you do not control
You will need a Meta Developer app of type Business, with the Instagram and Webhooks products enabled, valid privacy-policy and terms-of-service URLs, a registered redirect URI, and (for production) Meta app review approving the requested scopes: instagram_business_basic, instagram_business_manage_messages.
Two gates to keep in mind before starting:
- Before App Review, only accounts holding the Instagram Tester role can connect. Not any role on the Meta app — that specific one, invited under the app's roles and then accepted by the account in its own Instagram settings (Settings → Website permissions → Apps and websites → Tester invites). An account without it cannot complete the login at all. That account should have "Business" or "Professional" status also.
- The app must be published before webhooks are delivered. "Publishing" and "App Review" is a two different things for Meta. Before publishing the login succeeds, the subscription succeeds, /me/subscribed_apps reads back exactly the fields you asked for — and not one event ever arrives. It looks precisely like a broken webhook URL, and it will send you debugging the one part of the system that is fine. Just publish your app before testing.
Budget for both in the schedule rather than discovering them the week you planned to launch. App Review also wants the deauthorize and data-deletion callbacks of §5.5 live and answering, so those are not "later" work either.
The six concerns we cover, in order:
- OAuth login — getting an access token for the connected account.
- Storing the token alongside its expiry.
- Subscribing the connected account to webhook deliveries.
- Receiving and validating inbound webhook events.
- Sending outbound messages.
- Refreshing tokens before they expire.
A seventh section covers operational concerns — idempotency, retries, observability, error handling — that production deployments need.
## 2. Architecture overview
┌──────────────────┐
Browser ───▶│ OAuth login │ ──▶ Token store (DB, one row per channel)
└──────────────────┘ ▲
│ refresh
┌────────┴────────┐
│ Token refresher │ (periodically)
└─────────────────┘
IG servers ──▶ Webhook receiver ──▶ Inbound queue ──▶ Inbound consumer
│ │
└─ verifies signature ▼
Persist message,
enqueue attachment download
Operator UI ──▶ API ──▶ Outbound queue ──▶ Outbound consumer
│
▼
POST graph.instagram.com /me/messages
Two design choices to flag up front:
Two queues, not one. Webhook receivers must return HTTP 200 within a few seconds; therefore real work — DB writes, attachment downloads — happens asynchronously. A separate downloader queue isolates the slow, flaky operation of pulling media from Meta's CDN from the fast operation of recording the message itself. If the downloader falls behind, the chat UI still shows the message text immediately.
A token store, keyed by the Instagram User ID. Your app may be connected to many Instagram accounts; each has its own long-lived token and its own expiry. The natural primary key for the connection ("channel") record is the Instagram User ID returned during OAuth. We will refer to a stored row as a channel throughout the article.
## 3. Prerequisites and configuration
Four values from the Meta dashboard go into your application configuration:
| Env var | What it is |
| IG_APP_ID | Public ID of your Instagram app |
| IG_APP_SECRET | Secret of your Instagram app |
| IG_REDIRECT_URI | OAuth callback URL — url of your app where you will receive the authorization code |
| IG_WEBHOOK_VERIFY_TOKEN | A long random string you choose for the webhook verification |
The four dashboard values all live under App dashboard → Use cases → Manage messaging & content on Instagram, but at different sub-steps:
- IG_APP_ID and IG_APP_SECRET — shown on the use-case page itself.
- IG_WEBHOOK_VERIFY_TOKEN — 3. Configure webhooks.
- IG_REDIRECT_URI — 4. Set up Instagram business login → Business Login Settings.
Note: these are not the Meta app's ID and secret. Your Meta app has its own ID and secret on Settings → Basic; the Instagram app — added to it under Use cases → Manage messaging & content on Instagram — is a separate entity with its own ID and secret. The OAuth exchanges and webhook signature in this article all expect the Instagram ones; pasting the Meta values will fail with non-obvious authentication errors.
### The three hosts
Not configuration, just constants — but worth writing down in one place, because Meta serves this flow from three different origins and it is natural to assume one covers it:
- https://www.instagram.com — where the browser authorizes (§4.1).
- https://api.instagram.com — where the authorization code is exchanged (§4.4).
- https://graph.instagram.com/v26.0 — everything else: the long-lived exchange, /me, subscriptions, sends, refresh. The only one of the three that carries a version segment.
### Verify token vs. app secret
The single most common bug when implementing Meta webhooks is conflating the verify token with the App Secret. They are different secrets and they are used in different requests.
| Secret | Used by Meta to ... | Used by you to ... |
| IG_WEBHOOK_VERIFY_TOKEN |
Echo it back, exactly once, when you save the webhook URL |
Compare-equal during the GET challenge — that is its only job |
| IG_APP_SECRET |
Sign every POST webhook body with HMAC-SHA256, and accept it on OAuth exchanges |
(a) Verify X-Hub-Signature-256 on POST webhooks, (b) include in OAuth token-exchange calls |
HTTPS is mandatory on both IG_REDIRECT_URI and your webhook URL — Meta refuses HTTP for either.
Local webhook testing tip: Use a tool like Cloudflare Tunnel or ngrok to expose your local server over HTTPS.
## 4. OAuth login
The flow is a textbook OAuth 2.0 authorization-code grant with two extra steps after the standard token exchange: trade the short-lived token for a long-lived one, then fetch the connected account's profile.
### 4.1 Build the authorize URL
The user clicks "Connect Instagram" in your app. Your backend issues a redirect to Instagram's authorization endpoint:
GET https://www.instagram.com/oauth/authorize
?client_id=<IG_APP_ID>
&redirect_uri=<IG_REDIRECT_URI>
&response_type=code
&scope=instagram_business_basic,instagram_business_manage_messages
&state=<state-jwt>
### 4.2 The state parameter
state is round-tripped untouched by Instagram, so it is the right place to carry both:
- a CSRF guard — verify the callback originated from a request you issued, not from a forged link in someone's mailbox; and
- the internal user id of the operator who initiated the connection — so the callback knows which application user to attach the new channel to.
A signed JWT works for both jobs. Sign it with one of your service-internal keys; do not reuse the App Secret here. A typical claim shape:
{ "user_id": "uuid-of-internal-user", "exp": 1715000000, "iat": 1714999400 }
Keep exp short — ten minutes is plenty; the user is mid-flow.
### 4.3 Handle the callback
The user authorises. Instagram redirects the browser to your IG_REDIRECT_URI with two query parameters:
GET <IG_REDIRECT_URI>?code=AQB...&state=<state-jwt>
Validate state first. If the JWT signature is bad or exp is past, respond 400 and stop. Decode the JWT and pull the internal user id — you'll attach the new channel to that user.
### 4.4 Exchange code for a short-lived token
POST https://api.instagram.com/oauth/access_token
Content-Type: application/x-www-form-urlencoded
client_id=<IG_APP_ID>
&client_secret=<IG_APP_SECRET>
&grant_type=authorization_code
&redirect_uri=<IG_REDIRECT_URI>
&code=<code-from-callback>
Response:
{ "access_token": "IGQVJ...",
"user_id": 178414...,
"permissions": "instagram_business_basic,instagram_business_manage_messages" }
permissions is a comma-separated string, not an array — a strictly-typed list here fails to deserialise the response. You do not need the field: §4.6 gets the identity from /me, so the only thing worth taking out of this response is the token itself.
This token is valid for about an hour. Do not store it. Do not expose it to the browser.
### 4.5 Exchange short-lived for long-lived
GET https://graph.instagram.com/v26.0/access_token
?grant_type=ig_exchange_token
&client_secret=<IG_APP_SECRET>
&access_token=<short-lived-token>
Response:
{ "access_token": "IGQVJ...long...",
"token_type": "bearer",
"expires_in": 5183944 }
expires_in is in seconds; ~60 days. Compute and store expires_at = now + expires_in. This is the token you persist.
### 4.6 Fetch the connected business account
GET https://graph.instagram.com/v26.0/me?fields=user_id,username,name
&access_token=<long-lived-token>
Response:
{ "user_id": "178414...", "username": "yourbiz", "name": "Your Biz", "id": "178414..." }
The returned user_id (also returned in the id field for compatibility) is the IG-User-ID. Treat it as the natural key of the channel.
### 4.7 Persist the channel
Pseudocode:
def on_oauth_callback(code, state):
user_id = verify_jwt(state).user_id
short = exchange_code_for_short_lived(code)
long = exchange_short_for_long_lived(short.token)
profile = fetch_profile(long.token) # /me
channel, created = channel_repo.upsert_by_external_id(
external_id = profile.user_id,
owner_user_id = user_id,
name = "Instagram @" + profile.username,
access_token = long.token,
access_token_expires_at = now() + long.expires_in,
)
try:
enable_webhook_subscription(channel) # see §5.2
except SubscribeFailed as e:
if created:
channel_repo.hard_delete(channel.id) # physical, not soft — see below
raise
return redirect(app.settings_url, warning=f"connected, but not subscribed: {e}")
return redirect(app.settings_url)
Three things in that handler are less obvious than they look.
The upsert is keyed on external_id, and it reports whether it inserted. A user reconnecting the same Instagram account should refresh their channel's token in place, not create a duplicate. And the rollback below must branch on what the statement did, never on a SELECT you ran beforehand: a concurrent login inserting the row between your read and your write would otherwise have you hard-delete a live channel and its entire history.
A failed subscription rolls a newly created channel back physically. The row is milliseconds old so nothing can reference it yet — and the unique index on the account identity has no deleted_at filter, so a soft delete would occupy that Instagram account forever, with no way to connect it again.
An existing channel keeps its row. The new token is strictly better than the one it replaced, so deleting anything here would be pure loss. The honest end state is "connected, not subscribed" — report it in those words and give the operator something to press.
A "Reconnect" button must refuse a different account, and cannot always tell. If a login started from an existing channel comes back with a different external_id, write nothing: a channel owns its chats and its message history, and its identity cannot be swapped underneath them. But name both ids in the error, because there are two ways to arrive here and they look identical from the inside. One is somebody authorising the wrong account. The other is the same Instagram account arriving under a new id because the Meta app changed — the id is app-scoped, so it is scoped to the app you have now. The second case is not a mistake; it is a new channel, and the operator needs to be told that rather than left to conclude the button is broken.
Nothing is written before the token exchange succeeds, which is what keeps this tractable: the only dangerous window is between the insert and the subscribe. Close it. A crash in there commits the row with nothing left to roll it back, and the account's identity is then occupied by a channel that never worked. Mark the channel provisional until the subscribe returns — a subscribed_at column, plus a panel that offers to finish or discard provisional channels — or reconcile on startup. Either works; leaving it open does not.
## 5. Subscribing to webhooks
There are two activities both colloquially called "subscribing", and they use different secrets. Read the warning in §3 again before proceeding.
### 5.1 The verify-token handshake (one time, GET)
When you save your webhook URL in the Meta dashboard, or when you register it programmatically via POST /<IG_APP_ID>/subscriptions, Meta makes a single GET to your URL to prove you control it:
GET <your-webhook-url>?hub.mode=subscribe
&hub.verify_token=<IG_WEBHOOK_VERIFY_TOKEN>
&hub.challenge=<random-string>
Implementation:
def webhook_verify(request):
if request.query["hub.mode"] == "subscribe" \
and request.query["hub.verify_token"] == config.IG_WEBHOOK_VERIFY_TOKEN:
return Response(body=request.query["hub.challenge"], status=200)
return Response(status=403)
The body must be the raw hub.challenge string — no JSON wrapping, no quotation marks. Mismatch returns 403, no body.
Some platforms (notably PHP) translate . to _ in query parameter keys. If you read hub_mode instead of hub.mode, that's why; both refer to the same parameter on the wire.
This GET fires once per webhook URL change to prove you own the URL. Successful response means Meta will start delivering POSTs to your URL. The verify token's job is now done. It is never used again.
### 5.2 Per-account field subscription (one time per channel, POST)
Saving the webhook URL gives Meta somewhere to send events. It does not yet tell Meta which events to send for which account. Right after the OAuth callback persists the new channel, call:
POST https://graph.instagram.com/v26.0/me/subscribed_apps
?access_token=<channel-access-token>
&subscribed_fields=messages,message_edit,message_reactions,messaging_seen
Response:
{ "success": true }
Check that body, not just the status code — a 200 can carry {"success": false}. See §7.6.
Without this call, the dashboard webhook is wired but no events flow for the connected account. With it, Meta starts streaming events for that account to the URL you registered.
The field name and the key it produces inside changes[].value are not the same word, which is worth a table:
| Subscribed field | Key in value |
| messages | message |
| message_edit | message_edit |
| message_reactions | reaction |
| messaging_seen | read |
The field is messaging_seen, not message_reads. message_reads is a Facebook Page field and does not exist on the instagram object. It reads like the obvious sibling of message_edit, it is not one, and a single unrecognised name makes Meta reject the whole subscribe call — taking the three good fields down with it. Do not "correct" messaging_seen to it.
Other fields exist (message_postbacks, …). Subscribe only to what your handler can actually interpret: an event shape nobody handles is worse than an event you never receive, because it looks like the integration is working.
### 5.3 Subscribing replaces the set, and one bad name fails everything
Two properties of this call that the docs do not spell out, and that interact badly.
It replaces the field set, it does not add to it. Verified against the live API: subscribe four fields, then subscribe two, then read the subscription back — exactly two. So every call must carry the complete list you want, never a delta.
A single unrecognised field name makes Meta reject the whole call. Not the bad name — the call. Three good fields and one typo leave the account subscribed to nothing, and the error says the list was rejected without saying which name was at fault.
On the create path that combination is fatal: this call is what makes events flow at all, so a wrong guess makes channel creation impossible. The recovery is to find the bad names by elimination:
def subscribe_fields(token, wanted):
if subscribe(token, ",".join(wanted)):
return wanted
accepted = [f for f in wanted if subscribe(token, f)] # probe one at a time
if not accepted:
raise SubscribeFailed(wanted)
log.warn("instagram rejected fields", rejected=set(wanted) - set(accepted))
subscribe(token, ",".join(accepted)) # <-- not optional
return accepted
That last call is the line people drop. The probe loop ends having sent a single field, and because the semantics are replace, stopping there leaves exactly one field subscribed — whichever you happened to try last. Re-sending the survivors as one list is correct under either reading of the semantics, which is the other reason to do it: you do not have to be sure you got the semantics right.
### 5.4 Unsubscribing
When a user disconnects the channel, do the inverse:
DELETE https://graph.instagram.com/v26.0/me/subscribed_apps
?access_token=<channel-access-token>
Response: {"success": true}. Then delete the local channel row, which also removes the stored access token.
### 5.5 When the user disconnects you
§5.4 is you letting go of an account. This is the account letting go of you, and Meta requires you to handle it: both callbacks below are checked at App Review, so without them you do not ship at all.
Both arrive as a POST carrying a single form field, signed_request, shaped <signature>.<payload> with both halves base64url. Verifying it has exactly one trap:
signature_b64, payload_b64 = signed_request.split(".", 1)
expected = hmac_sha256(app_secret, payload_b64.encode()) # the ENCODED string
if not constant_time_equal(expected, base64url_decode(signature_b64)):
raise BadRequest("invalid signed request signature")
payload = json.loads(base64url_decode(payload_b64))
user_id = payload["user_id"] # the IG-User-ID
The HMAC covers the still-encoded payload string, not the JSON you get after decoding it. Decode first and every signature fails, with nothing in the failure to tell you why. And note the key: this is the App Secret again, doing a third job on top of the two in §3.
#### Deauthorize
Fires when somebody removes your app in their Instagram settings. The token you are holding is dead as of that moment. Find the channel by user_id, blank the credential, and take the channel out of service so nothing keeps trying to use it. This is not a deletion request and should not be treated as one — the conversation history stays; what has ended is your access.
Skip this callback and the channel stays live holding a token that no longer works, every send fails with a 190, and nobody can explain why the messages stopped.
#### Data deletion
This one is a deletion request. Parse the same way, then remove the account's data — the channel, its chats, the messages in them, stored attachments, the client records that only existed because of it — and blank the credentials. Then answer with where the requester can check up on it:
{ "url": "https://your-app.example.com/instagram/data-deletion/status?code=178414...",
"confirmation_code": "178414..." }
Then serve that status URL — GET …?code= — reporting whether the deletion happened. Define "happened" precisely, because three states have to be distinguishable and only two of them are obvious:
- Never existed — nothing under that id, ever. Answer 404: "no" and "there was never anything here" are different answers, and the requester is entitled to both.
- Deleted — the data is gone and the credentials are blank. Both, not either.
- Not deleted — anything else, including "in progress".
That second condition is what makes the middle state mean anything. A record marked gone while a working access token still sits beside it is not deleted, whatever the flag says — and that is precisely the state an auditor will ask you about.
End-of-section reminder. From here on, every webhook delivery is a POST with an X-Hub-Signature-256 header. That signature is HMAC-SHA256 of the raw body keyed by the App Secret — not the verify token. Section 6.1 shows the verification.
## 6. Receiving messages
### 6.1 Signature verification — the first thing your handler does
Every inbound POST from Meta carries:
X-Hub-Signature-256: sha256=<hex-digest>
The digest is HMAC-SHA256(app_secret, raw_request_body). To verify:
def verify_signature(raw_body: bytes, header: str | None) -> bool:
if header is None or not header.startswith("sha256="):
return False
provided = header[len("sha256="):]
expected = hmac_sha256_hex(config.IG_APP_SECRET, raw_body)
return constant_time_equal(expected, provided)
Three pitfalls to mark with red ink:
- The HMAC key is the App Secret, not the verify token. The verify token from §5.1 is irrelevant here and must not appear in this code path. If your signature check is failing, double-check the key — this is the single most common bug.
- HMAC the raw body. Most web frameworks parse JSON for you and silently re-serialise it on access. Re-serialised JSON does not byte-match the raw body — extra whitespace, sorted keys, escaped characters all break the signature. Read the request body as bytes before any parser sees it.
- Ignore X-Hub-Signature (SHA-1). Meta also sends a SHA-1 digest in that header for backward compatibility. Verify only the SHA-256 header and reject if it is missing.
A failed signature check returns 403 with no body. Do not echo the error back to the caller; Meta does not need it.
### 6.2 Acknowledge fast
Meta requires a 200 response within a few seconds. Beyond that, it retries with backoff and may eventually disable the subscription. So your handler does the minimum:
def webhook_post(request):
if not verify_signature(request.raw_body, request.headers.get("X-Hub-Signature-256")):
return Response(status=403)
inbound_queue.publish(request.raw_body) # or the parsed payload
return Response(status=200)
All real work — DB writes, attachment downloads, fan-out to UI sockets — happens in a queue consumer, asynchronously.
### 6.3 The payload
A typical inbound payload looks like this (one event; an entry may bundle several):
{
"object": "instagram",
"entry": [
{
"id": "178414...", // your IG-User-ID — selects the channel
"time": 1773347860136,
"changes": [
{
"field": "messages", // the subscribed field that produced this
"value": {
"sender": { "id": "98123..." }, // IGSID of the end user
"recipient": { "id": "178414..." }, // your IG-User-ID
"timestamp": "1712948120",
"message": {
"mid": "aWdfZGl...",
"text": "Hi, do you ship to Berlin?",
"is_echo": false,
"attachments": [
{ "type": "image",
"payload": { "url": "https://lookaside.fbsbx.com/..." } }
]
}
}
}
]
}
]
}
Every event is a {field, value} pair under entry[].changes[]. field names the subscribed field that produced it; value is the event itself, and its shape is the same whichever field delivered it.
timestamp is a string, not a number. Type it strictly as an integer and deserialisation fails for the whole payload, taking every event batched alongside it down with it. Nothing routes on the timestamp anyway — parse it leniently or not at all.
Field-by-field:
- entry[].id — the IG-User-ID of your connected account. It also appears on every event inside the entry; §6.4 covers which of the two to route on. Either way you need it, because one app can serve many connected accounts.
- changes[].field — the subscribed field that produced the event: messages, message_edit, message_reactions, messaging_seen.
- value.sender.id — the IGSID (Instagram-Scoped ID) of the end user who sent the message. This is the recipient ID you use when replying. (Unlike older Page-mediated Messenger flows there is no separate PSID lookup; the IDs are symmetric.)
- value.message.mid — Meta's globally-unique message id. Store it as the message's external id; you use it for deduplication and to find the message later when an edit event arrives.
- value.message.text — the text body, optional.
- value.message.attachments[] — each has a type (one of image, video, audio, file) and a payload.url pointing to the media on Meta's CDN.
- value.message.is_echo — true when the event is a copy of a message the account itself sent. Do not drop these — see §6.9.
### 6.4 Routing to a channel
Routing is per entry, not per payload:
def find_channel(ev):
account, _customer = sides(ev) # see §6.9
return channel_repo.find_by_external_id(account["id"])
The entry names the account, and so does every event inside it — value.recipient.id on an inbound message, value.sender.id on an echo. They agree, so either would work. Prefer the event: you have to derive the (account, customer) pair from it anyway to know which side of the conversation the message is on (§6.9), and routing off that same pair means the channel you looked up can never disagree with the side you decided on.
Never index payload["entry"][0]. A payload may carry several entries and an entry several changes; Meta gives you no guarantee about how it batches them, and a hardcoded index drops everything after the first — silently, with a 200 going back on the wire.
If find_by_external_id returns None, log and drop. The webhook is either misrouted (Meta delivering events for an account you no longer connect to) or stale (a channel was deleted but Meta hasn't caught up).
### 6.5 Mapping sender → client
End users are clients in your domain. Maintain a lookup table keyed by the IGSID, scoped to the channel that received the event:
def upsert_client(channel, igsid):
existing = client_external_repo.find(channel.id, igsid)
if existing:
return existing.client
profile = ig_api.fetch_profile(channel.access_token, user_id=igsid)
client = client_repo.create(name=profile.name or profile.username)
client_external_repo.create(channel.id, igsid, profile.username, profile.name, client_id=client.id)
return client
The fetch_profile call hits GET /v26.0/<igsid>?fields=username,name&access_token=<token> and returns the public username and display name. Cache the result on the client_external row so subsequent messages from the same person are a cheap DB lookup.
### 6.6 Persisting and dispatching
The full inbound consumer:
def classify(ev):
"""Which kind of event is this? Decided by the shape of `value`."""
for kind in ("message", "message_edit", "read", "reaction"):
if kind in ev:
return kind
return None
def inbound_consumer(payload):
for entry in payload["entry"]:
for change in entry["changes"]:
ev = change["value"]
kind = classify(ev)
if kind is None:
log.warn("uninterpretable instagram change",
entry_id=entry["id"], field=change["field"])
continue
account, customer = sides(ev) # §6.9
channel = channel_repo.find_by_external_id(account["id"])
if channel is None:
continue
if kind == "message" and ev["message"].get("is_echo"):
persist_echo(channel, ev) # §6.9 — do not drop these
continue
client = upsert_client(channel, customer["id"])
chat = chat_repo.get_or_create(channel.id, client.id)
match kind:
case "message":
persist_inbound_message(chat, client, ev["message"])
enqueue_attachments(chat, ev["message"]) # §6.7
case "message_edit":
update_message_text(chat, ev["message_edit"]["mid"],
ev["message_edit"]["text"])
case "read":
mark_read(chat, ev["read"].get("mid")) # §6.11
case "reaction":
apply_reaction(chat, ev["reaction"]) # §6.10
def persist_inbound_message(chat, client, msg):
chat_message_repo.create(
chat_id = chat.id,
sender_id = client.id,
text = msg.get("text"),
external_id = msg["mid"], # for dedup + edit lookup
)
realtime.publish_to_operator_ui(chat.id, msg)
Two things about that dispatch are deliberate.
The kind is decided by the shape of value, never by changes[].field. The field name is a label; the payload is the fact. Gate on the name and you get to pick your bug: an unlisted field drops real events on the floor, or an unrecognised one becomes an inbox entry made out of nothing. A change whose value carries none of message, message_edit, read or reaction is something else entirely — and it must not turn into a message.
Unrecognised is logged, not silently skipped. Log the field and the entry id and nothing else, because the values carry message text. A bare continue on an unhandled shape is indistinguishable from Meta having sent nothing at all, and that is the hardest state to debug anywhere in this integration.
Two queues are in play here: the inbound queue the webhook handler published to (§6.2), and the download queue that enqueue_attachments feeds (§6.7).
### 6.7 Attachments
Meta gives you a URL per attachment, not an attachment ID, and the URL is short-lived. The signed query string on lookaside.fbsbx.com URLs expires in minutes, not hours. Store the event and move on, and what you have stored is a link that was already dying when it arrived — which looks fine for the first hour and is a wall of broken thumbnails by the next morning.
So the bytes must be fetched promptly, and the fetch must not sit on the webhook's critical path. Split it in two.
#### On the ingest path
Map the attachment's type onto whatever kinds your domain has. This is the whole set:
| Instagram type | What it is |
| image | photo |
| video | video |
| audio | voice message |
| file | document |
Anything else is dropped rather than guessed at. Then create the attachment row before the bytes exist, persist the message referencing it, publish to the UI, and only then enqueue the download:
for att in msg.get("attachments", []):
kind = ATTACHMENT_KINDS.get(att["type"])
if kind is None:
continue # unknown type — drop, don't guess
record = attachment_repo.create(chat.id, kind, status="pending")
pending.append((record.id, att["payload"]["url"]))
message = persist_inbound_message(chat, client, msg, attachments=pending_ids)
realtime.publish_to_operator_ui(chat.id, message) # text is visible NOW
for attachment_id, url in pending:
download_queue.publish(chat.id, message.id, attachment_id, url)
The row starts pending, with no file behind it and the metadata it cannot know yet — dimensions, duration — left at zero. That ordering is the whole point: the customer's text reaches the operator at webhook speed with the attachment rendering as a placeholder, and nothing about the message is held hostage to a CDN fetch.
#### In the downloader
- Receive (chat_id, message_id, attachment_id, download_url).
- Check the URL has a scheme and a host before handing it to an HTTP client. It came from outside.
- GET it straight to a temp file, with a generous timeout — sixty seconds for the body and thirty to connect is not paranoia, some of these are videos.
- Sniff the MIME type from the downloaded bytes and derive the extension from that. The lookaside URL carries no meaningful extension, and its Content-Type is not something to bet a filename on.
- Move the bytes into your own storage, set status = downloaded, record the real filename.
- On any failure set status = failed and stop. A dead end beats a row that claims to have a file it does not have.
- Either way, publish a "message updated" event so open UIs re-render that one bubble.
The three-state ladder — pending → downloaded | failed — is what lets the UI tell "still coming" apart from "this one is never arriving". Those are very different things to show a person.
#### Serving them back
Never hand out storage paths. Generate a signed, expiring URL per attachment and cache it for its own lifetime, so re-rendering a thread does not re-sign every image in it. The same signed URL is what you hand to Meta when sending an attachment the other way — §7.3.
### 6.8 Edits
message_edit events arrive with the same mid as the original. Look up the existing message by external_id, replace its text, and emit a domain event so live UIs refresh:
def update_message_text(chat, mid, new_text):
existing = chat_message_repo.find_by_external_id(chat.id, mid)
if existing is None:
log.warn("edit for unknown mid", mid=mid)
return
existing.text = new_text
chat_message_repo.save(existing)
realtime.publish_message_edit(chat.id, existing.id, new_text)
### 6.9 Echoes — store them, don't suppress them
An echo is an event with message.is_echo: true: a copy of a message the account sent. The obvious reading is "that's my own reply coming back, drop it" — and that reading throws away data you cannot get any other way.
Echoes cover two different things:
- A reply your app sent through the API. You already have this one.
- A message the account owner typed in the Instagram app itself. Your app never saw it go out, and no other event will ever tell you about it. Drop echoes and your operator opens a thread that reads as though the customer was ignored, while the owner is answering them on their phone.
An echo is the mirror of an inbound event: sender is the account and recipient is the customer. Routed the normal way it matches no channel — which is exactly how these come to be dropped by accident rather than by decision. Swap the two before routing:
def sides(ev):
"""Returns (account, customer). An echo has them the other way round."""
if (ev.get("message") or {}).get("is_echo", False):
return ev["sender"], ev["recipient"]
return ev["recipient"], ev["sender"]
Store it on the business side of the conversation with no author — nobody in your operators table typed it. And the duplicate you were worried about is not a problem: an echo of a reply your app sent carries the same mid you already stored, so the unique index from §9.1 absorbs it. Deduplication handles this, not a continue.
### 6.10 Reactions
A message_reactions subscription delivers events whose value carries a reaction instead of a message: the mid that was reacted to, an action of react or unreact, a reaction name and the emoji itself.
"value": {
"sender": { "id": "98123..." },
"recipient": { "id": "178414..." },
"reaction": { "mid": "aWdfZGl...", "action": "react",
"reaction": "love", "emoji": "❤" }
}
Key your storage on (message_id, sender), not on the reaction itself: unreact then deletes the row, and someone switching from hearts to tears replaces theirs rather than accumulating both. Getting that key wrong produces a bubble wearing every emoji its author ever tried.
Outbound reactions, when you want them, go on the same /me/messages endpoint as a send.
### 6.11 Read receipts
A messaging_seen subscription delivers events whose value carries a read:
"value": {
"sender": { "id": "98123..." },
"recipient": { "id": "178414..." },
"read": { "mid": "aWdfZGl..." }
}
It names one message. It means every message up to that one.
Mark the anchor and everything older in that chat — created_at <=, not < — excluding the reader's own. Marking only the named message leaves a thread in which message five is read and one through four are not: a state that cannot occur, and one your UI should never have to render.
Do not put an upper time bound on the range. The temptation is to bracket it with the event's timestamp. Resist it: your created_at and Meta's timestamp are different clocks, and a read means "everything up to here" whatever either of them thinks the time is.
mid is optional, and an anchor you cannot find is routine. Type the field as required and a single receipt without one fails deserialisation for the entire payload, taking every event batched alongside it down too. And a mid that resolves to nothing is normal rather than exceptional: the receipt may have overtaken the id adoption of §7.4, or the message may have been sent from the Instagram app and never had a row here at all. Fall back to marking everything the other side has unread in that chat.
#### The status ladder
new → delivered → read, plus the dead end failed. One tick in the panel means the provider took the message; two mean the customer read it.
Guard both transitions out of new on the current status, because they race. A customer with the thread open can read a reply before its delivery task has finished writing anything down — and an unconditional delivered will then overwrite a read that already landed, making the second tick vanish in front of the operator. Which is why the read query matches status IN ('new', 'delivered') and never = 'new'.
The other direction — telling Instagram that you have read something — is §7.8.
## 7. Sending messages
### 7.1 Endpoint
POST https://graph.instagram.com/v26.0/me/messages?access_token=<channel-access-token>
Content-Type: application/json
### 7.2 Body — text
{ "recipient": { "id": "<IGSID>" },
"message": { "text": "Sure, we ship to Berlin in 3 working days." } }
recipient.id is the IGSID — exactly the same value you saw in sender.id on the inbound webhook event. There is no PSID lookup. (This trips up readers familiar with the older Page-Messenger flow, where PSID and IGSID are distinct.)
### 7.3 Body — attachment
{ "recipient": { "id": "<IGSID>" },
"message": {
"attachments": [
{ "type": "image",
"payload": { "url": "https://your-app.example.com/attachments/abc?signature=…" } }
]
} }
type is one of image, video, audio, file — the same four as inbound (§6.7), so one mapping serves both directions.
It is attachments, an array. The Messenger documentation shows a singular message.attachment object, and that is not what this endpoint takes. The kind of difference you find by capturing traffic rather than by reading.
payload.url must be fetchable by Meta's servers, during the API call. There is no two-step "create an attachment id, then send it" upload here — unlike Telegram, WhatsApp Business and most Meta-adjacent APIs. Which leaves you needing a URL public enough for Meta and private enough for you:
- A signed URL with an expiry is the answer, not a public bucket. Storage stays closed; the link works for whoever holds it, for as long as you allow.
- The expiry has to outlive the fetch. Meta pulls the bytes while your POST is still in flight, so a TTL measured in seconds is a race you lose intermittently and never reproduce.
- It must need no auth header. Meta will not send one.
A message tag, when you have one, goes at the top level of the payload — beside recipient and message, never inside message. The docs will tell you otherwise.
### 7.4 Response
{ "message_id": "aWdfZGl...", "recipient_id": "98123..." }
Persist message_id as the external id of your outbound message — but notice when you are able to. The row already exists: you wrote it before the send so the operator's intent would survive a crash (§7.5), and at that moment Meta's id did not exist. So the row is created under a local id of your own — operator:<uuid> — and adopts Meta's when the send returns. The same id then arrives once more as an is_echo: true event, which the unique index absorbs (§6.9).
That leaves a window, and things arrive inside it. Everything inbound about this message is keyed by Meta's id: the echo, every read receipt, any later edit. Until the adoption lands they all look up an id you have not stored yet and resolve to nothing. Two consequences worth designing for rather than discovering:
- Key the adoption on your row's own primary key, never on the external id you are replacing. You are, by definition, in the state where that external id is wrong.
- A read receipt that finds no message must not be discarded. Fall back to marking the chat (§6.11) — the receipt overtaking the adoption is a normal race, not corruption.
A reply the provider refuses keeps its local id forever: there is no provider id to adopt. Mark it failed and leave it alone.
### 7.5 The async pipeline
Operator UI
│
▼
HTTP API: persist outbound chat_message under a LOCAL external id
(operator:<uuid>) in state=pending,
publish OutboundChatInstagramMessage(chat_message_id) to outbound_queue,
return 200 to UI
Outbound consumer:
1. Load chat_message by id
2. Load chat → channel → access_token
3. Build payload (text and/or attachments)
4. POST graph.instagram.com /me/messages
5. On success: adopt the returned message_id as external_id, keyed on
the row's own id; set chat_message.state = sent
6. On failure: classify error (see §7.6), retry or fail —
the row keeps its local id
Two reasons not to call Graph API synchronously from the operator's HTTP request: the Graph API can stall for several seconds (which would freeze the operator's UI), and consumer crashes mid-call should not silently lose a message the operator already pressed Send on. Persisting the outbound message before the API call makes the operator's intent durable.
### 7.6 Error handling
A Graph API response needs three independent checks. Any one of them on its own lets failures through as success.
- An error envelope in the body. Meta reports most failures this way, and it does so alongside a 4xx rather than instead of one — so the body has to be read even when the request failed.
- An HTTP status of 400 or more. It can arrive with no envelope. Read the body first, because a 4xx that does carry one has the useful message in it — but never let a bare 4xx fall through. It will die later at deserialization instead, with a message that hides the real cause.
- On /me/subscribed_apps: a body that is not {"success": true}. A 200 carrying {"success": false} is a failure and nothing in the HTTP layer says so. Take it for success and you have created a channel that silently receives nothing — the worst failure mode in this article, because every part of it looks connected.
With that settled: Meta reports errors in a consistent envelope.
{ "error": {
"message": "This message is sent outside of allowed window",
"type": "OAuthException",
"code": 10,
"error_subcode": 2534022,
"fbtrace_id": "Az..."
} }
error_subcode is the field that actually tells you what happened. code on its own collapses unrelated failures into one bucket, and the two cases below are the ones that hurt: they are operator-actionable, they look like generic permission errors, and only the subcode separates them. Classify on the pair:
- 10 + subcode 2534022 — not a scope problem. This is the 24-hour customer-service window having closed (§7.7). Retrying never helps; only a tagged message is allowed now. Tell the operator that in those words — "permission denied" sends them looking in the wrong place.
- subcode 2534014 — "Requested user not found". Instagram only knows customers who have messaged this account through your app; an IGSID from anywhere else does not resolve. Do not retry.
- 190 (OAuthException) — token expired or revoked. Stop retrying — a dead token does not come back — and surface the channel to whoever owns the connection so they can re-authorise. Do not requeue.
- 190 + subcode 33 — a different fault wearing the same code: "this is not a short-lived token". You called ig_exchange_token with a token that is already long-lived. Your flow is wrong, the token is fine.
- 4, 17, 32, 613 — rate-limit family. Requeue with exponential backoff. If the response carries an X-Business-Use-Case-Usage header, honour the estimated_time_to_regain_access value rather than guessing.
- 200-family, and 10 without a subcode you recognise — genuine permission errors. Usually a missing scope after Meta tightened a policy. Surface and block further sends until the account is re-authorised.
- 5xx — transient. Retry with standard backoff; don't escalate.
Always log fbtrace_id and error_subcode. The first is the only thing Meta support will ask for; the second is the only thing that will let you classify the failure a year from now, when the message text has been reworded.
### 7.7 The 24-hour customer-service window
Outside a 24-hour window starting at the user's last inbound message, you can only send a message tag (e.g. HUMAN_AGENT). Free-form messages outside the window are rejected by Meta with an error.
Enforce the window yourself instead of letting Meta enforce it for you. Track last_inbound_at per chat, refuse a free-form send past 24 hours before it leaves your process, and allow a tagged send with an explicit business reason. Leaning on the API to reject the late message costs a round trip, a failed row and an operator who has already moved on — and it is the difference between telling them "the window closed at 14:02" and telling them "Instagram said permission denied".
### 7.8 Marking a thread seen
The same endpoint as a send, with sender_action in place of message — so no extra permission is involved:
{ "recipient": { "id": "<IGSID>" },
"sender_action": "mark_seen" }
Two things to know before wiring it to your UI:
- It marks the whole thread, not a message. Instagram has no per-message granularity here, so there is nothing of your own read watermark to send along.
- Send it only when a read actually changed something. An operator UI typically emits one read event per inbound message it renders; calling Meta on each of those means a Graph API request per message in the visible history, for a thread state that was already correct after the first one.
## 8. Refreshing tokens
### 8.1 Why
Long-lived tokens last about 60 days. When one expires, every interaction breaks: you cannot fetch sender profiles, cannot download attachments, cannot send replies. Refreshing must be automatic and proactive.
### 8.2 Endpoint
GET https://graph.instagram.com/v26.0/refresh_access_token
?grant_type=ig_refresh_token
&access_token=<current-long-lived-token>
Response:
{ "access_token": "IGQVJ...new...",
"token_type": "bearer",
"expires_in": 5183944 }
The new token is again ~60 days out. Replace the stored value and update access_token_expires_at = now + expires_in.
### 8.3 Cron design
Run the pass hourly, or at least daily. Find every channel where the token is approaching expiry, and refresh:
REFRESH_THRESHOLD_DAYS = 3
def refresh_due_tokens():
threshold = now() + days(REFRESH_THRESHOLD_DAYS)
channels = channel_repo.find_where_expires_at_null_or_lte(threshold)
refreshed, failed = 0, 0
for channel in channels:
try:
new = ig_api.refresh_access_token(channel.access_token)
channel.access_token = new.token
channel.access_token_expires_at = new.expires_at
channel_repo.save(channel)
refreshed += 1
except OAuthException as e:
failed += 1
if channel.access_token_expires_at is None:
log.warn("refused; token age unknown, Meta requires 24h",
channel_id=channel.id, error=e) # normal on day one
else:
log.error("instagram token refresh failed",
channel_id=channel.id, error=e)
return refreshed, failed
The three-day buffer absorbs failed passes, deploy windows and weekends. Hourly rather than daily is not belt-and-braces: it turns that buffer into about seventy attempts instead of three, and it is the only thing that makes the next rule survivable.
Meta refuses to refresh a token less than 24 hours old. A channel that was just connected — or one whose token was pasted in by hand, whose age you therefore do not know — is expected to be turned away on the first few passes. With an hourly pass it succeeds later the same day and nobody has to know. With a daily one you get one attempt every 24 hours and a permanently confusing error in the log. Treat a refusal on a token of unknown age as a warning, not an error: it is the normal first day of that channel's life.
Resist adding a needs_reconnect column. It is the obvious thing to reach for and it is a second copy of the truth: the row says "broken" while Meta may already disagree, and now clearing it is your job too. Derive the state instead. A "check connection" endpoint that reads the subscription back and does the expiry arithmetic answers is this channel actually working right now, live, and the panel renders that next to a Reconnect button. A failed pass then has nothing to do but say so in the log.
### 8.3a Running it on Kubernetes
If the app is deployed to Kubernetes, the natural translation of the hourly pass is a CronJob that runs the same refresh_due_tokens() logic — same threshold, same buffer, same code path. You get k8s-native retry, overlap prevention, and run history without writing any of it.
apiVersion: batch/v1
kind: CronJob
metadata:
name: ig-refresh-tokens
spec:
schedule: "0 * * * *" # hourly
concurrencyPolicy: Forbid # never let two runs overlap
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2 # retry the job twice before giving up
activeDeadlineSeconds: 600 # kill stuck pods after 10 minutes
template:
spec:
restartPolicy: OnFailure
containers:
- name: refresh
image: your-registry/your-app:tag
command: ["app:instagram:refresh-tokens"]
envFrom:
- secretRef: { name: ig-app-secrets }
- configMapRef: { name: ig-app-config }
Three fields worth pointing at:
- concurrencyPolicy: Forbid stops a slow run from being lapped by the next schedule tick. Two parallel refreshers race on the same row, and one will overwrite a token the other just refreshed — at best wasted work, at worst a clobbered expires_at.
- backoffLimit: 2 retries the job; restartPolicy: OnFailure retries the container inside the same pod. Together: transient pod hiccups retry locally, persistent failures stop after a few attempts instead of looping forever.
- activeDeadlineSeconds is the runaway guard. The refresh loop should finish in seconds; if it's running for ten minutes something is wrong and the pod should be killed well before the next schedule tick fires.
The app:instagram:refresh-tokens command (§8.5) is reused as-is here — exactly the same logic the cron drives, just invoked by k8s now.
If you already run a controller-heavy stack, the heavier alternative is an operator: a Channel CRD with status.tokenExpiresAt, and a controller that requeues each channel for reconciliation as that timestamp approaches. Each channel becomes its own scheduled refresh rather than being picked up by a daily batch scan, which scales better past a few thousand channels and gives per-channel observability for free. For most deployments it's overkill — reach for it only if the operator pattern is already your platform's idiom.
### 8.4 What can go wrong
A refresh requires the current token to still be valid. If the token has already expired, refresh_access_token returns OAuthException and you cannot recover automatically — the user must reconnect. Two implications:
- The cron must run more often than the buffer minus the longest plausible outage. Daily with a 3-day buffer tolerates two missed runs.
- A 190 from refresh_access_token is the signal to stop trying — no number of attempts revives a dead token. Leave expires_at exactly as it is; do not push it forward to keep the row looking healthy. The connection check is what surfaces the channel to its owner with a Reconnect button.
### 8.5 Manual trigger
Provide a CLI command that runs the same logic on demand:
$ app:instagram:refresh-tokens
Useful after incidents (cron didn't run for a day), and on any fresh deployment with imported data where expires_at is unknown — the threshold query catches expires_at IS NULL rows on the first run.
## 9. Operational checklist
### 9.1 Idempotency
- Inbound: dedupe by message.mid. Meta retries delivery on any non-200 response (and sometimes on 200 too). Your inbound writer must be safe to call twice with the same mid. A unique index on (channel_id, external_id) is the cheapest enforcement.
- Know what your conflict clause does. ON CONFLICT DO NOTHING is the natural way to write that insert, and it turns a duplicate key into a silently dropped message rather than an error. That is exactly right for a genuine redelivery and exactly wrong for an id collision — and after the fact the two are indistinguishable. Which makes the uniqueness of the id your problem, not the database's.
- Build external ids in one place, and namespace them. A Meta mid is unique platform-wide and needs nothing added to it. But if your app carries more than one provider, that mid shares a column with a Telegram message_id, which is unique only inside its chat — so prefix them (instagram:<mid>, telegram:<chat>:<id>). The invariant to hold is "unique within the channel", and the only way to hold it is one function every caller goes through. Ids are stored: change the format later and you orphan every row already written.
- Outbound: dedupe by your internal chat_message.id. Use it as the request idempotency key in your queue so a consumer crash does not send the same message twice.
### 9.2 Observability
- Log every Graph API call's HTTP status and the fbtrace_id from the response body. Meta support cannot help without it.
- Alert on tokens expiring within 7 days that have failed to refresh.
- Alert on webhook signature verification failures > 0 over any window — it means either someone is spoofing webhooks or your App Secret rotated without the deployment picking it up.
- Track outbound consumer lag and inbound queue depth as separate dashboards.
### 9.3 Rate limits
Meta returns X-Business-Use-Case-Usage headers alongside Graph API responses, carrying the account's call_count, total_cputime and estimated_time_to_regain_access. Record them whenever they are present. When throttled, requeue the failed job with at least the wait it names.
### 9.4 Secret hygiene
- Never log access tokens — they are bearer credentials.
- Encrypt access_token at rest if your threat model demands it.
- Rotate the App Secret? Be aware it breaks both signature verification on inbound webhooks and OAuth flows in progress. Coordinate the rollout: deploy code that accepts both old and new secrets, rotate, then remove the old.
### 9.5 Local development
- Tunnel the webhook URL with ngrok / Cloudflare Tunnel — Meta needs a publicly reachable HTTPS endpoint.
- The dashboard's per-field Test button sends a fixed sample payload: useful for exercising your parser, not evidence that live delivery works.
- The /me/subscribed_apps and /refresh_access_token calls are safe to hit repeatedly with the same token; they're effectively idempotent.
### 9.6 Retries
Every call to somebody else's API needs an answer to "and if it fails?". The default answer — report it and move on — is what you will find yourself shipping unless you decide otherwise up front, because each individual call looks too small to deserve a policy.
Decide once, not five times:
- Bounded exponential backoff for calls that are safe to repeat: profile lookups, subscription reads, token refresh.
- An outbox for the ones that are not. A delivery must never be retried without an idempotency key, or the customer gets the message twice — which is worse than not getting it.
- Honour the wait you were given. Rate limits are what hits first in practice, and the response tells you how long to back off (§9.3). Guessing when you were told is a choice, not an oversight.
Your own fan-out deserves naming separately, because it is the one people forget: a dropped publish to your message bus is not a provider failure at all, and losing it silently means no socket ever hears about a message that is sitting in your database, correct and invisible. Retry it or log it loudly — doing neither is how that bug survives for months.
## 10. Glossary
- IG-User-ID
- the integer ID of an Instagram Business or Creator account. Returned during OAuth as user_id. Used as entry[].id and recipient.id on inbound events. The natural key of a channel.
- IGSID (Instagram-Scoped ID)
- the ID of an end user as seen by your app, scoped to a single connected business account. Same value appears as sender.id on inbound events and as recipient.id on outbound sends.
- PSID (Page-Scoped ID)
- the equivalent identifier in older Page-mediated Messenger flows. Different from IGSID. The Business Login flow described here does not use PSIDs.
- App-Scoped User ID
- the OAuth-time user_id returned in the short-lived token response, namespaced to your app. In the Business Login flow this is the same value as the IG-User-ID. "Namespaced to your app" is not decoration: change the Meta app and the same Instagram account comes back under a different id, indistinguishable from a different account. See §4.7.
- IG Business Account
- an Instagram account in Business or Creator mode, eligible for the Business Login flow. Personal accounts are not eligible.
- Business Login
- the OAuth flow at instagram.com/oauth/authorize with instagram_business_* scopes. The flow this article describes.
- Basic Display API
- a deprecated Instagram OAuth flow for non-business accounts. Do not use it for messaging.
- Webhook field
- a category of events you can subscribe an account to. We use messages and message_edit; the API exposes more.
- Subscription
- overloaded term: §5.1 (the verify-token URL handshake) and §5.2 (the per-account field subscription) are both called "subscribing" and they use different secrets.
- 24-hour window
- the messaging-policy window during which you can send free-form messages to a user. Reset by every inbound message from that user.
- Message tag
- a category (e.g. HUMAN_AGENT) you attach to an outbound send to permit it outside the 24-hour window.
- Echo event
- an inbound webhook event with message.is_echo: true, representing a message the account sent — either through your app or typed by the owner in the Instagram app. Its sender and recipient are mirrored relative to an inbound message. Store it; see §6.9.
## A note on what this article is
This is a behaviour spec: how to build the thing, not a description of any one codebase. The wire facts — every endpoint, header, query parameter, field name, error code and subcode — are what flows today against Graph API v26.0, taken from live traffic rather than from the docs, and called out where the two disagree. None of this should be taken as an Instagram API specification — Meta evolves these endpoints continuously. When something stops behaving as described, the source of truth is your traffic capture, not this document.