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

### 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 fieldKey in value
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.

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

### 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 typeWhat it is
imagephoto
videovideo
audiovoice message
filedocument

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

  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 — pendingdownloaded | 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:

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

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

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

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:

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:

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

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