# 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:
- public comments on posts
- Stories publishing
- the user's followers list
- DMs from non-business accounts you do not control
You will need a Meta Developer app of type Business, with the Instagram and Webhooks products enabled, valid privacy-policy and terms-of-service URLs, a registered redirect URI, and (for production) Meta app review approving the requested scopes: instagram_business_basic, instagram_business_manage_messages.
The six concerns we cover, in order:
- OAuth login — getting an access token for the connected account.
- Storing the token alongside its expiry.
- Subscribing the connected account to webhook deliveries.
- Receiving and validating inbound webhook events.
- Sending outbound messages.
- Refreshing tokens before they expire.
A seventh section covers operational concerns — idempotency, retries, observability, error handling — that production deployments need.
## 2. Architecture overview
┌──────────────────┐
Browser ───▶│ OAuth login │ ──▶ Token store (DB, one row per channel)
└──────────────────┘ ▲
│ refresh
┌────────┴────────┐
│ Token refresher │ (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 var | What it is |
| IG_APP_ID | Public ID of your Instagram app |
| IG_APP_SECRET | Secret of your Instagram app |
| IG_REDIRECT_URI | OAuth callback URL — must match exactly what you send |
| IG_WEBHOOK_VERIFY_TOKEN | A long random string you choose for the GET handshake |
| IG_GRAPH_BASE_URL | https://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:
- IG_APP_ID and IG_APP_SECRET — shown on the use-case page itself.
- IG_WEBHOOK_VERIFY_TOKEN — 3. Configure webhooks.
- IG_REDIRECT_URI — 4. Set up Instagram business login → Business Login Settings.
Note: these are not the Meta app's ID and secret. Your Meta app has its own ID and secret on Settings → Basic; the Instagram app — added to it under Use cases → Manage messaging & content on Instagram — is a separate entity with its own ID and secret. The OAuth exchanges and webhook signature in this article all expect the Instagram ones; pasting the Meta values will fail with non-obvious authentication errors.
### The 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.
| Secret | Used by Meta to ... | Used by you to ... |
| IG_WEBHOOK_VERIFY_TOKEN |
Echo it back, exactly once, when you save the webhook URL |
Compare-equal during the GET challenge — that is its only job |
| IG_APP_SECRET |
Sign every POST webhook body with HMAC-SHA256, and accept it on OAuth exchanges |
(a) Verify X-Hub-Signature-256 on POST webhooks, (b) include in OAuth token-exchange calls |
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 CSRF guard — verify the callback originated from a request you issued, not from a forged link in someone's mailbox; and
- the internal user id of the operator who initiated the connection — so the callback knows which application user to attach the new channel to.
A signed JWT works for both jobs. Sign it with one of your service-internal keys; do not reuse the App Secret here. A typical claim shape:
{ "user_id": "uuid-of-internal-user", "exp": 1715000000, "iat": 1714999400 }
Keep exp short — ten minutes is plenty; the user is mid-flow.
### 4.3 Handle the callback
The user authorises. Instagram redirects the browser to your IG_REDIRECT_URI with two query parameters:
GET <IG_REDIRECT_URI>?code=AQB...&state=<state-jwt>
Validate state first. If the JWT signature is bad or exp is past, respond 400 and stop. Decode the JWT and pull the internal user id — you'll attach the new channel to that user.
### 4.4 Exchange code for a short-lived token
POST https://api.instagram.com/oauth/access_token
Content-Type: application/x-www-form-urlencoded
client_id=<IG_APP_ID>
&client_secret=<IG_APP_SECRET>
&grant_type=authorization_code
&redirect_uri=<IG_REDIRECT_URI>
&code=<code-from-callback>
Response:
{ "access_token": "IGQVJ...",
"user_id": 178414...,
"permissions": ["instagram_business_basic", "instagram_business_manage_messages"] }
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 Secret — not the verify token. Section 6.1 shows the verification.
## 6. Receiving messages
### 6.1 Signature verification — the first thing your handler does
Every inbound POST from Meta carries:
X-Hub-Signature-256: sha256=<hex-digest>
The digest is HMAC-SHA256(app_secret, raw_request_body). To verify:
def verify_signature(raw_body: bytes, header: str | None) -> bool:
if header is None or not header.startswith("sha256="):
return False
provided = header[len("sha256="):]
expected = hmac_sha256_hex(config.IG_APP_SECRET, raw_body)
return constant_time_equal(expected, provided)
Three pitfalls to mark with red ink:
- The HMAC key is the App Secret, not the verify token. The verify token from §5.1 is irrelevant here and must not appear in this code path. If your signature check is failing, double-check the key — this is the single most common bug.
- HMAC the raw body. Most web frameworks parse JSON for you and silently re-serialise it on access. Re-serialised JSON does not byte-match the raw body — extra whitespace, sorted keys, escaped characters all break the signature. Read the request body as bytes before any parser sees it.
- Ignore X-Hub-Signature (SHA-1). Meta also sends a SHA-1 digest in that header for backward compatibility. Verify only the SHA-256 header and reject if it is missing.
A failed signature check returns 403 with no body. Do not echo the error back to the caller; Meta does not need it.
### 6.2 Acknowledge fast
Meta requires a 200 response within a few seconds. Beyond that, it retries with backoff and may eventually disable the subscription. So your handler does the minimum:
def webhook_post(request):
if not verify_signature(request.raw_body, request.headers["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:
- entry[].id — the IG-User-ID of your connected account that received the message. Use this to look up which channel the event belongs to. This matters because one app can serve many connected accounts.
- messaging[].sender.id — the IGSID (Instagram-Scoped ID) of the end user who sent the message. This is the recipient ID you use when replying. (Unlike older Page-mediated Messenger flows there is no separate PSID lookup; the IDs are symmetric.)
- messaging[].message.mid — Meta's globally-unique message id. Store it as the message's external id; you use it for deduplication and to find the message later when an edit event arrives.
- messaging[].message.text — the text body, optional.
- messaging[].message.attachments[] — each has a type (one of image, video, audio, file) and a payload.url pointing to the media on Meta's CDN.
- messaging[].message.is_echo — true when the event is a copy of an outbound message your own app just sent; suppress these or you will store every reply twice.
### 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:
- Download the bytes promptly — within the same job, on a separate worker.
- Store them in your own object storage (S3, a CDN-fronted bucket, …).
- Persist a local media reference on the message, not the Meta URL.
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:
- Receives (chat_id, message_id, attachment_type, download_url).
- GETs the URL.
- Uploads the bytes to your storage.
- Writes the resulting URL onto the message attachment record.
- 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:
- 190 (OAuthException) — token expired or revoked. Mark the channel "needs reconnect", stop retrying, surface to whoever owns the connection so they can re-authorise. Do not requeue; retrying will not help.
- 4, 17, 32, 613 — rate-limit family. Requeue with exponential backoff. If the response carries an X-Business-Use-Case-Usage header, honour the estimated_time_to_regain_access value rather than guessing.
- 10, 200-family — permission errors. Usually a missing scope after Meta tightened a policy. Surface and block further sends until the account is re-authorised.
- 5xx — transient. Retry with standard backoff; don't escalate.
Always log fbtrace_id. 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:
- Typing indicators (POST /me/messages with sender_action: typing_on|typing_off|mark_seen).
- Quick replies — message.quick_replies[] on outbound payloads.
- Message reactions — both directions.
- Read receipts — both directions.
- Message tags — required for the post-24h-window case above.
- Stories replies — different inbound payload shape.
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:
- concurrencyPolicy: Forbid stops a slow run from being lapped by the next schedule tick. Two parallel refreshers race on the same row, and one will overwrite a token the other just refreshed — at best wasted work, at worst a clobbered expires_at.
- backoffLimit: 2 retries the job; restartPolicy: OnFailure retries the container inside the same pod. Together: transient pod hiccups retry locally, persistent failures stop after a few attempts instead of looping forever.
- activeDeadlineSeconds is the runaway guard. The refresh loop should finish in seconds; if it's running for ten minutes something is wrong and the pod should be killed well before the next schedule tick fires.
The app:instagram:refresh-tokens command (§8.5) is reused as-is here — exactly the same logic the cron drives, just invoked by k8s now.
If you already run a controller-heavy stack, the heavier alternative is an operator: a Channel CRD with status.tokenExpiresAt, and a controller that requeues each channel for reconciliation as that timestamp approaches. Each channel becomes its own scheduled refresh rather than being picked up by a daily batch scan, which scales better past a few thousand channels and gives per-channel observability for free. For most deployments it's overkill — reach for it only if the operator pattern is already your platform's idiom.
### 8.4 What can go wrong
A refresh requires the current token to still be valid. If the token has already expired, refresh_access_token returns OAuthException and you cannot recover automatically — the user must reconnect. Two implications:
- The cron must run more often than the buffer minus the longest plausible outage. Daily with a 3-day buffer tolerates two missed runs.
- A 190 from refresh_access_token is the signal to stop trying and surface the channel to its owner with a "reconnect" button. Don't keep the row's expires_at as-is and pretend the token still works.
### 8.5 Manual trigger
Provide a CLI command that runs the same logic on demand:
$ app:instagram:refresh-tokens
Useful after incidents (cron didn't run for a day), and on any fresh deployment with imported data where expires_at is unknown — the threshold query catches expires_at IS NULL rows on the first run.
## 9. Operational checklist
### 9.1 Idempotency
- Inbound: dedupe by message.mid. Meta retries delivery on any non-200 response (and sometimes on 200 too). Your inbound writer must be safe to call twice with the same mid. A unique index on (channel_id, external_id) is the cheapest enforcement.
- Outbound: dedupe by your internal chat_message.id. Use it as the request idempotency key in your queue so a consumer crash does not send the same message twice.
### 9.2 Observability
- Log every Graph API call's HTTP status and the fbtrace_id from the response body. Meta support cannot help without it.
- Alert on tokens expiring within 7 days that have failed to refresh.
- Alert on webhook signature verification failures > 0 over any window — it means either someone is spoofing webhooks or your App Secret rotated without the deployment picking it up.
- Track outbound consumer lag and inbound queue depth as separate dashboards.
### 9.3 Rate limits
Meta returns X-Business-Use-Case-Usage headers 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
- Never log access tokens — they are bearer credentials.
- Encrypt access_token at rest if your threat model demands it.
- Rotate the App Secret? Be aware it breaks both signature verification on inbound webhooks and OAuth flows in progress. Coordinate the rollout: deploy code that accepts both old and new secrets, rotate, then remove the old.
### 9.5 Local development
- Tunnel the webhook URL with ngrok / Cloudflare Tunnel — Meta needs a publicly reachable HTTPS endpoint.
- The Meta dashboard's "Test webhook" button replays representative payloads to your URL so you can develop without a real Instagram message.
- The /me/subscribed_apps and /refresh_access_token calls are safe to hit repeatedly with the same token; they're effectively idempotent.
## 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.