# 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:

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:

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:

  1. OAuth login — getting an access token for the connected account.
  2. Storing the token alongside its expiry.
  3. Subscribing the connected account to webhook deliveries.
  4. Receiving and validating inbound webhook events.
  5. Sending outbound messages.
  6. 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 varWhat it is
IG_APP_IDPublic ID of your Instagram app
IG_APP_SECRETSecret of your Instagram app
IG_REDIRECT_URIOAuth callback URL — url of your app where you will receive the authorization code
IG_WEBHOOK_VERIFY_TOKENA 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:

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:

### 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.

SecretUsed 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 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.

A callback without a code is the other normal outcome. The user declined the dialog, and Instagram says so with ?error=access_denied and an error_description you can show. Check for the code before you try to spend it, or the exchange in §4.4 fails on an empty string and reports something unrelated to what happened.

### 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 (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 name you subscribe to and the key that carries the event are different words:

Subscribed fieldKey on the event
messagesmessage
message_editmessage_edit
message_reactionsreaction
messaging_seenread

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.

Replace semantics also decide when to make the call. The set you want is not fixed: the day you start handling reactions, every channel connected before that day is subscribed to a list that does not include them, and the symptom is that the feature works for recent accounts and not for older ones. Since the call is idempotent and always carries the full list, you do not need a migration for that. Send it wherever you already hold a working token for the channel, which means after a reconnect and after a successful token refresh (§8.3). Channels converge on the current set on their own, and the ones left behind are those nobody has touched in sixty days, which have a bigger problem than their field list.

### 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:

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:

  1. 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.
  2. 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.
  3. 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 stops the handler and raises an alert. What it answers on the wire is a separate decision, and §6.2 makes 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")):
        log.error("instagram webhook signature mismatch")   # alert on this — §9.2
        return Response(status=200)
    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.

A non-200 asks for redelivery, and that is the only thing it does. Meta retries with backoff and disables the subscription after a run of failures, so the status you return is a decision about whether another copy of these exact bytes would do you any good.

It would not. A signature mismatch means either that the request did not come from Meta, in which case the sender does not care what you answer, or that your App Secret is wrong, in which case every delivery fails the check and rejecting each one costs you the subscription. A deploy that ships the wrong environment variable then becomes an outage you have to undo by hand in the dashboard, with the events from that window gone. The same holds for a payload you cannot parse or a bug in your handler: the identical bytes arrive again and meet the identical code. Answer 200, keep the body where a person can find it, and alert (§9.2).

Redelivery is worth asking for in one case: you are temporarily unable to accept the event at all, because the queue is unreachable or the process is coming up. Returning 200 there would acknowledge something you never took responsibility for. Weigh it against the outage lasting long enough to take the subscription with it, which is the one situation where a run of failures is guaranteed.

### 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,
      "messaging": [
        {
          "sender":    { "id": "98123..." },   // IGSID of the end user
          "recipient": { "id": "178414..." },  // your IG-User-ID
          "timestamp": 1712948120000,
          "message": {
            "mid":     "aWdfZGl...",
            "text":    "Hi, do you ship to Berlin?",
            "is_echo": false,
            "attachments": [
              { "type": "image",
                "payload": { "url": "https://lookaside.fbsbx.com/..." } }
            ]
          }
        }
      ]
    }
  ]
}

The instagram object delivers two different payload shapes. Messaging events arrive as entry[].messaging[], shown above. The comment and media fields of the same object (comments, mentions, live_comments, story_insights) arrive as entry[].changes[] carrying a {field, value} pair, which is also the shape the WhatsApp object uses throughout. Both are reachable from the same subscription screen, and the reference documents them on separate pages. Check which shape your fields produce before you write the parser.

Each element of entry[].messaging[] is one event. It carries sender, recipient, timestamp and exactly one payload key: message, message_edit, read or reaction. That key is what identifies the event. The name of the subscribed field that produced it stays on Meta's side and is not part of the delivery.

timestamp is milliseconds since the epoch, as an integer, and so is entry[].time. Neither is a routing input: order your messages by the id you assign on write, not by a clock you do not control.

Field-by-field:

### 6.4 Routing to a channel

Route each event in entry[].messaging[] on its own:

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 — recipient.id on an inbound message, 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 events; 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.

Two messages from a customer you have never seen arrive as two webhook deliveries, and nothing makes them land on the same worker. Both copies of upsert_client miss the lookup, both fetch the profile, and both insert. Put a unique index on (channel_id, igsid) and let the insert decide: on conflict, read the row the other worker wrote and use its client. Checking find first stays worthwhile as the fast path, since after the first message every delivery hits it, but the check is an optimisation and the index is the correctness. The loser of the race also pays for a profile fetch it throws away, which is an argument for holding a lock across the whole function if your stack has one handy, and not much more than that.

### 6.6 Persisting and dispatching

The full inbound consumer:

