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

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.

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 │   (cron, daily)
                                       └─────────────────┘

   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

Five 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 — must match exactly what you send
IG_WEBHOOK_VERIFY_TOKENA long random string you choose for the GET handshake
IG_GRAPH_BASE_URLhttps://graph.instagram.com/v25.0 (constant)

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 two-secrets-two-jobs rule

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

Pin this rule. The verify token authenticates the URL once. The App Secret authenticates each event afterwards. If you find yourself reaching for the verify token while validating an inbound event, stop — you're using the wrong key.

HTTPS is mandatory on both IG_REDIRECT_URI and your webhook URL — Meta refuses HTTP for either.

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

### 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"] }

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

    existing = channel_repo.find_by_external_id(profile.user_id)
    if existing:
        existing.access_token = long.token
        existing.access_token_expires_at = now() + long.expires_in
        channel_repo.save(existing)
        channel = existing
    else:
        channel = Channel(
            owner_user_id        = user_id,
            external_id          = profile.user_id,
            name                 = "Instagram @" + profile.username,
            access_token         = long.token,
            access_token_expires_at = now() + long.expires_in,
        )
        channel_repo.create(channel)

    enable_webhook_subscription(channel)  # see §5.2
    return redirect(app.settings_url)

The idempotent upsert by external_id is important: a user reconnecting the same Instagram account should refresh their channel's token in place, not create a duplicate.

## 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/v25.0/me/subscribed_apps
     ?access_token=<channel-access-token>
     &subscribed_fields=messages,message_edit

Response:

{ "success": true }

Without this call, the dashboard webhook is wired but no events flow for the connected account. With it, Meta starts streaming messages and message_edit events for that account to the URL you registered.

Other available fields exist (message_reactions, message_postbacks, …). Adding them is a code change in your handler, not a config tweak — your handler must understand the new payload shape.

### 5.3 Unsubscribing

When a user disconnects the channel, do the inverse:

DELETE https://graph.instagram.com/v25.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.

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 Secretnot 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 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["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; entries may bundle several):

{
  "object": "instagram",
  "entry": [
    {
      "id": "178414...",          // your IG-User-ID — selects the channel
      "time": 1712948123,
      "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/..." } }
            ]
          }
        }
      ]
    }
  ]
}

Field-by-field:

### 6.4 Routing to a channel

def find_channel(payload):
    entry = payload["entry"][0]                # one entry per event
    return channel_repo.find_by_external_id(entry["id"])

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 /v25.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 inbound_consumer(payload):
    for entry in payload["entry"]:
        channel = channel_repo.find_by_external_id(entry["id"])
        if channel is None:
            continue
        for ev in entry["messaging"]:
            msg = ev.get("message") or {}
            edit = ev.get("message_edit")
            if msg.get("is_echo"):
                continue
            client = upsert_client(channel, ev["sender"]["id"])
            chat   = chat_repo.get_or_create(channel.id, client.id)
            if edit:
                update_message_text(chat, edit["mid"], edit["text"])
            else:
                persist_inbound_message(chat, client, msg)
                for att in msg.get("attachments", []):
                    download_queue.publish(chat.id, msg["mid"], att["type"], att["payload"]["url"])

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 separate queues are visible in the consumer: the inbound queue the webhook handler published to, and the download queue the consumer publishes to.

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

If you defer the download until the operator scrolls into view, you will ship a product where last-week's attachments are dead links.

The downloader is a small worker that:

  1. Receives (chat_id, message_id, attachment_type, download_url).
  2. GETs the URL.
  3. Uploads the bytes to your storage.
  4. Writes the resulting URL onto the message attachment record.
  5. Notifies the UI to refresh.

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

## 7. Sending messages

### 7.1 Endpoint

POST https://graph.instagram.com/v25.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-cdn.example.com/abc.jpg" } }
    ]
  } }

type is one of image, video, audio, file. payload.url must be a publicly reachable HTTPS URL — Meta's servers fetch it during the API call. There is no two-step "create attachment id, then send" upload, unlike some other Meta-adjacent APIs (and unlike Telegram, WhatsApp Business, …).

### 7.4 Response

{ "message_id": "aWdfZGl...", "recipient_id": "98123..." }

Persist message_id as the external id of your outbound message. You will see the same id again moments later in an inbound is_echo: true event, which you have already learned to suppress.

### 7.5 The async pipeline

Operator UI
    │
    ▼
HTTP API:  persist outbound chat_message 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: store returned message_id as external_id,
                   set chat_message.state = sent
    6. On failure: classify error (see §7.6), retry or fail

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

Meta returns errors in a consistent envelope:

{ "error": {
    "message":  "...",
    "type":     "OAuthException",
    "code":     190,
    "fbtrace_id": "Az..."
  } }

Classify by code:

Always log fbtrace_id. It is the only thing Meta support will ask for.

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

The integration described here does not enforce this window — outbound sends just go out and Meta returns an error if it's past the window. A production-grade implementation would: track last_inbound_at per chat, refuse free-form sends past 24 hours, allow tagged sends with an explicit business reason. Treat this as a known gap to close before launch.

### 7.8 What this integration does not do (yet)

So you know what to add:

Each is a code change in the handler/sender plus, in some cases, a new subscribed field on the webhook.

## 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/v25.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 a job daily. Find every channel where the token is approaching expiry, and refresh:

REFRESH_THRESHOLD_DAYS = 3

def refresh_tokens_cron():
    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:
            channel_repo.mark_needs_reconnect(channel.id)
            notify_owner(channel)
            failed += 1
    return refreshed, failed

The 3-day buffer absorbs cron failures, deploy windows, and weekends. With a daily cron, every channel gets at least three refresh attempts before it expires.

### 8.3a Running it on Kubernetes

If the app is deployed to Kubernetes, the natural translation of the daily cron is a CronJob that runs the same refresh_tokens_cron() 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 3 * * *"           # 03:00 UTC daily
  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 (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

### 9.2 Observability

### 9.3 Rate limits

Meta returns X-Business-Use-Case-Usage headers on every response with the current account's call_count, total_cputime, and estimated_time_to_regain_access. Record them. When throttled, requeue the failed job with at least the suggested wait.

### 9.4 Secret hygiene

### 9.5 Local development

## 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.
IG Business Account
an Instagram account in Business or Creator mode, eligible for the Business Login flow. Personal accounts are not eligible.
Page
a Facebook Page object. Older Instagram integrations were mediated through a Page; the Business Login flow we use here is not.
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 your own outbound message being delivered. Suppress.

## A note on what this article is

This is a behaviour spec, distilled from a working production implementation. Every endpoint, header, query parameter, and field name above is what flows on the wire today against Graph API v25.0. 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.