Architecture
Stack, Meta's constraints, routes, the processing pipeline, cron jobs, crypto, admin auth, and the places the code deliberately departs from the original spec.
One deployment serves one person. Each operator has their own Cloudflare account, Worker, D1 database and Meta app. There's no signup, no multi-user model and no billing. A deployment can manage several Instagram accounts, and they all belong to the same person, who uses the admin panel and never needs to see the code.
The Meta app must be Live (published) for real comment webhooks; in Development, Meta only delivers the dashboard's Test button. Tester accounts can usually go Live without App Review.
Stack
- Cloudflare Workers, TypeScript, ESM.
- Hono with the default Workers export (
fetchandscheduled), nothono/vercel. Hono runs with{ strict: false }, so/a/:secretand/a/:secret/both work. - D1 through the
DBbinding, with raw parameterised SQL (.prepare().bind()) and no ORM. - Schema in numbered
migrations/*.sqlfiles, applied in order by Wrangler's tracked runner (wrangler d1 migrations apply), which records each one ind1_migrationsand never runs it again.npm run deployapplies them before deploying. - Server-rendered HTML through
hono/html. No React, no Tailwind, no bundler, no client framework, and one<style>block in the layout. - One runtime dependency,
hono.wrangler,typescriptand@cloudflare/workers-typesare dev dependencies. nodejs_compatis off and nothing importsnode:crypto.
What happens to a comment
- Meta POSTs a signed webhook when someone comments on a connected account's post. In Development this usually never happens, except for the Test button.
- After Connect, and again on Home and in the daily cron, the Worker POSTs
/{ig_user_id}/subscribed_appswith JSON{ subscribed_fields: ["comments"] }. The Meta dashboard's Generate access tokens → Webhook subscription toggle must also be On for each Instagram account. - The Worker verifies the signature against both app secrets, logs a
webhook_eventsrow and the time inlast_webhook_received_at, routes onentry.idto an account, and matches the comment text against that account's rules. - On a match it sends one DM through Private Replies (
recipient.comment_id, not the commenter's user ID), and optionally a public reply on the comment. When the account has used its share of Instagram's hourly limit, the comment waits in a queue instead. - A cron runs every minute and sends what's queued. Every fifth minute it first polls recent comments on recent media (and on media that rules are scoped to) and queues the ones a rule matches. That covers missed webhooks and Development mode.
- Everything is logged. The operator manages accounts and rules at
/a/:secret. The legal pages at/privacy,/termsand/data-deletionexist so Meta will allow the app to be published.
Follow-up DMs are out of scope. They need the person to reply first (a 24-hour window) or App Review for the 7-day human-agent extension. One DM, with everything in it.
Meta's hard constraints
- Self-comment guard. Never act when
from.id === account.ig_user_id: webhooks include the operator's own comments.isSelfCommentinsrc/guard.tsruns before any send or dedupe work, after the account lookup, and is unit-tested inscripts/test-unit.ts. - One private reply per comment, ever. Meta rejects a second attempt; the Worker deduplicates anyway.
- About 750 private replies per account per hour. The Worker counts its DM calls per account in one-minute windows and makes at most 12 in each. A slot is taken up to 40 seconds before its call, so an hour's calls fall in at most 62 windows: at most 744, under 750. A comment past that waits in the send queue.
- At-least-once delivery. Duplicate POSTs are normal. Dedupe is
INSERT … ON CONFLICT DO NOTHINGonsent.comment_id; ifmeta.changesis 0, stop. A DM call happens only after a statement that moved its row topendingchanged exactly one row, then the outcome isUPDATEd. Never SELECT-then-INSERT. - Private replies only work on comments up to 7 days old. Older ones are skipped without calling Meta.
- Verify every POST against
X-Hub-Signature-256using the raw body text, beforeJSON.parse; re-serialising breaks the HMAC. Either app secret is accepted. A mismatch gets a 401 and inserts abad_sigrow inwebhook_events, at most one a minute, checked in D1 so it holds across isolates, so Home isn't silent and a flood can't use up D1's daily writes. Home's "signing secret did not match" banner needs the three newest signed deliveries to all bebad_sig. A POST with no signature header is never Meta, so it gets a 401 and no row. A body declared or found to be over 256 KB gets a 413 before the rest is read. - Long-lived tokens expire in 60 days, and an unrefreshed token dies permanently. A refresh needs the token to be at least 24 hours old and not yet expired.
- About 200 calls per account per hour. No tight retry loops. Every attempt times out after 10 seconds.
- A DM or public reply gets exactly one attempt. Neither endpoint takes an idempotency key, so a request that timed out, got a 5xx, lost its connection or had its answer cut off may already have been carried out, and sending it again could deliver the DM twice or post the reply twice. Those outcomes are recorded as
unsure, "may have been delivered" (or for a public reply,failed, "may have been posted"), and never sent again by the Worker. Only a 4xx, which Instagram writes when it turns a request down, proves nothing went out; its body is logged verbatim, and the retry policy decides whether the DM waits to try again. A send has a deadline (see the pipeline below): its attempt never runs past it, and one that wouldn't get at least 2 seconds isn't started. - Reads are retried. The other calls (listing comments and media, token refresh and exchange, the comment subscription) are safe to repeat: 5xx, 429 and network failures, timeouts included, get exponential backoff, 3 attempts, jittered. Other 4xx responses are never retried.
Routes
| Method | Path | Role |
|---|---|---|
| GET | / | ok, a deploy smoke check |
| GET, HEAD | /health | For uptime monitors. No auth, no-store, noindex. One D1 read: account counts and the two job timestamps. 200 healthy, 503 if D1 fails or a job is stale. No IDs, usernames or secrets. |
| GET | /privacy, /terms, /data-deletion | Static HTML for Meta's Publish step |
| GET, HEAD | /favicon.svg, /favicon.ico | The app icon as SVG, and as a PNG at the path browsers try by default. Every page links the SVG. Public and cached for a week, because the icon is the same for every deployment. |
| GET | /webhook | Meta's verify handshake. The right hub.verify_token echoes hub.challenge; a wrong one gets 403. |
| POST | /webhook | Verify the signature (either secret), log the event, return 200 immediately, process in waitUntil |
| GET | /connect | Logged-in admin session required, else a plain 403 page. Checklist with the exact OAuth redirect URIs and Instagram App ID, then continue. Passes ?reconnect=1 through. |
| GET | /connect/start | Logged-in admin session required, else 403. Sets a signed, 10-minute state and a matching igcdm_connect cookie, then 302 to Instagram Business Login (force_reauth when ?reconnect=1). |
| GET | /connect/callback | Requires a valid, unexpired state and the igcdm_connect cookie that matches it, before any call to Instagram. Code → short-lived token → long-lived token, fetch user_id and username, encrypt, upsert the account. error=access_denied (Cancel on Instagram's screen) gets a plain "Not connected" page linking to /connect/accounts. |
| GET | /connect/accounts | Logged-in admin session required, else 403. 302 to the admin Accounts page, so public pages can offer a way back without naming the admin path. |
| GET, HEAD | /a/:secret/manifest.webmanifest, /a/:secret/icon.svg, /a/:secret/icon-192.png, /a/:secret/icon-512.png | The web app manifest and home-screen icons. No login, because browsers fetch them without cookies; a wrong secret still gets 404, and they're no-store. Mounted ahead of the admin routes so the login middleware never sees these four GETs. |
| GET/POST | /a/:secret/* | Admin. :secret is ADMIN_URL_SECRET (32 characters). A wrong secret gets 404. |
Admin pages, with a password and CSRF on every POST: / (dashboard), /logs (every sent row, keyset-paginated on sent_at then comment_id, with its filters in the query string), /rules (with /rules/new, /rules/:id/edit and POST /rules/:id/toggle), /rules/:id/delete (a confirmation page, then a POST), /test (a dry run that sends nothing, choosing the rule through the same chooseRule as the pipeline), /accounts (with POST /accounts/:id/deactivate and /activate for Pause and Resume), the first-run /setup, /login, /logout, POST /run-cron ("Check connections now") and POST /sends/:id/retry (Send again on an unsure row, from Home or Logs).
Three unlisted, session-gated endpoints are used by scripts/selftest.ts: POST /a/:secret/selftest/seed, GET /a/:secret/selftest/status and POST /a/:secret/selftest/cleanup, which deletes the fake account with its sends and the webhook rows that name it.
Processing pipeline
For each comment change. Only field === "comments" is handled. Instagram Login sometimes puts field and value on the entry instead of in changes[], and both shapes are handled. Oversized numeric IDs in the JSON are quoted before JSON.parse, so they aren't rounded.
- Look up the account by
entry.id. Unknown: drop silently. The account and its rules are read once per delivery. - Self-comment guard. Stop, with no
sentrow. - Active rules, oldest first (
chooseRuleinsrc/process.tsandadmitCommentinsrc/queue.tsboth callmatchCommentinsrc/match.ts, as the poll does). A rule withmedia_idset applies to that post only;NULLapplies to all posts. A comment on a boosted post's ad carries the ad's own media ID, with the boosted post's inmedia.original_media_id; a rule for either ID applies, and the original's rules come first. The poll reaches those comments through the post itself, because Instagram lists a boosted post's ad comments with its own. The order is keyword rules for this post, keyword rules for all posts, thenmatch_allrules for this post (no keyword; every comment matches). Amatch_allrule withoutmedia_idis refused by the admin form and skipped by the matcher. The first match sends the DM, and matching carries on to collect the other matching rules for step 11. A rule whoseexclude_keywordsalso match is passed over and doesn't count as matching, so exclusions are per rule. - Normalise the comment: fold accents, lowercase, strip emoji, punctuation and symbols, collapse whitespace.
- Apply the rule's
match_mode.word(the default) matches each keyword on Unicode letter and digit boundaries, with regex metacharacters escaped; scripts written without spaces (Han, Hiragana, Katakana, Thai, Lao, Khmer, Myanmar) don't count as a boundary.containsis a substring test on the normalised text. Both normalise the keyword the same way, once per isolate. Keywords under 3 characters after normalising (2 when every character is Han, Hiragana or Katakana), or with no letter or digit, are rejected when a rule is saved, not here. - No rule matches: stop, with no
sentrow. Most of a busy account's comments end here, and a row for each would spend D1's daily write allowance on them. If nothing fires because of an exclusion, the row is written asskipped, with therule_idof the first rule passed over andSkipped: matched exclusion “…”as the error, so the poll leaves it alone. A comment older than 7 days, or for a paused account ("account paused": pausing means no reply, not a late one), is written asskippedtoo, without calling Meta. - If the delivery has time and subrequests left, decrypt the token and reserve one of the account's 12 DM calls for this minute. With a slot, the row is inserted as
pending:INSERT INTO sent … ON CONFLICT DO NOTHING, and that insert is the claim. Without one, it's inserted asqueuedfor the cron.changes === 0means it's already handled: stop, and give the slot back. Each other matching rule gets itssent_extra_repliesrow at the same time (step 11). POST https://graph.instagram.com/v23.0/{ig_user_id}/messageswith{ recipient: { comment_id }, message: { text } }.{username}in the DM and public reply becomesfrom.username(src/placeholders.ts). With no username, the token is removed along with the space or comma touching it, and nothing else in the text moves.- If the rule has
public_reply_textand the DM went through,POST /{comment-id}/replies. The column holds one reply as plain text, or two or more as a JSON array of strings (src/replies.ts), and one is picked per send withcrypto.getRandomValues. Any stored value that isn't a JSON array of non-empty strings, such as a multi-line reply from before variations, is one reply and is sent whole. If the DM failed, the public reply isn't posted andreply_statusisskipped: "Sent you a DM!" would be false. UPDATE sentwith both outcomes:ok,failed,skipped,retry(a refusal worth trying again, below) orunsure: a send whose outcome Instagram never confirmed (a timeout, 5xx, dropped connection or unreadable answer), never leftpendingand never sent again unless the operator says so. Home countsunsureas failed.- The other matching rules, in matching order (
src/other-replies.ts, andreplyForOtherRulesinsrc/queue.ts). Meta's one-private-reply limit binds only the DM, so the first 2 of them withpublic_reply_textandreply_without_dm = 1post it, with a variation and{username}as in step 9 (EXTRA_PUBLIC_REPLY_CAP). Each rule gets a row insent_extra_replies, keyed on(comment_id, rule_id)and written withINSERT … ON CONFLICT DO NOTHINGalong with the comment's own row:queuedfor a reply that waits for the DM, orskippedwith the reason, which is that it has no public reply, it posts one only with its own DM (reply_without_dm = 0, the default, because most replies say "Sent you a DM"), or it's over the limit. After step 10, wherever the DM was sent from, a confirmed DM lets eachqueuedreply go out: only the caller whoseUPDATE … SET status = 'pending' WHERE status = 'queued'changed the row posts it, in one attempt, with the rule's text as it is then. A DM that wasn't confirmed, or was skipped, turns themskipped("Instagram didn't confirm the DM"). A reply there's no time for, or that Instagram didn't confirm, isfailed, like step 9's, and is never posted again.sentis untouched, so its one row per comment still makes the DM exactly-once. A reply row stillpendingafter 15 minutes is shown on Home as stopped, and never retried.
Every Meta call in steps 8, 9 and 11 ends by a deadline, so step 10 always runs. The webhook sets it 25 seconds after it starts processing a delivery, shared by every comment in that delivery: Cloudflare stops waitUntil 30 seconds after the response, and a DM and its public reply can take 10 seconds each. Each attempt's timeout is the smaller of its usual 10 seconds and the time left. A comment that would get less than 2 seconds is queued instead. If the DM leaves less than 2 seconds, the public reply isn't attempted and reply_status is failed, with an error saying there was no time left to post it.
A delivery also keeps within its 50 subrequests (below): it sends its first comments straight away while it can still afford a row for every comment after them, and queues the rest.
The send queue
src/queue.ts. A matched comment's sent row moves through these states:
dm_status | Meaning | Next |
|---|---|---|
queued | Waiting for a DM call | pending, or skipped when it expires |
retry | Instagram turned the DM down for now; next_attempt_at is the next try | pending once that time comes, or skipped when it expires |
pending | Its DM is being sent; next_attempt_at is when the lease runs out | ok, failed, skipped, retry, back to queued if the DM never started or the token died, or unsure when the lease runs out |
unsure | Instagram never confirmed the DM, so it may have arrived | retry, only from Home's Send again |
ok, failed, skipped | Done |
No duplicates. A DM call for a comment happens only after a statement moved its row to pending and changed exactly one row: the webhook's insert, or the cron's UPDATE … SET dm_status = 'pending' WHERE comment_id IN (the oldest due) AND dm_status IN ('queued', 'retry') RETURNING …. D1 runs one statement at a time, so two Workers can't both claim a comment. A row goes back to the queue only when Instagram's answer proves it didn't act, or the DM call was never started. The public reply goes out once, only after a confirmed DM, and is never retried.
The limit. send_budget holds each account's current minute and the DM calls made in it. One batch reads the row and takes up to the slots wanted with INSERT … ON CONFLICT DO UPDATE … WHERE excluded.minute > minute OR used < 12 RETURNING used, so concurrent Workers never take more than 12 between them, and a Worker whose clock is a moment behind can't reopen a minute that's over. Calls are counted, not successes. Slots taken for calls that never happened are given back while their minute is still current.
The minute job, * * * * * (runMinute in src/cron.ts). On minutes divisible by 5, the comment poll runs first. Then, in one batch, it records pending rows whose lease has run out as unsure, "may have been delivered" (the Worker sending them was stopped; they're never sent again), expires queued rows for comments more than 7 days minus an hour old as skipped ("Expired: …"), skips a paused account's queued rows, and lists the accounts. For each active account with something due, it reserves what's left of the account's 12, claims that many rows, oldest comment first, and sends them. The claim returns the rule's current text with each row, so a DM queued before a rule was edited goes out with the new text, and one whose rule was deleted or turned off is skipped. A paused account's queued rows are skipped ("account paused") in the same batch, as its new comments are, so resuming it doesn't send late replies. The job stops starting sends 40 seconds in. Whenever an account's turn ends, a send throwing included, the rows it claimed but didn't start go straight back in the queue and the unused slots are given back; a row whose send threw stays pending for its lease, since its DM may have gone out. An error in one account's turn doesn't stop the next. Leases are 2 minutes, so a send is never given up on while its Worker is still running.
Retries. classifyRefusal in src/meta.ts reads Instagram's error code, and deliver in src/queue.ts acts on it:
| Instagram's answer | What happens |
|---|---|
| HTTP 429, codes 4, 17, 32, 613, or the business-use-case limits 80001 (Pages), 80002 (Instagram) and 80006 (Messenger), which other apps on the account share: sending too fast | retry, and the account's DMs, webhook sends included, are held for 15 minutes (send_budget.paused_until). Its other sends that minute wait. |
Code 2, including subcode 2018344, or any error Instagram marks is_transient: its temporary error | retry |
| Subcode 2534025 (already answered), code 10 with 2534022 (window closed), 551, code 200 with 1545041, or any other 4xx | failed, never tried again. These subcodes win over a rate-limit code in the same answer. |
| Code 190: the token stopped working | Back to queued (or retry), not counted as a try; the account is flagged for reconnecting and held 15 minutes at a time (send_budget.token_held_until). Reconnecting lifts that hold, and leaves a rate limit's in place. |
| A timeout, 5xx, dropped connection or unreadable answer | unsure, "may have been delivered". Home offers Send again, which re-queues it as retry with its tries reset, for the operator to use once they've checked it didn't arrive. It moves sent_at to that moment and takes the row off the totals snapshot's failed count if the snapshot already counted it, so its next outcome is counted once. |
A retry waits 1, then 5, then 20 minutes, and the fourth refusal is final: failed, "Instagram turned it down 4 times". attempts counts the tries. Other rules' public replies wait through retries and go out only after the DM is confirmed.
On Home. A Sending table, shown while something waits, an account is held, or DMs went out in the last hour, gives each account's queued and retrying counts, the oldest waiting comment's age, a rough time to send it all at drainRatePerMinute for the accounts with DMs waiting, and the last hour's DM calls, all read through idx_sent_queue and the last hour of idx_sent_account_time. A note says when Instagram has asked an account to slow down and when its DMs carry on. The Accounts page says how many waiting DMs pausing an account will skip. Last 20 sends shows Queued, Retrying (which try is next, and when), May have been delivered with Send again, and Expired.
Subrequests. Cloudflare's free plan allows 50 subrequests per invocation, counting every fetch and every D1 query, and separately 50 D1 queries per invocation. src/subrequests.ts counts both together against 50, and each step takes what it needs before starting: a DM, its public reply and the outcome write are 3, plus 1 and then 3 for each other rule's reply waiting on it; reserving, claiming and giving back are 3 per account; a read spends one per attempt, retries included. The poll stops with one left. Accounts with DMs waiting take turns going first, a different one each minute, and each gets an even share of the subrequests left (drainRatePerMinute). So a quiet minute sends a full 12 for one account, about 7 each for two and 4 each for three; a poll minute sends what the poll leaves room for, usually 9 or 10 for one account; and each other rule's reply waiting on a DM costs 4 more.
Throughput. Webhook sends and the cron share each account's 12 a minute, under 750 an hour. The cron is one Worker run a minute, so its capacity is shared, not per account: a backlog on one account with nothing new arriving drains at about 700 an hour (5,000 queued comments take about 7 hours, 10,000 about 14, if D1's write allowance lasts, below), and backlogs on two accounts drain at about 400 an hour each. Webhook sends come on top, each delivery with its own subrequests.
D1 writes, the real ceiling. The free plan allows 100,000 rows written a day, and an index on a written column adds a row. Measured in test/queue.test.ts: a DM sent straight from the webhook writes 10 rows, plus 3 for the delivery's log (its webhook_events row, that row's index, and last_webhook_received_at); a DM that waits in the queue writes 13, plus the 3. A comment no rule matches writes only the log's 3, and an excluded one about 7. So about 6,000 to 7,500 DMs a day fit, fewer when many comments match nothing. Past 100,000, D1 refuses every query until 00:00 UTC, the webhook log and the queue included; comments that arrive then get no row, and the poll can find only the newest of them later. An idle minute job writes nothing.
Cron jobs
Cloudflare cron has no retries. A missed tick is gone.
Token refresh, 0 3 * * *:
- Select accounts with
token_expires_at < now + 10 days, which gives about ten chances if a run is missed. Paused accounts are included: pausing stops DMs, not token upkeep. - Skip tokens younger than 24 hours (by
last_refreshed_at, orconnected_at). GET https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=…- On success: re-encrypt, update
token_expires_atandlast_refreshed_at, clearneeds_reconnect. - On failure with under 14 days left: set
needs_reconnect. The admin panel shows a red banner with Reconnect, which goes to/connect. - On a clean run, whether or not there was work, write
last_cron_ok_at. The admin panel shows a banner if it's more than 72 hours old, or if the job has never run 26 hours after the deployment was first seen (health_first_seen_at, the clock/healthuses). Until then, Home says the first run is still on its way.
It's idempotent: running it five times a day is harmless.
Before refreshing, the daily job marks sent rows that are still pending 15 minutes after their claim as unsure ("The Worker stopped before this send finished…"). Leases catch these within 2 minutes; this catches rows claimed before leases existed, and any a stopped minute job missed. It then adds every row older than that 15-minute cutoff to an all-time snapshot (sent, skipped, failed and people reached, overall and per account) and stores it as JSON, with the cutoff, in the send_totals_snapshot key of system. The sweep goes first because it's the only write that changes a row's counted state without moving its sent_at forward: an outcome, an expiry or a lease running out all move it, and a claim or a release changes only queued and pending, which aren't counted. Once the sweep has run, nothing counted before the cutoff can change, so the snapshot stays true. Both read only the rows since the previous snapshot's cutoff, through idx_sent_time. The first run after an upgrade reads the whole table once. "Check connections now" runs the same job.
sent is never pruned, because it's the creator's history, so nothing Home runs on a visit reads all of it. That matters because D1's free plan allows 5 million rows read a day, and past that D1 refuses every query, sends included, until 00:00 UTC. The last 20 sends read 20 rows through idx_sent_time, and their other matching rules are looked up in sent_extra_replies by primary key. Today, 7 days and 30 days are range reads on sent_at, and the per-account breakdown reads one range per account through idx_sent_account_time. All time is the snapshot plus a live count of the rows since its cutoff, in the same statement. People reached stays exact: someone reached since the cutoff counts as new only if idx_sent_reached, a partial index of the rows where a DM went out, has no earlier DM to them. So a visit reads about the last 30 days of rows, or twice that with more than one account: at 1,000 comments a day, 30,000 to 60,000 rows, which leaves room for 80 or more visits a day however long the history. Before the first snapshot, Home counts all time live while sent has at most 1,000 rows, and otherwise takes the snapshot itself, once. It also brings a snapshot more than two days old up to date, so a stopped daily job doesn't bring the full read back. Deleting the send_totals_snapshot row makes the next run count everything again.
Comment poll, every fifth run of the minute job: src/reconcile.ts collects, per active account, every post a rule is limited to, plus the 3 newest from /me/media when any rule is on all posts. It fetches each post's newest comments, checks in one read per page which ones already have a row, and queues through admitComment the others a rule matches or excludes. The minute job sends them straight after. The caps are 3 media per account per sweep, the 25 newest comments per media, 10 queued comments per sweep (across all accounts) and a 72-hour lookback. With more than 3 posts, each sweep takes the next 3 after the post in poll_cursor:<ig_user_id> (pollWindow), so every post is read within ⌈n / 3⌉ sweeps unless the other caps cut a sweep short. A post they cut short isn't recorded as read, so the next sweep starts with it. The cursors are written with last_poll_ok_at, in one batch. The poll stops reading 4 minutes in, which ends a slow sweep before the next one begins. Comments it doesn't reach have no row, so the next sweep picks them up. A match_all rule added to an older post therefore also answers that post's unhandled comments from the last 72 hours, 10 per sweep; the rule form says so.
A deployment whose wrangler.toml still has the old */5 * * * * trigger runs the same minute job on it, every 5 minutes, so it keeps polling and sending. Any other trigger runs the daily job.
Crypto
Web Crypto only.
- All randomness comes from
crypto.getRandomValues, neverMath.random. - Webhook signatures (
X-Hub-Signature-256):crypto.subtle.importKeyandcrypto.subtle.verify(HMAC-SHA256). Verify; don't sign and compare with===.FACEBOOK_APP_SECRET(App settings → Basic) is tried first, thenMETA_APP_SECRET(the Instagram App Secret), because Meta often signs with the Facebook one. - Token encryption: AES-256-GCM with a random 96-bit IV per encryption. The IV is stored in
accounts.token_ivand the ciphertext inaccounts.access_token_enc. The key isTOKEN_ENCRYPTION_KEY, 32 random bytes in base64. - Password hashing: PBKDF2-HMAC-SHA256 through
crypto.subtle.deriveBits, 12,000 iterations, with a 16-byte random salt. Hash and salt live in thesystemtable. (The original spec asked for 600,000 iterations, which exceeds free-plan CPU and returns a 500 on the first password save.) - Session cookie: HMAC-signed,
HttpOnly,SameSite=Strict, 30-day expiry. The CSRF token lives inside the session, and every mutating admin POST must send it. The MAC covers the payload,admin_password_saltandsession_epoch(seesrc/session.ts), so a new password or a logout invalidates every older cookie.
Admin auth
The panel is mounted at /a/:secret. The unguessable URL is one layer, not the whole of the auth.
- First visit: create a password (PBKDF2). There's no email reset; recovery is deleting
admin_password_hashandadmin_password_saltwithwrangler d1 execute(see Troubleshooting). - A signed session cookie lasts 30 days, unless the operator logs out (which ends every session, in every browser) or the password is replaced.
- CSRF protection on every mutating form, including logout.
- Connect needs the same logged-in session. The session cookie is
SameSite=Strict, so the browser leaves it off Instagram's cross-site redirect to/connect/callback./connect/startchecks the session instead, then issues a signedstate(10-minute expiry) and anigcdm_connectcookie (HttpOnly,SameSite=Lax,Path=/connect) holding an HMAC of the state's nonce. The callback refuses unless both verify, and clears the cookie. Neither the refusal page nor the callback's error pages link to the admin path. - The keyword-save error is exactly:
Keywords need at least 3 characters, or 2 in Chinese and Japanese. Shorter ones match inside other words — 'AI' would fire on 'again' and 'email'. - Admin pages, login and setup included, link the manifest, so the panel installs to a phone's home screen. Public pages never do, because the manifest's URL contains the secret. The CSP allows
manifest-src 'self'andimg-src 'self'for this, since Chrome checks the manifest and its icons against them. There's no service worker. The PNG icons are base64 insrc/icons.ts, drawn bynpm run icons(scripts/icons.ts). - The DM text field has
maxlength="1000"and a static hint (no client JavaScript runs under the CSP). The server rejects anything longer and says how long the text was. Line breaks count as one character on both sides, though browsers submit them as CRLF. Each{username}counts as 30 characters, Instagram's longest username, so a rule that saves always fits once the name is in. The public reply has the same 1,000-character limit.
Tests
scripts/test-unit.ts covers the guard, matching and HMAC verification with no network. test/*.test.ts runs the real Worker in workerd against a fresh local D1 for each test, with every Meta host faked (npm run test:integration). npm test runs both.
scripts/selftest.ts drives a deployed Worker or a wrangler dev URL. Its live checks: the handshake returns 200, a wrong verify token gets 403, a bad signature gets 401, a synthetic comment creates one sent row, a duplicate delivery still leaves one row, a self-comment creates no row, the admin login page loads and rejects a wrong password, the fake account is removed again, and the cron writes last_cron_ok_at. It needs ADMIN_PASSWORD, and on a Worker with no password yet it creates one only when the URL is localhost or 127.x, or when you pass --allow-setup. Contributing has the commands.
Reference
Bindings and secrets
Every name the Worker reads, as env.* or c.env.* (from Env in src/types.ts):
| Name | Kind |
|---|---|
DB | D1 binding |
META_APP_ID | Worker secret |
META_APP_SECRET | Worker secret |
FACEBOOK_APP_SECRET | Worker secret. Optional in the type so an existing deployment still boots, but live comment POSTs usually fail with 401 until it's set. |
WEBHOOK_VERIFY_TOKEN | Worker secret |
TOKEN_ENCRYPTION_KEY | Worker secret |
ADMIN_URL_SECRET | Worker secret |
SESSION_SIGNING_KEY | Worker secret |
PUBLIC_BASE_URL | Worker secret |
Never put these in wrangler.toml. Local copies go in .dev.vars, which is gitignored; see .dev.vars.example. Where each value comes from is in the self-setup guide, and what leaking each one costs is in Security.
Keys in the system table
Not Worker environment:
| Key | What |
|---|---|
last_cron_ok_at | When the daily job last finished cleanly |
last_poll_ok_at | When the comment poll, every fifth minute, last finished |
health_first_seen_at | When /health was first checked, the start of its grace period for jobs that haven't run yet |
last_webhook_received_at | When the last delivery that passed the signature check arrived. webhook_events is pruned after 7 days, so this is what tells Home and npm run doctor that Meta has reached the Worker before |
poll_cursor:<ig_user_id> | The last post the comment poll finished reading for that account, when its rules cover more than 3 posts. The next sweep starts after it |
send_totals_snapshot | All-time send totals for rows before a cutoff, as JSON, which Home adds its live counts to. Written by the daily job |
admin_password_hash, admin_password_salt | The admin password |
session_epoch | Goes up on every logout. Session cookies are signed over it, so a logout ends them all |
login_fail_count, login_locked_until | Login rate limiting |
Selftest environment
Read by scripts/selftest.ts only:
| Variable | |
|---|---|
BASE_URL | Optional; otherwise the first argument, or http://localhost:8787 |
WEBHOOK_VERIFY_TOKEN | |
META_APP_SECRET | |
ADMIN_URL_SECRET | |
ADMIN_PASSWORD | Required. The admin password. On a Worker with no password yet, selftest sets it, but only on localhost unless --allow-setup is passed |
Graph API version
GRAPH_VERSION = 'v23.0' in src/meta.ts, as originally specified. Meta's current docs show v26.0; bump the constant if calls start failing.
Deliberate departures from the original spec
- An
accounts.needs_reconnectcolumn, which the cron and the reconnect banner need. 001and002use onlyCREATE … IF NOT EXISTS. Deployments from before migration tracking applied them by hand, so the first tracked run replays both, and that replay changes nothing.- A transient
dm_status = 'pending'on the claim row, updated with the outcome, andqueuedfor a comment waiting for a DM call. - Graph API version kept at v23.0.
- Instagram Login's flattened webhook shape (
fieldandvalueon the entry) is handled. - Hono
{ strict: false }. - No
[limits] cpu_msinwrangler.toml: it's for paid Workers only, and a Free-plan deploy rejects it. - PBKDF2 at 12,000 iterations instead of 600,000, so first-time password setup fits free-plan CPU.
- The unlisted selftest admin endpoints.
- First-run password setup; there's no
ADMIN_PASSWORDWorker secret. - The OAuth
redirect_uriis built from the request host (/connect/callback), HTTPS except on localhost, not fromPUBLIC_BASE_URL. Accounts and/connectshow that URI plus a trailing-slash variant for Meta's dashboard, and/connect/startsends the no-slash URI, the same string the token exchange uses. FACEBOOK_APP_SECRETas well as the InstagramMETA_APP_SECRETfor webhook HMAC.- The
webhook_eventstable and the comment poll. - The send queue:
queuedrows, leases, and thesend_budgettable.
The original Phase 1 contract (schema, secrets, bindings and CLI commands as first specified) is kept in git history: docs/phase-1.md at ace7ae9.