ENVELOPE = ("sender", "recipient", "timestamp")

def classify(ev):
    """Which kind of event is this? Decided by which payload key is present."""
    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 ev in entry["messaging"]:
            kind = classify(ev)
            if kind is None:
                log.warn("uninterpretable instagram event",
                         entry_id=entry["id"],
                         keys=[k for k in ev if k not in ENVELOPE])
                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" if ev["message"].get("is_deleted"):
                    delete_message(chat, ev["message"]["mid"])        # §6.8
                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 comes from the payload key the event carries. Nothing else is available: the subscribed field name never reaches your handler, so the key is the whole signal. An event carrying none of message, message_edit, read or reaction is something your handler does not model, and it must not turn into a message.

Unrecognised is logged, not silently skipped. Log the entry id and the names of the keys you did not recognise. Leave the values out of the log; they 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. Two groups behave differently:

Instagram typeWhat it ispayload
imagephotourl serves the media itself, from Meta's CDN
videovideo
audiovoice message
filedocument
ig_post, posta shared posturl serves an Instagram page; post and reel shares also carry title and id
ig_reel, reela shared reel
story_mentiona story that mentioned the account
ephemeralmedia that expiresno url at all

Meta lists the inbound types in the Instagram messaging webhook reference.

The second group is where a four-type mapping loses messages. Its url serves an Instagram page, so the downloader below saves a copy of HTML and reads the MIME type as text/html. A share carries no message text of its own, so dropping the attachment drops the message with it: the customer sees a post they sent, the operator sees an empty thread. Put the title and the link on the message as text, and reserve the download path for the first group. Meta adds types to this list, so give an unrecognised type with a url the same treatment.

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 no dimensions or duration yet. Resist writing zero into those columns. A zero width or a zero duration reads as "broken" to anything downstream that validates attachments, and a renderer that skips malformed media will skip these too. Make the columns nullable, or let status be the only thing that says the file has not arrived. 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

  1. Receive (chat_id, message_id, attachment_id, download_url).
  2. Check the URL has a scheme and a host before handing it to an HTTP client. It came from outside.
  3. 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.
  4. 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.
  5. Move the bytes into your own storage, set status = downloaded, record the real filename.
  6. On any failure set status = failed and stop. A dead end beats a row that claims to have a file it does not have.
  7. 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 and deletions

Both events name a message you already stored, by the same mid you saved it under, and neither can create anything.

#### 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):
    if new_text is None:
        log.warn("edit without text", mid=mid)
        return
    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)

That first guard earns its place: message_edit sometimes arrives with no text field at all. Write the missing value through and you blank the stored message, so the operator is left with an empty bubble where the customer's words were, while the customer's own thread still shows the text they just typed.

#### Deletions

A customer can unsend a message they have already sent. It arrives as a message event with is_deleted set, naming the mid and carrying no text, so a handler that only looks at the message key treats it as something new to show. The result is an empty bubble added to the thread while the message it was meant to withdraw stays where it was. Branch on is_deleted before the create path:

def delete_message(chat, mid):
    existing = chat_message_repo.find_by_external_id(chat.id, mid)
    if existing is None:
        log.warn("unsend for unknown mid", mid=mid)
        return
    chat_message_repo.mark_deleted(existing)
    realtime.publish_message_deleted(chat.id, existing.id)

Mark the row deleted rather than removing it. The thread should be able to show that something was withdrawn, and an operator who already read it will ask what it said.

An unknown mid ends here. Whatever the unsend refers to was never stored, and Meta redelivering the event will not change that, so log it and move on.

### 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:

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 carrying a reaction in place of a message: the mid that was reacted to, an action of react or unreact, a reaction name and the emoji itself.

{
  "sender":    { "id": "98123..." },
  "recipient": { "id": "178414..." },
  "timestamp": 1712948120000,
  "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 carrying a read:

{
  "sender":    { "id": "98123..." },
  "recipient": { "id": "178414..." },
  "timestamp": 1712948120000,
  "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.)

The text limit is 1000 bytes of UTF-8, not 1000 characters. A Cyrillic reply runs out around 500 characters and one made of emoji around 250. Measure the encoded length, not the string length.

Meta has no error code for exceeding it. An over-long message comes back as code 100, the generic "invalid parameter" that also covers a dozen unrelated mistakes, so the response will not tell you which of them you hit. Check the length yourself, before the message is written to your database: the operator can shorten a reply they are still looking at, and can do nothing about one already sitting in the thread.

### 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. Inbound has a wider set (§6.7): shares and story mentions arrive, and none of them can be sent.

A single attachment goes in message.attachment as an object; several go in message.attachments as an array.

Accepted formats and size ceilings (8 MB for images, 25 MB for audio, video and files) are in Send Messages, along with the text limit of §7.2.

Both routes hand Meta a URL and let it fetch the bytes itself. What differs is when that fetch happens.

Fetched during the send. Put the URL in payload.url, as in the body above. Meta pulls the bytes while your POST is in flight, so the send takes as long as your storage takes to serve the file, and it does that again for every recipient.

Fetched once, ahead of the send. Register the URL through the Attachment Upload API, which answers with an attachment_id, and send the id instead:

{ "recipient": { "id": "<IGSID>" },
  "message": {
    "attachment": {
      "type": "image",
      "payload": { "attachment_id": "<attachment-id>" }
    }
  } }

The upload takes the same attachment body you would have sent, with is_reusable set on the payload beside the URL. One registered file then goes to any number of recipients without Meta fetching it again, which is what pays for the extra round trip. url and attachment_id can appear in the same message.

Meta documents the upload call itself only under the Page-mediated flow, as POST /<PAGE_ID>/message_attachments with platform: "instagram". The Business Login pages name the API and show attachment_id on the send, but do not spell out the upload request. Capture it before you build on it.

Either route needs a URL public enough for Meta and private enough for you:

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:

A reply the provider refuses keeps its local id forever: there is no provider id to adopt. Mark it failed once the retries are done, not on the first error, or a message that succeeds on the second attempt will spend the minute in between telling the operator it never arrived. Give the mark a reason, resolved from the error code of §7.6 rather than from Meta's message string. "Not delivered" on its own tells an operator to retype the reply and send it again, which is the wrong move for most of the codes in that list.

### 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.

  1. 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.
  2. 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.
  3. 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..."
  } }

Retriability is a property of the code, not of the status. Meta does not use 429. Throttling arrives as an HTTP 400 carrying 4, 17, 32 or 613 in the body, and so do its transient failures. A retry policy written against the status line treats every one of them as a permanent rejection and sends the message to a dead-letter queue on the first attempt, which reads in the log as a refusal rather than as a request to wait.

Classify on the code. Where a subcode is present it narrows the code down, and for 10 that distinction decides whether the operator can do anything: a bare 10 is a permission error, and 10 with subcode 2534022 is the 24-hour window having closed.

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

A 24-hour window starts at the user's last inbound message. Inside it you can send anything; outside it a free-form message comes back as code 10, subcode 2534022 (§7.6).

HUMAN_AGENT is the only tag Instagram accepts. It extends the window to seven days from the last inbound message. Sending it without the Human Agent feature granted to your app fails the send. That grant is separate from the messaging permissions you cleared at App Review, and Meta can withhold it without saying why, so keep the tagged path behind a switch until it arrives. Tag only messages a person wrote.

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. Past seven days there is nothing left to allow: the thread stays closed until the customer writes again. 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:

## 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_lte(threshold)
    refreshed, failed = 0, 0
    for channel in channels:
        if channel.access_token_expires_at <= now():
            log.warn("token past expiry, reconnect required",
                     channel_id=channel.id)               # §8.4
            continue
        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
            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 turns that buffer into about seventy attempts instead of three, which is what makes a bad afternoon at Meta or a long deploy a non-event.

The guard at the top of the loop keeps the pass off the network for tokens that are already dead. Past its expiry a token cannot be refreshed at all (§8.4), so the call is certain to fail, and an hourly cron makes that twenty-four certain failures a day for a channel nobody may ever reconnect. Those requests count against your app's rate limits and fill the log with 190, which is where you look for real problems.

Meta refuses to refresh a token less than 24 hours old. The hourly pass never meets this rule: a channel connected today has an expiry sixty days out and comes nowhere near the three-day threshold. You meet it by refreshing on demand, so pressing the manual trigger of §8.5 right after connecting an account gets you turned away. That is the rule working, not the channel being broken.

Record the refusal, and let the scheduler read it. When Meta rejects a token for good — 190, or 102 for a session checkpoint — write that down as a reauthorization_required_at on the channel. The objection to such a column is that it duplicates state you could derive, and that objection applies to a different question. Is this channel working right now is derived, and a "check connection" endpoint that reads the subscription back and does the expiry arithmetic is the right way to answer it for somebody looking at a panel. Meta refused this token at 14:02 is an event you observed, and nothing recomputes it for you.

The scheduler is what needs it written down. A revoked token keeps a valid-looking expires_at, so the guard above does not catch it, and the hourly pass will spend a request on that channel every hour until somebody reconnects. Filter those rows out of the query instead. Write the mark once and leave the timestamp alone on later failures, so it keeps saying when the connection broke rather than when you last noticed, and clear it when a fresh Business Login succeeds for the account.

### 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:

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:

### 8.5 Manual trigger

Provide a CLI command that runs the same logic on demand:

$ app:instagram:refresh-tokens

Useful after incidents, when the cron has not run for a day. Running it immediately after connecting an account does nothing: that token is both too new to refresh and nowhere near expiry.

## 9. Operational checklist

### 9.1 Idempotency

### 9.2 Observability

### 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

### 9.5 Local development

### 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:

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.
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.