From bdd2be6bed09299004c97c5e98510e39328ca6cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:43:47 +0000 Subject: [PATCH 1/2] feat: add instagram-webhooks and messenger-webhooks skills Instagram (object "instagram": changes[] for comments, live_comments, mentions, story_insights; messaging[] for DMs) and Facebook Messenger (object "page": messaging[] / standby[] events). Both verify X-Hub-Signature-256 (sha256=, HMAC-SHA256 of the raw body keyed with the app secret) and answer the GET hub.challenge handshake. Express, Next.js and FastAPI examples with tests, provider YAML, and the regenerated README row and marketplace entries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Mp6z12EgZRFuEhuPqFwxMT --- .claude-plugin/marketplace.json | 48 +++- README.md | 2 + providers/instagram.yaml | 39 +++ providers/messenger.yaml | 31 +++ skills/instagram-webhooks/SKILL.md | 202 ++++++++++++++ .../examples/express/.env.example | 9 + .../examples/express/README.md | 58 +++++ .../examples/express/package.json | 18 ++ .../examples/express/src/index.js | 179 +++++++++++++ .../examples/express/test/webhook.test.js | 246 ++++++++++++++++++ .../examples/fastapi/.env.example | 9 + .../examples/fastapi/README.md | 59 +++++ .../examples/fastapi/main.py | 180 +++++++++++++ .../examples/fastapi/requirements.txt | 5 + .../examples/fastapi/test_webhook.py | 182 +++++++++++++ .../examples/nextjs/.env.example | 9 + .../examples/nextjs/README.md | 58 +++++ .../nextjs/app/webhooks/instagram/route.ts | 176 +++++++++++++ .../examples/nextjs/package.json | 23 ++ .../examples/nextjs/test/webhook.test.ts | 191 ++++++++++++++ .../examples/nextjs/vitest.config.ts | 8 + .../instagram-webhooks/references/overview.md | 137 ++++++++++ skills/instagram-webhooks/references/setup.md | 92 +++++++ .../references/verification.md | 78 ++++++ skills/messenger-webhooks/SKILL.md | 196 ++++++++++++++ .../examples/express/.env.example | 7 + .../examples/express/README.md | 62 +++++ .../examples/express/package.json | 18 ++ .../examples/express/src/index.js | 182 +++++++++++++ .../examples/express/test/webhook.test.js | 218 ++++++++++++++++ .../examples/fastapi/.env.example | 7 + .../examples/fastapi/README.md | 63 +++++ .../examples/fastapi/main.py | 183 +++++++++++++ .../examples/fastapi/requirements.txt | 5 + .../examples/fastapi/test_webhook.py | 163 ++++++++++++ .../examples/nextjs/.env.example | 7 + .../examples/nextjs/README.md | 62 +++++ .../nextjs/app/webhooks/messenger/route.ts | 172 ++++++++++++ .../examples/nextjs/package.json | 23 ++ .../examples/nextjs/test/webhook.test.ts | 173 ++++++++++++ .../examples/nextjs/vitest.config.ts | 8 + .../messenger-webhooks/references/overview.md | 82 ++++++ skills/messenger-webhooks/references/setup.md | 80 ++++++ .../references/verification.md | 82 ++++++ 44 files changed, 3830 insertions(+), 2 deletions(-) create mode 100644 providers/instagram.yaml create mode 100644 providers/messenger.yaml create mode 100644 skills/instagram-webhooks/SKILL.md create mode 100644 skills/instagram-webhooks/examples/express/.env.example create mode 100644 skills/instagram-webhooks/examples/express/README.md create mode 100644 skills/instagram-webhooks/examples/express/package.json create mode 100644 skills/instagram-webhooks/examples/express/src/index.js create mode 100644 skills/instagram-webhooks/examples/express/test/webhook.test.js create mode 100644 skills/instagram-webhooks/examples/fastapi/.env.example create mode 100644 skills/instagram-webhooks/examples/fastapi/README.md create mode 100644 skills/instagram-webhooks/examples/fastapi/main.py create mode 100644 skills/instagram-webhooks/examples/fastapi/requirements.txt create mode 100644 skills/instagram-webhooks/examples/fastapi/test_webhook.py create mode 100644 skills/instagram-webhooks/examples/nextjs/.env.example create mode 100644 skills/instagram-webhooks/examples/nextjs/README.md create mode 100644 skills/instagram-webhooks/examples/nextjs/app/webhooks/instagram/route.ts create mode 100644 skills/instagram-webhooks/examples/nextjs/package.json create mode 100644 skills/instagram-webhooks/examples/nextjs/test/webhook.test.ts create mode 100644 skills/instagram-webhooks/examples/nextjs/vitest.config.ts create mode 100644 skills/instagram-webhooks/references/overview.md create mode 100644 skills/instagram-webhooks/references/setup.md create mode 100644 skills/instagram-webhooks/references/verification.md create mode 100644 skills/messenger-webhooks/SKILL.md create mode 100644 skills/messenger-webhooks/examples/express/.env.example create mode 100644 skills/messenger-webhooks/examples/express/README.md create mode 100644 skills/messenger-webhooks/examples/express/package.json create mode 100644 skills/messenger-webhooks/examples/express/src/index.js create mode 100644 skills/messenger-webhooks/examples/express/test/webhook.test.js create mode 100644 skills/messenger-webhooks/examples/fastapi/.env.example create mode 100644 skills/messenger-webhooks/examples/fastapi/README.md create mode 100644 skills/messenger-webhooks/examples/fastapi/main.py create mode 100644 skills/messenger-webhooks/examples/fastapi/requirements.txt create mode 100644 skills/messenger-webhooks/examples/fastapi/test_webhook.py create mode 100644 skills/messenger-webhooks/examples/nextjs/.env.example create mode 100644 skills/messenger-webhooks/examples/nextjs/README.md create mode 100644 skills/messenger-webhooks/examples/nextjs/app/webhooks/messenger/route.ts create mode 100644 skills/messenger-webhooks/examples/nextjs/package.json create mode 100644 skills/messenger-webhooks/examples/nextjs/test/webhook.test.ts create mode 100644 skills/messenger-webhooks/examples/nextjs/vitest.config.ts create mode 100644 skills/messenger-webhooks/references/overview.md create mode 100644 skills/messenger-webhooks/references/setup.md create mode 100644 skills/messenger-webhooks/references/verification.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 046f91b8b..ea69baf70 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,7 +5,7 @@ "email": "phil@hookdeck.com" }, "metadata": { - "description": "Webhook integration skills for AI coding agents: provider skills (Stripe, Shopify, GitHub, and 802 others) plus framework-agnostic handler patterns and Hookdeck infrastructure plugins for receiving, verifying, and delivering webhooks across Express, Next.js, and FastAPI.", + "description": "Webhook integration skills for AI coding agents: provider skills (Stripe, Shopify, GitHub, and 804 others) plus framework-agnostic handler patterns and Hookdeck infrastructure plugins for receiving, verifying, and delivering webhooks across Express, Next.js, and FastAPI.", "version": "0.1.0" }, "plugins": [ @@ -6869,6 +6869,27 @@ "build" ] }, + { + "name": "instagram-webhooks", + "description": "Receive and verify Instagram (Meta) webhooks. Use when setting up Instagram webhook handlers, completing the GET hub.challenge handshake, debugging X-Hub-Signature-256 signature verification, or handling Instagram comments, live_comments, mentions, story_insights, messages, message_reactions, messaging_postbacks, messaging_referral, and messaging_seen notifications.", + "source": "./skills/instagram-webhooks", + "strict": false, + "skills": [ + "./" + ], + "category": "integration", + "license": "MIT", + "author": { + "name": "Hookdeck", + "email": "phil@hookdeck.com" + }, + "repository": "https://github.com/hookdeck/webhook-skills", + "homepage": "https://github.com/hookdeck/webhook-skills/tree/main/skills/instagram-webhooks", + "keywords": [ + "webhooks", + "instagram" + ] + }, { "name": "instantly-webhooks", "description": "Receive and authenticate Instantly (Instantly.ai cold email) webhooks. Use when setting up an Instantly webhook handler, securing it with the custom headers Instantly lets you attach (e.g. Authorization: Basic ... or a secret header), debugging why the header check fails, or handling campaign events like email_sent, email_opened, reply_received, email_bounced, lead_interested, lead_unsubscribed and lead_meeting_booked. Instantly does not sign webhooks: there is no signature header or HMAC.", @@ -8553,6 +8574,27 @@ "mercury" ] }, + { + "name": "messenger-webhooks", + "description": "Receive and verify Facebook Messenger Platform webhooks. Use when setting up Messenger webhook handlers for a Facebook Page, completing the GET hub.challenge handshake, debugging X-Hub-Signature-256 signature verification (escaped-unicode raw body), or handling messages, messaging_postbacks, message_reads, message_deliveries, message_reactions, messaging_optins, messaging_referrals, and message_echoes events.", + "source": "./skills/messenger-webhooks", + "strict": false, + "skills": [ + "./" + ], + "category": "integration", + "license": "MIT", + "author": { + "name": "Hookdeck", + "email": "phil@hookdeck.com" + }, + "repository": "https://github.com/hookdeck/webhook-skills", + "homepage": "https://github.com/hookdeck/webhook-skills/tree/main/skills/messenger-webhooks", + "keywords": [ + "webhooks", + "messenger" + ] + }, { "name": "metabase-webhooks", "description": "Receive and authenticate Metabase alert webhooks. Use when setting up a Metabase webhook endpoint, checking the HTTP Basic Authorization header, debugging 401s, or handling alert payloads with question results (alert_id, question_url, raw_data, base64 PNG visualization).", @@ -17306,7 +17348,7 @@ }, { "name": "webhook-skills", - "description": "Bundle of all webhook integration skills in this repo: 805 provider skills (Stripe, Shopify, GitHub, and 802 others) for verifying signatures and handling provider-specific events, framework-agnostic webhook handler patterns covering idempotency, error handling and retries, three Hookdeck infrastructure skills for the Event Gateway and Outpost, and a webhook DX audit skill for scoring how a platform sends outbound webhooks. Each provider skill includes Express, Next.js, and FastAPI examples. Install this plugin to get all 810 skills; install individual `-webhooks@webhook-skills` plugins for narrower per-provider use.", + "description": "Bundle of all webhook integration skills in this repo: 807 provider skills (Stripe, Shopify, GitHub, and 804 others) for verifying signatures and handling provider-specific events, framework-agnostic webhook handler patterns covering idempotency, error handling and retries, three Hookdeck infrastructure skills for the Event Gateway and Outpost, and a webhook DX audit skill for scoring how a platform sends outbound webhooks. Each provider skill includes Express, Next.js, and FastAPI examples. Install this plugin to get all 812 skills; install individual `-webhooks@webhook-skills` plugins for narrower per-provider use.", "source": "./", "strict": false, "skills": [ @@ -17632,6 +17674,7 @@ "./skills/increase-webhooks", "./skills/infinite-giving-webhooks", "./skills/ingenious-build-webhooks", + "./skills/instagram-webhooks", "./skills/instantly-webhooks", "./skills/intercom-webhooks", "./skills/invoice-ninja-webhooks", @@ -17711,6 +17754,7 @@ "./skills/memos-webhooks", "./skills/meraki-webhooks", "./skills/mercury-webhooks", + "./skills/messenger-webhooks", "./skills/metabase-webhooks", "./skills/metronome-webhooks", "./skills/mews-pos-webhooks", diff --git a/README.md b/README.md index a40191234..1cb492ff9 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ Skills for receiving and verifying webhooks from specific providers. Each includ | [Exact Online](https://support.exactonline.com/community/s/article/All-All-DNO-Content-webhooksc?language=en_GB) | [`exact-online-webhooks`](skills/exact-online-webhooks/) | Verify Exact Online `HashCode` body signatures (HMAC-SHA256 over the `Content` node, hex, uppercased), subscribe to topics, handle `Accounts`, `Items`, `StockPositions`, `GoodsDeliveries` events | | [Expo (EAS)](https://docs.expo.dev/eas/webhooks/) | [`expo-webhooks`](skills/expo-webhooks/) | Verify Expo EAS webhook signatures (`expo-signature` `sha1=`, HMAC-SHA1 of the raw body), handle EAS Build and Submit completion payloads with finished, errored and canceled statuses | | [Facebook](https://developers.facebook.com/docs/graph-api/webhooks) | [`facebook-webhooks`](skills/facebook-webhooks/) | Verify Facebook Graph API webhook signatures (`X-Hub-Signature-256`, HMAC-SHA256), complete the hub.challenge handshake, handle Page and User field updates | +| [Facebook Messenger](https://developers.facebook.com/documentation/business-messaging/messenger-platform/webhooks) | [`messenger-webhooks`](skills/messenger-webhooks/) | Verify Facebook Messenger webhook signatures (`X-Hub-Signature-256` `sha256=`, HMAC-SHA256 of the raw escaped-unicode body keyed with the App Secret), answer the `hub.challenge` handshake, handle `messages`, `messaging_postbacks`, `message_reads`, `message_deliveries`, `message_reactions`, `messaging_optins` and `messaging_referrals` | | [Factorial](https://apidoc.factorialhr.com/docs/webhooks-what) | [`factorial-webhooks`](skills/factorial-webhooks/) | Authenticate Factorial webhooks (unsigned: the `x-factorial-wh-challenge` value compared in constant time), handle subscription types like `employees/employee/create_with_contract` and `timeoff/leave/create` | | [Fanvue](https://api.fanvue.com/docs/webhooks) | [`fanvue-webhooks`](skills/fanvue-webhooks/) | Verify Fanvue creator-platform webhook signatures (`X-Fanvue-Signature: t=,v0=`, HMAC-SHA256 hex over `.`, with a bare second MAC during secret rotation that must also be accepted), answer the unsigned `fanvue.webhook.verification` probe, and handle the `creator.*`, `checkout_link.*`, `payout.*` and `app.*` event families plus the deprecated flat legacy events | | [FastSpring](https://developer.fastspring.com/reference/webhooks-overview) | [`fastspring-webhooks`](skills/fastspring-webhooks/) | Verify FastSpring webhook signatures (`X-FS-Signature`, HMAC-SHA256 base64 over the raw body), iterate the batched `events` array, handle order.completed, subscription.activated, subscription.charge.completed, and subscription.canceled events | @@ -352,6 +353,7 @@ Skills for receiving and verifying webhooks from specific providers. Each includ | [Increase](https://increase.com/documentation/webhooks) | [`increase-webhooks`](skills/increase-webhooks/) | Verify Increase webhooks (Standard Webhooks headers, HMAC-SHA256 base64 over `{id}.{timestamp}.{body}` keyed with the raw `shared_secret`, not `whsec_`), handle event categories like `transaction.created` | | [Infinite Giving](https://docs.infinitegiving.com/guide/webhooks/overview) | [`infinite-giving-webhooks`](skills/infinite-giving-webhooks/) | Verify Infinite Giving webhook signatures (`X-IG-Signature`, HMAC-SHA256 of `{X-IG-Timestamp}.{body}`), handle donation:created and donation:updated events | | [INGENIOUS.BUILD](https://help.ingenious.build/custom-accounting-integration-setup-guide-ingenious.build-help-center) | [`ingenious-build-webhooks`](skills/ingenious-build-webhooks/) | Authenticate INGENIOUS.BUILD Custom Accounting Integration webhooks (unsigned: a header you name, its whole value compared in constant time), handle budget, contract and invoice exports | +| [Instagram](https://developers.facebook.com/documentation/instagram-platform/webhooks/setup) | [`instagram-webhooks`](skills/instagram-webhooks/) | Verify Instagram (Meta) webhook signatures (`X-Hub-Signature-256` `sha256=`, HMAC-SHA256 of the raw body keyed with the app secret), answer the `hub.challenge` handshake, handle `comments`, `live_comments`, `mentions`, `story_insights` and messaging events (`messages`, `message_reactions`, `messaging_postbacks`, `messaging_seen`) | | [Instantly](https://help.instantly.ai/en/articles/6261906-webhooks) | [`instantly-webhooks`](skills/instantly-webhooks/) | Authenticate Instantly cold email webhooks — no signature; constant-time compare of the custom header you attach to the webhook (`Authorization: Basic ...` or a secret header), handle `email_sent`, `reply_received`, `email_bounced`, `lead_interested`, lead status and custom label events | | [Intercom](https://developers.intercom.com/docs/webhooks) | [`intercom-webhooks`](skills/intercom-webhooks/) | Verify Intercom `X-Hub-Signature` (HMAC-SHA1), handle conversation, contact, and ticket events | | [Invoice Ninja](https://api-docs.invoicing.co/#tag/webhooks) | [`invoice-ninja-webhooks`](skills/invoice-ninja-webhooks/) | Authenticate Invoice Ninja webhooks (unsigned: a secret header compared in full in constant time), handle invoice, payment and client entity payloads (and avoid the 404/405/410 responses that delete the subscription) | diff --git a/providers/instagram.yaml b/providers/instagram.yaml new file mode 100644 index 000000000..0cf1f5780 --- /dev/null +++ b/providers/instagram.yaml @@ -0,0 +1,39 @@ +name: instagram +displayName: 'Instagram' +docs: + webhooks: https://developers.facebook.com/documentation/instagram-platform/webhooks/setup + events: https://developers.facebook.com/documentation/instagram-platform/webhooks/fields + examples: https://developers.facebook.com/documentation/instagram-platform/webhooks/examples + overview: https://developers.facebook.com/documentation/instagram-platform/webhooks + messaging: https://developers.facebook.com/documentation/business-messaging/instagram-messaging/webhooks + reference_impl: https://github.com/fbsamples/graph-api-webhooks-samples +notes: > + Instagram (Meta) webhooks for Instagram professional accounts. Meta signs every notification: + header X-Hub-Signature-256: 'sha256=', HMAC-SHA256 hex of the raw payload keyed with the app + secret. Require and strip the sha256= prefix. The setup guide says the key is "your app's App + Secret" (App settings > Basic). Apps on Instagram API with Instagram Login also have an Instagram + app secret (Instagram > API setup with Instagram login > Business login settings); the docs don't + say whether webhooks for those apps are signed with it, so say so, start with the App Secret and + try the Instagram app secret if verification fails. Three API setups (Unified Login, Instagram + Login, Facebook Login) differ in token, host, permissions and field availability; cover the fields + matrix. Body is {object: 'instagram', entry[]}; entries carry changes[] (comments, live_comments, + mentions, story_insights) or messaging[] (message incl. is_echo/is_deleted, reaction, postback, + referral, read for messaging_seen, message_edit), plus standby[] under the handover protocol. + Comment samples use value.id (Instagram Login) or value.comment_id (Unified/Facebook Login). Some + examples-page samples are wrapped in a JSON array; accept both. Compare in constant time: in Node + check /^sha256=([0-9a-f]{64})$/i before any Buffer.from(x, 'hex'), then crypto.timingSafeEqual on + equal-length buffers; in Python use hmac.compare_digest on bytes so a non-ASCII header gives 401. + No timestamp window. Setup also needs the GET verification handshake: respond to hub.mode=subscribe + with hub.challenge when hub.verify_token matches (examples use INSTAGRAM_VERIFY_TOKEN; Hookdeck's + edge answers the challenge itself). Hookdeck setup (say this in SKILL.md's setup section and in + references/setup.md): Paste your Meta app's App Secret (App settings > Basic). If your app uses + Instagram API with Instagram Login and verification fails, use the Instagram app secret from the + Instagram product's API setup page instead. Express examples must read the raw body with + express.raw({ type: '*/*' }). +testScenario: + events: + - 'comments' + - 'mentions' + - 'messages' +catalog: + summary: 'Verify Instagram (Meta) webhook signatures (`X-Hub-Signature-256` `sha256=`, HMAC-SHA256 of the raw body keyed with the app secret), answer the `hub.challenge` handshake, handle `comments`, `live_comments`, `mentions`, `story_insights` and messaging events (`messages`, `message_reactions`, `messaging_postbacks`, `messaging_seen`)' diff --git a/providers/messenger.yaml b/providers/messenger.yaml new file mode 100644 index 000000000..0e9742453 --- /dev/null +++ b/providers/messenger.yaml @@ -0,0 +1,31 @@ +name: messenger +displayName: 'Facebook Messenger' +docs: + webhooks: https://developers.facebook.com/documentation/business-messaging/messenger-platform/webhooks + events: https://developers.facebook.com/documentation/business-messaging/messenger-platform/webhooks/webhook-events + reference_impl: https://github.com/fbsamples/original-coast-clothing +notes: > + Facebook Messenger Platform webhooks for a Facebook Page. Meta signs every notification: header + X-Hub-Signature-256: 'sha256=', HMAC-SHA256 hex of the payload keyed with the app's App Secret + (App settings > Basic). Meta signs the escaped-unicode payload (äöå as äöå): verify + over the raw bytes as received, never re-serialized JSON. Require and strip the sha256= prefix. + Body is {object: 'page', entry[]} with entry.messaging[] (or entry.standby[] under the handover + protocol); each item has sender, recipient, timestamp and one event key: message (messages; is_echo + for message_echoes), postback (messaging_postbacks), read (message_reads), delivery + (message_deliveries), reaction (message_reactions), optin (messaging_optins), referral + (messaging_referrals), message_edit (message_edits), account_linking (messaging_account_linking). + Respond 200 within 5 seconds; an hour of failed deliveries unsubscribes the app (Webhooks Disabled). + Compare in constant time: in Node check /^sha256=([0-9a-f]{64})$/i before any Buffer.from(x, + 'hex'), then crypto.timingSafeEqual on equal-length buffers; in Python use hmac.compare_digest on + bytes. No timestamp window. Setup also needs the GET verification handshake: respond to + hub.mode=subscribe with hub.challenge when hub.verify_token matches (examples use + MESSENGER_VERIFY_TOKEN; Hookdeck's edge answers the challenge itself). Hookdeck setup (say this in + SKILL.md's setup section and in references/setup.md): Paste your Meta app's App Secret (App settings + > Basic). Express examples must read the raw body with express.raw({ type: '*/*' }). +testScenario: + events: + - 'messages' + - 'messaging_postbacks' + - 'message_reads' +catalog: + summary: 'Verify Facebook Messenger webhook signatures (`X-Hub-Signature-256` `sha256=`, HMAC-SHA256 of the raw escaped-unicode body keyed with the App Secret), answer the `hub.challenge` handshake, handle `messages`, `messaging_postbacks`, `message_reads`, `message_deliveries`, `message_reactions`, `messaging_optins` and `messaging_referrals`' diff --git a/skills/instagram-webhooks/SKILL.md b/skills/instagram-webhooks/SKILL.md new file mode 100644 index 000000000..6c9b2b8ec --- /dev/null +++ b/skills/instagram-webhooks/SKILL.md @@ -0,0 +1,202 @@ +--- +name: instagram-webhooks +description: > + Receive and verify Instagram (Meta) webhooks. Use when setting up Instagram + webhook handlers, completing the GET hub.challenge handshake, debugging + X-Hub-Signature-256 signature verification, or handling Instagram comments, + live_comments, mentions, story_insights, messages, message_reactions, + messaging_postbacks, messaging_referral, and messaging_seen notifications. +license: MIT +metadata: + author: hookdeck + version: "0.1.0" + repository: https://github.com/hookdeck/webhook-skills +--- + +# Instagram Webhooks + +Instagram webhooks use the **Meta** webhook mechanism: a GET handshake when you save the +Callback URL, then POST deliveries signed with `X-Hub-Signature-256`. They do **not** follow +the Standard Webhooks spec. The body is `{ "object": "instagram", "entry": [...] }`. + +Meta has three Instagram API setups (Instagram API with **Unified Login**, with **Instagram +Login**, and with **Facebook Login**). They differ in token, API host, permissions and some +payload shapes, but the webhook handshake and signature are the same. + +## When to Use This Skill + +- How do I receive Instagram webhooks? +- How do I verify Instagram webhook signatures with X-Hub-Signature-256? +- How do I complete the Instagram hub.challenge verification handshake? +- Why is my Instagram webhook signature verification failing? +- How do I handle Instagram `comments`, `mentions` or `messages` (DM) notifications? + +## Two Requests, Two Jobs + +1. **`GET`: handshake.** Meta sends `hub.mode=subscribe`, `hub.verify_token` and + `hub.challenge`. If the token matches the Verify Token you set in the App Dashboard, reply + `200` with the raw `hub.challenge`. +2. **`POST`: event delivery.** A JSON body signed with `X-Hub-Signature-256`. + +## Verification (core) + +Meta sends `X-Hub-Signature-256: sha256=`, the HMAC-SHA256 hex digest of the **raw +request body** keyed with your app secret. Require the `sha256=` prefix, check the digest is +exactly 64 hex characters (`Buffer.from(x, 'hex')` silently truncates invalid hex), and +compare in constant time. Verify before parsing JSON. + +> **Which secret?** Meta's Instagram webhooks guide says to sign with "your app's **App +> Secret**" (App settings > Basic). Apps using Instagram API with Instagram Login also have a +> separate **Instagram app secret** (Instagram > API setup with Instagram login > Business +> login settings). The docs don't say whether Instagram Login webhooks are signed with that +> one. Start with the App Secret; if verification fails on an Instagram Login app, try the +> Instagram app secret. + +Node: + +```javascript +const crypto = require('crypto'); + +function verify(rawBody, signatureHeader, appSecret) { + const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader || ''); + if (!match || !appSecret) return false; + const received = Buffer.from(match[1], 'hex'); // safe: regex guarantees 64 hex chars + const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest(); + return received.length === expected.length && crypto.timingSafeEqual(received, expected); +} +``` + +Python: + +```python +import hmac, hashlib, re + +def verify(raw_body: bytes, signature_header: str, app_secret: str) -> bool: + match = re.fullmatch(r"sha256=([0-9a-fA-F]{64})", signature_header or "") + if not match or not app_secret: + return False + expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(match.group(1).lower().encode(), expected.encode()) +``` + +In Express, read the body with `express.raw({ type: '*/*' })` so every content type reaches +the verifier as unparsed bytes. + +> **For complete handlers with the GET handshake, event dispatch, and tests**, see: +> - [examples/express/](examples/express/) +> - [examples/nextjs/](examples/nextjs/) +> - [examples/fastapi/](examples/fastapi/) + +## Common Event Types + +Each `entry` carries **either** `changes[]` (field changes) **or** `messaging[]` (messaging +events). For `changes[]` the event is `changes[].field`; for `messaging[]` the event is the +key present on the item. + +| Subscribed field | Arrives in | Item shape | Triggered when | +|------------------|------------|------------|----------------| +| `comments` | `changes[]` | `value.id` or `value.comment_id`, `from`, `text`, `media` | A comment is made on the account's media | +| `live_comments` | `changes[]` | same as `comments` | A comment is made during a live broadcast | +| `mentions` | `changes[]` | `value.media_id`, `value.comment_id` | The account is @mentioned in a caption or comment (Facebook / Unified Login; Instagram Login folds mentions into `comments`) | +| `story_insights` | `changes[]` | `value.media_id`, `impressions`, `reach`, ... | A story expires (first 24h of metrics) | +| `messages` | `messaging[]` | `message` (`is_echo`, `is_deleted`, `attachments`, ...) | A DM is received, or sent by the account (echo) | +| `message_reactions` | `messaging[]` | `reaction` | A message is reacted to / unreacted | +| `messaging_postbacks` | `messaging[]` | `postback` | An Icebreaker or Generic Template button is tapped | +| `messaging_referral` | `messaging[]` | `referral` | An `ig.me` link with `ref` is opened in an existing thread | +| `messaging_seen` | `messaging[]` | `read` | A message is read | + +Fields available depend on the API setup (see [references/overview.md](references/overview.md)). + +```json +{ + "object": "instagram", + "entry": [ + { + "id": "17841405726653026", + "time": 1569262486134, + "messaging": [ + { + "sender": { "id": "IGSID" }, + "recipient": { "id": "17841405726653026" }, + "timestamp": 1569262485349, + "message": { "mid": "MESSAGE-ID", "text": "Hi!" } + } + ] + } + ] +} +``` + +## Important Headers + +| Header | Description | +|--------|-------------| +| `X-Hub-Signature-256` | `sha256=` HMAC-SHA256 of the raw body keyed with the app secret | + +## Environment Variables + +```bash +INSTAGRAM_APP_SECRET=your_app_secret # App settings > Basic > App Secret (see "Which secret?") +INSTAGRAM_VERIFY_TOKEN=your_verify_token # A string you choose; must match the Verify Token in the Dashboard +``` + +## Setup + +1. Your app must be published / **Live** to receive notifications for app users. +2. In the App Dashboard open Webhooks for your Instagram API setup (for Unified Login: + **Configure webhooks** on the **API setup with Unified login** tab; for Instagram Login: + **Instagram > API setup with Instagram login > Configure webhooks**). Enter the Callback + URL and Verify Token, verify, and select fields. +3. Enable notifications for each account with `POST //subscribed_apps?subscribed_fields=...`. +4. **Hookdeck setup:** Paste your Meta app's App Secret (App settings > Basic). If your app + uses Instagram API with Instagram Login and verification fails, use the Instagram app + secret from the Instagram product's API setup page instead. Hookdeck answers the + `hub.challenge` handshake itself. + +See [references/setup.md](references/setup.md) for per-setup tokens, hosts and permissions. + +## Local Development + +```bash +# Start tunnel (no account needed) +npx hookdeck-cli listen 3000 instagram --path /webhooks/instagram +``` + +Use the tunnel URL as the Callback URL in the App Dashboard. + +## Reference Materials + +- [references/overview.md](references/overview.md) - Fields per API setup, payloads, limitations +- [references/setup.md](references/setup.md) - App Dashboard configuration, secrets, enabling subscriptions +- [references/verification.md](references/verification.md) - Handshake and signature verification details + +## Attribution + +When using this skill, add this comment at the top of generated files: + +```javascript +// Generated with: instagram-webhooks skill +// https://github.com/hookdeck/webhook-skills +``` + +## Recommended: webhook-handler-patterns + +We recommend installing the [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) skill alongside this one for handler sequence, idempotency, error handling, and retry logic. Key references (open on GitHub): + +- [Handler sequence](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/handler-sequence.md) — Verify first, parse second, handle idempotently third +- [Idempotency](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/idempotency.md) — Prevent duplicate processing (Meta retries and batches) +- [Error handling](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/error-handling.md) — Return codes, logging, dead letter queues +- [Retry logic](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/retry-logic.md) — Provider retry schedules, backoff patterns + +## Related Skills + +- [messenger-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/messenger-webhooks) - Facebook Messenger Platform webhooks (`object: page`, same Meta mechanism) +- [facebook-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/facebook-webhooks) - Meta Graph API webhooks for Pages (same handshake and signature) +- [threads-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/threads-webhooks) - Threads webhooks (same Meta mechanism) +- [whatsapp-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/whatsapp-webhooks) - WhatsApp Business Platform webhooks (same Meta mechanism) +- [github-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/github-webhooks) - GitHub webhooks (same X-Hub-Signature-256 scheme) +- [tiktok-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/tiktok-webhooks) - TikTok webhook handling +- [twitter-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/twitter-webhooks) - X (Twitter) webhook handling +- [linkedin-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/linkedin-webhooks) - LinkedIn webhook handling +- [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) - Handler sequence, idempotency, error handling, retry logic +- [hookdeck-event-gateway](https://github.com/hookdeck/webhook-skills/tree/main/skills/hookdeck-event-gateway) - Webhook infrastructure that replaces your queue — guaranteed delivery, automatic retries, replay, rate limiting, and observability for your webhook handlers diff --git a/skills/instagram-webhooks/examples/express/.env.example b/skills/instagram-webhooks/examples/express/.env.example new file mode 100644 index 000000000..826a0f803 --- /dev/null +++ b/skills/instagram-webhooks/examples/express/.env.example @@ -0,0 +1,9 @@ +# Secret Meta signs webhook POSTs with (X-Hub-Signature-256). +# Meta's docs say the App Secret (App Dashboard > App settings > Basic). If your app +# uses Instagram API with Instagram Login and verification fails, try the Instagram +# app secret (Instagram > API setup with Instagram login > Business login settings). +INSTAGRAM_APP_SECRET=your_app_secret_here + +# Verify token: a string you choose. Must match the Verify Token entered next to the +# Callback URL in the App Dashboard. Used for the GET handshake. +INSTAGRAM_VERIFY_TOKEN=your_verify_token_here diff --git a/skills/instagram-webhooks/examples/express/README.md b/skills/instagram-webhooks/examples/express/README.md new file mode 100644 index 000000000..43e0661dc --- /dev/null +++ b/skills/instagram-webhooks/examples/express/README.md @@ -0,0 +1,58 @@ +# Instagram Webhooks - Express Example + +Minimal example of receiving Instagram (Meta) webhooks with the GET verification +handshake and `X-Hub-Signature-256` signature verification. + +## Prerequisites + +- Node.js 18+ +- A [Meta for Developers](https://developers.facebook.com/) Business app with the Instagram product, published (Live) + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add `INSTAGRAM_APP_SECRET` and `INSTAGRAM_VERIFY_TOKEN` to `.env`. Meta's docs say + webhooks are signed with the App Secret (App settings > Basic). If your app uses + Instagram API with Instagram Login and verification fails, try the Instagram app + secret instead; the docs don't say which one Instagram Login webhooks use. + +## Run + +```bash +npm start +``` + +Server runs on http://localhost:3000 + +## Test + +### Using Hookdeck CLI + +```bash +# Forward webhooks to localhost (no account required) +npx hookdeck-cli listen 3000 instagram --path /webhooks/instagram +``` + +Use the tunnel URL as the **Callback URL** in the App Dashboard. Meta sends a `GET` +handshake to verify the URL, then `POST`s events. Use **Test** next to a field in the +Dashboard to send a sample notification. + +### Run the tests + +```bash +npm test +``` + +## Endpoints + +- `GET /webhooks/instagram` - Verification handshake (echoes `hub.challenge`) +- `POST /webhooks/instagram` - Verifies the signature, then dispatches `changes[]` (`comments`, `live_comments`, `mentions`, `story_insights`) and `messaging[]` / `standby[]` events (`message`, `reaction`, `postback`, `referral`, `read`, `message_edit`) diff --git a/skills/instagram-webhooks/examples/express/package.json b/skills/instagram-webhooks/examples/express/package.json new file mode 100644 index 000000000..57498d7cf --- /dev/null +++ b/skills/instagram-webhooks/examples/express/package.json @@ -0,0 +1,18 @@ +{ + "name": "instagram-webhooks-express", + "version": "1.0.0", + "description": "Instagram (Meta) webhook handler with Express", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "jest" + }, + "dependencies": { + "dotenv": "^18.0.9", + "express": "^5.3.0" + }, + "devDependencies": { + "jest": "^30.5.2", + "supertest": "^7.3.1" + } +} diff --git a/skills/instagram-webhooks/examples/express/src/index.js b/skills/instagram-webhooks/examples/express/src/index.js new file mode 100644 index 000000000..2a2b59161 --- /dev/null +++ b/skills/instagram-webhooks/examples/express/src/index.js @@ -0,0 +1,179 @@ +// Generated with: instagram-webhooks skill +// https://github.com/hookdeck/webhook-skills +require('dotenv').config({ quiet: true }); +const express = require('express'); +const crypto = require('crypto'); + +const app = express(); + +const SIGNATURE_PATTERN = /^sha256=([0-9a-f]{64})$/i; + +/** + * Verify an Instagram (Meta) webhook signature. + * + * Meta sends X-Hub-Signature-256: sha256=, the HMAC-SHA256 hex digest of + * the RAW request body keyed with the app secret. The pattern check guarantees + * exactly 64 hex characters before Buffer.from(x, 'hex') (which silently + * truncates invalid hex), then the two 32-byte digests are compared in + * constant time. + */ +function verifyInstagramSignature(rawBody, signatureHeader, appSecret) { + if (typeof signatureHeader !== 'string' || !appSecret) { + return false; + } + + const match = SIGNATURE_PATTERN.exec(signatureHeader); + if (!match) { + return false; + } + + const received = Buffer.from(match[1], 'hex'); + const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest(); + + return received.length === expected.length && crypto.timingSafeEqual(received, expected); +} + +/** Field changes: comments, live_comments, mentions, story_insights. */ +function handleChange(change, entry) { + const value = change.value || {}; + + switch (change.field) { + case 'comments': + case 'live_comments': { + // Instagram Login samples use value.id; Unified/Facebook Login use value.comment_id. + const commentId = value.comment_id || value.id; + console.log( + `${change.field}: comment ${commentId} by @${value.from?.username} on media ${value.media?.id}` + ); + // TODO: Moderate or reply to the comment. + break; + } + + case 'mentions': + // comment_id is present only for a mention in a comment. + console.log(`Mention on media ${value.media_id}${value.comment_id ? ` in comment ${value.comment_id}` : ''}`); + // TODO: Fetch the media/comment and respond. + break; + + case 'story_insights': + console.log(`Story ${value.media_id} insights: reach ${value.reach}, impressions ${value.impressions}`); + // TODO: Store metrics (counts under 5 are returned as -1). + break; + + default: + console.log(`Unhandled Instagram field: ${change.field} (account ${entry.id})`); + } +} + +/** Messaging events: the event is the key present on the item. */ +function handleMessagingEvent(event, source) { + const from = event.sender?.id; + + if (event.message) { + const msg = event.message; + if (msg.is_echo) { + console.log(`Echo of message ${msg.mid} sent by the account to ${event.recipient?.id}`); + } else if (msg.is_deleted) { + console.log(`Message ${msg.mid} deleted by ${from}`); + } else if (msg.admin_text && !msg.mid) { + console.log(`Handoff notice (${msg.handoff_reason || 'no reason'}) for ${from}`); + } else { + const kind = msg.text ? 'text' : (msg.attachments || []).map((a) => a.type).join(','); + console.log(`Message ${msg.mid} from ${from} (${kind})`); + // TODO: Reply via the Send API (respond to the webhook first). + } + } else if (event.reaction) { + console.log(`Reaction ${event.reaction.action} ${event.reaction.reaction || ''} on ${event.reaction.mid}`); + } else if (event.postback) { + console.log(`Postback "${event.postback.title}" (${event.postback.payload}) from ${from}`); + } else if (event.referral) { + console.log(`Referral ref=${event.referral.ref} source=${event.referral.source} from ${from}`); + } else if (event.read) { + console.log(`Message ${event.read.mid} seen by ${from}`); + } else if (event.message_edit) { + console.log(`Message ${event.message_edit.mid} edited (${event.message_edit.num_edit} edits)`); + } else { + console.log(`Unhandled Instagram ${source} event: ${Object.keys(event).join(',')}`); + } +} + +/** Dispatch a notification: { object: 'instagram', entry: [...] }. */ +function handleNotification(notification) { + if (notification.object !== 'instagram') { + console.log(`Ignoring object: ${notification.object}`); + return; + } + + for (const entry of notification.entry || []) { + for (const change of entry.changes || []) handleChange(change, entry); + for (const event of entry.messaging || []) handleMessagingEvent(event, 'messaging'); + // Handover protocol: events for threads your app doesn't currently own. + for (const event of entry.standby || []) handleMessagingEvent(event, 'standby'); + } +} + +// Constant-time compare for the GET handshake's hub.verify_token. +function verifyTokenMatches(token, expected) { + if (typeof token !== 'string' || !token || !expected) return false; + const a = Buffer.from(token, 'utf8'); + const b = Buffer.from(expected, 'utf8'); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +// GET verification handshake: Meta sends this when the Callback URL is configured. +app.get('/webhooks/instagram', (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + if (mode === 'subscribe' && verifyTokenMatches(token, process.env.INSTAGRAM_VERIFY_TOKEN)) { + // Echo the challenge back verbatim as plain text. + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + + return res.status(403).send('Forbidden'); +}); + +// POST event delivery: the raw body is needed for signature verification. +// '*/*' makes sure every content type reaches the verifier as a Buffer. +app.post('/webhooks/instagram', express.raw({ type: '*/*' }), (req, res) => { + const signature = req.headers['x-hub-signature-256']; + const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); + + if (!verifyInstagramSignature(rawBody, signature, process.env.INSTAGRAM_APP_SECRET)) { + console.error('Webhook signature verification failed'); + return res.status(401).send('Invalid signature'); + } + + // Parse only after verification. + let payload; + try { + payload = JSON.parse(rawBody.toString('utf8')); + } catch { + return res.status(400).send('Invalid JSON'); + } + + // Some Meta samples wrap the notification in an array; accept both forms. + const notifications = Array.isArray(payload) ? payload : [payload]; + if (!notifications.every((n) => n && typeof n === 'object')) { + return res.status(400).send('Invalid payload'); + } + + notifications.forEach(handleNotification); + + return res.status(200).send('EVENT_RECEIVED'); +}); + +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +module.exports = { app, verifyInstagramSignature }; + +if (require.main === module) { + const PORT = process.env.PORT || 3000; + app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); + console.log(`Webhook endpoint: http://localhost:${PORT}/webhooks/instagram`); + }); +} diff --git a/skills/instagram-webhooks/examples/express/test/webhook.test.js b/skills/instagram-webhooks/examples/express/test/webhook.test.js new file mode 100644 index 000000000..0b9484497 --- /dev/null +++ b/skills/instagram-webhooks/examples/express/test/webhook.test.js @@ -0,0 +1,246 @@ +const request = require('supertest'); +const crypto = require('crypto'); + +const APP_SECRET = 'test_app_secret'; +const VERIFY_TOKEN = 'test_verify_token'; + +process.env.INSTAGRAM_APP_SECRET = APP_SECRET; +process.env.INSTAGRAM_VERIFY_TOKEN = VERIFY_TOKEN; + +const { app, verifyInstagramSignature } = require('../src/index'); + +// Meta signs the raw body: sha256= +function sign(payload, secret = APP_SECRET) { + return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +const ACCOUNT_ID = '17841405726653026'; + +function changeBody(field, value) { + return JSON.stringify({ + object: 'instagram', + entry: [{ id: ACCOUNT_ID, time: 1520622968, changes: [{ field, value }] }], + }); +} + +function messagingBody(item, key = 'messaging') { + return JSON.stringify({ + object: 'instagram', + entry: [ + { + id: ACCOUNT_ID, + time: 1569262486134, + [key]: [{ sender: { id: 'IGSID' }, recipient: { id: ACCOUNT_ID }, timestamp: 1569262485349, ...item }], + }, + ], + }); +} + +const commentBody = changeBody('comments', { + id: '17865799348089039', + from: { id: '1234567890', username: 'test_user' }, + text: 'Nice post', + media: { id: '17918195224117851', media_product_type: 'FEED' }, +}); + +function post(body, signature, contentType = 'application/json') { + const req = request(app).post('/webhooks/instagram').set('Content-Type', contentType); + if (signature !== undefined) req.set('X-Hub-Signature-256', signature); + return req.send(body); +} + +function captureLogs() { + return jest.spyOn(console, 'log').mockImplementation(() => {}); +} + +describe('GET /webhooks/instagram (handshake)', () => { + it('echoes hub.challenge when mode and token match', async () => { + const res = await request(app).get('/webhooks/instagram').query({ + 'hub.mode': 'subscribe', + 'hub.verify_token': VERIFY_TOKEN, + 'hub.challenge': '1158201444', + }); + expect(res.status).toBe(200); + expect(res.text).toBe('1158201444'); + }); + + it('returns 403 on a wrong verify token', async () => { + const res = await request(app).get('/webhooks/instagram').query({ + 'hub.mode': 'subscribe', + 'hub.verify_token': 'nope', + 'hub.challenge': '1158201444', + }); + expect(res.status).toBe(403); + }); + + it('returns 403 when hub.verify_token is missing', async () => { + const res = await request(app).get('/webhooks/instagram').query({ 'hub.mode': 'subscribe' }); + expect(res.status).toBe(403); + }); + + it('returns 403 when hub.mode is not subscribe', async () => { + const res = await request(app).get('/webhooks/instagram').query({ + 'hub.mode': 'unsubscribe', + 'hub.verify_token': VERIFY_TOKEN, + 'hub.challenge': '1', + }); + expect(res.status).toBe(403); + }); +}); + +describe('POST /webhooks/instagram (signature verification)', () => { + let spy; + beforeEach(() => { spy = captureLogs(); }); + afterEach(() => spy.mockRestore()); + + it('accepts a valid signature', async () => { + expect((await post(commentBody, sign(commentBody))).status).toBe(200); + }); + + it('accepts an uppercase hex digest', async () => { + const sig = 'sha256=' + sign(commentBody).slice(7).toUpperCase(); + expect((await post(commentBody, sig)).status).toBe(200); + }); + + it('verifies any content type', async () => { + expect((await post(commentBody, sign(commentBody), 'text/plain')).status).toBe(200); + }); + + it('verifies the escaped-unicode bytes Meta signs', async () => { + // Meta signs e.g. "äöå" as "äöå"; verify over those exact bytes. + const body = changeBody('comments', { id: '1', text: 'PLACEHOLDER' }).replace( + 'PLACEHOLDER', + '\\u00e4\\u00f6\\u00e5' + ); + expect(body).toContain('\\u00e4'); + expect((await post(body, sign(body))).status).toBe(200); + }); + + it('rejects a signature computed over re-serialized (decoded) JSON', async () => { + const body = changeBody('comments', { id: '1', text: 'PLACEHOLDER' }).replace( + 'PLACEHOLDER', + '\\u00e4\\u00f6\\u00e5' + ); + const reserialized = JSON.stringify(JSON.parse(body)); // now contains "äöå" + expect(reserialized).not.toBe(body); + expect((await post(body, sign(reserialized))).status).toBe(401); + }); + + it('verifies a raw UTF-8 body', async () => { + const body = changeBody('comments', { id: '1', text: 'héllo 📸' }); + expect((await post(body, sign(body))).status).toBe(200); + }); + + it('rejects a missing signature header', async () => { + expect((await post(commentBody, undefined)).status).toBe(401); + }); + + it('rejects a signature made with the wrong secret', async () => { + expect((await post(commentBody, sign(commentBody, 'other_secret'))).status).toBe(401); + }); + + it('rejects a tampered body', async () => { + expect((await post(commentBody.replace('Nice', 'Mean'), sign(commentBody))).status).toBe(401); + }); + + it('rejects a header without the sha256= prefix', async () => { + expect((await post(commentBody, sign(commentBody).slice(7))).status).toBe(401); + }); + + it('rejects the legacy sha1= prefix', async () => { + expect((await post(commentBody, 'sha1=' + sign(commentBody).slice(7))).status).toBe(401); + }); + + it('rejects a short, long or non-hex digest instead of truncating it', async () => { + const digest = sign(commentBody).slice(7); + expect((await post(commentBody, 'sha256=' + digest.slice(0, 40))).status).toBe(401); + expect((await post(commentBody, 'sha256=' + digest.slice(0, 62) + 'zz')).status).toBe(401); + expect((await post(commentBody, 'sha256=' + digest + 'ab')).status).toBe(401); + }); + + it('returns 400 for a validly signed non-JSON body', async () => { + expect((await post('not json', sign('not json'), 'text/plain')).status).toBe(400); + }); + + it('returns 400 for a validly signed non-object JSON body', async () => { + expect((await post('42', sign('42'))).status).toBe(400); + }); +}); + +describe('event handling', () => { + const cases = [ + ['comments (Instagram Login)', changeBody('comments', { id: '1', from: { username: 'u' }, media: { id: '2' } })], + ['comments (Facebook Login)', changeBody('comments', { comment_id: '1', from: { username: 'u' }, media: { id: '2' } })], + ['live_comments', changeBody('live_comments', { comment_id: '1', from: { username: 'u' }, media: { id: '2' } })], + ['mentions on media', changeBody('mentions', { media_id: '17918195224117851' })], + ['mentions on comment', changeBody('mentions', { media_id: '1', comment_id: '2' })], + ['story_insights', changeBody('story_insights', { media_id: '1', impressions: 44, reach: 40, exits: -1 })], + ['messages', messagingBody({ message: { mid: 'MID', text: 'Hi' } })], + ['messages with attachments', messagingBody({ message: { mid: 'MID', attachments: [{ type: 'ig_reel', payload: { url: 'https://x' } }] } })], + ['message echo', messagingBody({ message: { mid: 'MID', text: 'Hi', is_echo: true } })], + ['message deleted', messagingBody({ message: { mid: 'MID', is_deleted: true } })], + ['handoff admin_text', messagingBody({ message: { admin_text: 'Needs a human', handoff_reason: 'human_requested' } })], + ['message_reactions', messagingBody({ reaction: { mid: 'MID', action: 'react', reaction: 'love', emoji: '❤️' } })], + ['messaging_postbacks', messagingBody({ postback: { mid: 'MID', title: 'Hours?', payload: 'HOURS' } })], + ['messaging_referral', messagingBody({ referral: { ref: 'promo', source: 'https://ig.me/x', type: 'OPEN_THREAD' } })], + ['messaging_seen', messagingBody({ read: { mid: 'MID' } })], + ['message_edit', messagingBody({ message_edit: { mid: 'MID', text: 'edited', num_edit: 1 } })], + ['standby message', messagingBody({ message: { mid: 'MID', text: 'Hi' } }, 'standby')], + ]; + + it.each(cases)('handles %s', async (_name, body) => { + const spy = captureLogs(); + const res = await post(body, sign(body)); + expect(res.status).toBe(200); + expect(spy.mock.calls.flat().join('\n')).not.toMatch(/Unhandled|Ignoring/); + spy.mockRestore(); + }); + + it('handles a batch with several entries', async () => { + const spy = captureLogs(); + const body = JSON.stringify({ + object: 'instagram', + entry: [ + { id: ACCOUNT_ID, time: 1, changes: [{ field: 'mentions', value: { media_id: '1' } }] }, + { id: ACCOUNT_ID, time: 2, messaging: [{ sender: { id: 'a' }, recipient: { id: 'b' }, timestamp: 2, read: { mid: 'm' } }] }, + ], + }); + expect((await post(body, sign(body))).status).toBe(200); + expect(spy.mock.calls.flat().join('\n')).toMatch(/Mention on media 1/); + expect(spy.mock.calls.flat().join('\n')).toMatch(/seen by a/); + spy.mockRestore(); + }); + + it('accepts a notification wrapped in an array (as in some Meta samples)', async () => { + const spy = captureLogs(); + const body = `[${commentBody}]`; + expect((await post(body, sign(body))).status).toBe(200); + spy.mockRestore(); + }); + + it('acknowledges unknown fields and other objects', async () => { + const spy = captureLogs(); + const unknown = changeBody('something_else', {}); + expect((await post(unknown, sign(unknown))).status).toBe(200); + const page = JSON.stringify({ object: 'page', entry: [] }); + expect((await post(page, sign(page))).status).toBe(200); + spy.mockRestore(); + }); +}); + +describe('verifyInstagramSignature', () => { + it('returns false when the secret is empty', () => { + expect(verifyInstagramSignature(Buffer.from('x'), sign('x'), '')).toBe(false); + }); + + it('returns false for a non-string header', () => { + expect(verifyInstagramSignature(Buffer.from('x'), ['sha256=00'], APP_SECRET)).toBe(false); + }); +}); + +describe('GET /health', () => { + it('returns ok', async () => { + const res = await request(app).get('/health'); + expect(res.body).toEqual({ status: 'ok' }); + }); +}); diff --git a/skills/instagram-webhooks/examples/fastapi/.env.example b/skills/instagram-webhooks/examples/fastapi/.env.example new file mode 100644 index 000000000..826a0f803 --- /dev/null +++ b/skills/instagram-webhooks/examples/fastapi/.env.example @@ -0,0 +1,9 @@ +# Secret Meta signs webhook POSTs with (X-Hub-Signature-256). +# Meta's docs say the App Secret (App Dashboard > App settings > Basic). If your app +# uses Instagram API with Instagram Login and verification fails, try the Instagram +# app secret (Instagram > API setup with Instagram login > Business login settings). +INSTAGRAM_APP_SECRET=your_app_secret_here + +# Verify token: a string you choose. Must match the Verify Token entered next to the +# Callback URL in the App Dashboard. Used for the GET handshake. +INSTAGRAM_VERIFY_TOKEN=your_verify_token_here diff --git a/skills/instagram-webhooks/examples/fastapi/README.md b/skills/instagram-webhooks/examples/fastapi/README.md new file mode 100644 index 000000000..18c616c55 --- /dev/null +++ b/skills/instagram-webhooks/examples/fastapi/README.md @@ -0,0 +1,59 @@ +# Instagram Webhooks - FastAPI Example + +Minimal example of receiving Instagram (Meta) webhooks with the GET verification +handshake and `X-Hub-Signature-256` signature verification. + +## Prerequisites + +- Python 3.9+ +- A [Meta for Developers](https://developers.facebook.com/) Business app with the Instagram product, published (Live) + +## Setup + +1. Install dependencies: + ```bash + python3 -m venv venv && source venv/bin/activate + pip install -r requirements.txt + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add `INSTAGRAM_APP_SECRET` and `INSTAGRAM_VERIFY_TOKEN` to `.env`. Meta's docs say + webhooks are signed with the App Secret (App settings > Basic). If your app uses + Instagram API with Instagram Login and verification fails, try the Instagram app + secret instead; the docs don't say which one Instagram Login webhooks use. + +## Run + +```bash +uvicorn main:app --reload --port 8000 +``` + +Server runs on http://localhost:8000 + +## Test + +### Using Hookdeck CLI + +```bash +# Forward webhooks to localhost (no account required) +npx hookdeck-cli listen 8000 instagram --path /webhooks/instagram +``` + +Use the tunnel URL as the **Callback URL** in the App Dashboard. Meta sends a `GET` +handshake to verify the URL, then `POST`s events. Use **Test** next to a field in the +Dashboard to send a sample notification. + +### Run the tests + +```bash +pytest test_webhook.py -v +``` + +## Endpoints + +- `GET /webhooks/instagram` - Verification handshake (echoes `hub.challenge`) +- `POST /webhooks/instagram` - Verifies the signature, then dispatches `changes[]` (`comments`, `live_comments`, `mentions`, `story_insights`) and `messaging[]` / `standby[]` events (`message`, `reaction`, `postback`, `referral`, `read`, `message_edit`) diff --git a/skills/instagram-webhooks/examples/fastapi/main.py b/skills/instagram-webhooks/examples/fastapi/main.py new file mode 100644 index 000000000..f8b2bc124 --- /dev/null +++ b/skills/instagram-webhooks/examples/fastapi/main.py @@ -0,0 +1,180 @@ +# Generated with: instagram-webhooks skill +# https://github.com/hookdeck/webhook-skills +import os +import re +import hmac +import hashlib +import json +from typing import Optional + +from dotenv import load_dotenv +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import PlainTextResponse + +load_dotenv() + +app = FastAPI() + +SIGNATURE_PATTERN = re.compile(r"sha256=([0-9a-fA-F]{64})") + + +def get_app_secret() -> str: + return os.environ.get("INSTAGRAM_APP_SECRET", "") + + +def get_verify_token() -> str: + return os.environ.get("INSTAGRAM_VERIFY_TOKEN", "") + + +def verify_instagram_signature(raw_body: bytes, signature_header: Optional[str], app_secret: str) -> bool: + """Verify an Instagram (Meta) webhook signature. + + Meta sends ``X-Hub-Signature-256: sha256=``, the HMAC-SHA256 hex digest + of the RAW request body keyed with the app secret. The pattern requires + exactly 64 hex characters (a non-ASCII header simply fails to match, giving + 401 instead of a 500), and the comparison is done on bytes. + """ + if not signature_header or not app_secret: + return False + + match = SIGNATURE_PATTERN.fullmatch(signature_header) + if not match: + return False + + expected = hmac.new(app_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + + return hmac.compare_digest( + match.group(1).lower().encode("ascii"), + expected.encode("ascii"), + ) + + +def handle_change(change: dict, entry: dict) -> None: + """Field changes: comments, live_comments, mentions, story_insights.""" + field = change.get("field") + value = change.get("value") or {} + + if field in ("comments", "live_comments"): + # Instagram Login samples use value.id; Unified/Facebook Login use value.comment_id. + comment_id = value.get("comment_id") or value.get("id") + username = (value.get("from") or {}).get("username") + media_id = (value.get("media") or {}).get("id") + print(f"{field}: comment {comment_id} by @{username} on media {media_id}") + # TODO: Moderate or reply to the comment. + elif field == "mentions": + # comment_id is present only for a mention in a comment. + suffix = f" in comment {value['comment_id']}" if value.get("comment_id") else "" + print(f"Mention on media {value.get('media_id')}{suffix}") + elif field == "story_insights": + # Counts under 5 are returned as -1. + print(f"Story {value.get('media_id')} insights: reach {value.get('reach')}, impressions {value.get('impressions')}") + else: + print(f"Unhandled Instagram field: {field} (account {entry.get('id')})") + + +def handle_messaging_event(event: dict, source: str) -> None: + """Messaging events: the event is the key present on the item.""" + sender = (event.get("sender") or {}).get("id") + + if "message" in event: + msg = event.get("message") or {} + if msg.get("is_echo"): + print(f"Echo of message {msg.get('mid')} sent by the account to {(event.get('recipient') or {}).get('id')}") + elif msg.get("is_deleted"): + print(f"Message {msg.get('mid')} deleted by {sender}") + elif msg.get("admin_text") and not msg.get("mid"): + print(f"Handoff notice ({msg.get('handoff_reason') or 'no reason'}) for {sender}") + else: + kind = "text" if msg.get("text") else ",".join(a.get("type", "") for a in msg.get("attachments") or []) + print(f"Message {msg.get('mid')} from {sender} ({kind})") + # TODO: Reply via the Send API (respond to the webhook first). + elif "reaction" in event: + r = event["reaction"] or {} + print(f"Reaction {r.get('action')} {r.get('reaction') or ''} on {r.get('mid')}") + elif "postback" in event: + p = event["postback"] or {} + print(f"Postback \"{p.get('title')}\" ({p.get('payload')}) from {sender}") + elif "referral" in event: + r = event["referral"] or {} + print(f"Referral ref={r.get('ref')} source={r.get('source')} from {sender}") + elif "read" in event: + print(f"Message {(event['read'] or {}).get('mid')} seen by {sender}") + elif "message_edit" in event: + e = event["message_edit"] or {} + print(f"Message {e.get('mid')} edited ({e.get('num_edit')} edits)") + else: + print(f"Unhandled Instagram {source} event: {','.join(event.keys())}") + + +def handle_notification(notification: dict) -> None: + """Dispatch a notification: { object: 'instagram', entry: [...] }.""" + if notification.get("object") != "instagram": + print(f"Ignoring object: {notification.get('object')}") + return + + for entry in notification.get("entry") or []: + for change in entry.get("changes") or []: + handle_change(change, entry) + for event in entry.get("messaging") or []: + handle_messaging_event(event, "messaging") + # Handover protocol: events for threads your app doesn't currently own. + for event in entry.get("standby") or []: + handle_messaging_event(event, "standby") + + +@app.get("/webhooks/instagram") +async def verify_webhook(request: Request): + """GET verification handshake: Meta sends this when the Callback URL is configured.""" + params = request.query_params + mode = params.get("hub.mode") + token = params.get("hub.verify_token") + challenge = params.get("hub.challenge") + + verify_token = get_verify_token() + if ( + mode == "subscribe" + and token + and verify_token + and hmac.compare_digest(token.encode("utf-8"), verify_token.encode("utf-8")) + ): + # Echo the challenge back verbatim as plain text. + return PlainTextResponse(content=challenge or "", status_code=200) + + raise HTTPException(status_code=403, detail="Forbidden") + + +@app.post("/webhooks/instagram") +async def receive_webhook(request: Request): + """POST event delivery: use the raw body for signature verification.""" + raw_body = await request.body() + signature_header = request.headers.get("x-hub-signature-256") + + if not verify_instagram_signature(raw_body, signature_header, get_app_secret()): + raise HTTPException(status_code=401, detail="Invalid signature") + + # Parse only after verification. + try: + payload = json.loads(raw_body) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid JSON") + + # Some Meta samples wrap the notification in an array; accept both forms. + notifications = payload if isinstance(payload, list) else [payload] + if not all(isinstance(n, dict) for n in notifications): + raise HTTPException(status_code=400, detail="Invalid payload") + + for notification in notifications: + handle_notification(notification) + + return PlainTextResponse(content="EVENT_RECEIVED", status_code=200) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/skills/instagram-webhooks/examples/fastapi/requirements.txt b/skills/instagram-webhooks/examples/fastapi/requirements.txt new file mode 100644 index 000000000..d553dbe46 --- /dev/null +++ b/skills/instagram-webhooks/examples/fastapi/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.162.0 +uvicorn>=0.54.0 +python-dotenv>=1.2.4 +pytest>=9.2.0 +httpx>=0.28.1 diff --git a/skills/instagram-webhooks/examples/fastapi/test_webhook.py b/skills/instagram-webhooks/examples/fastapi/test_webhook.py new file mode 100644 index 000000000..4605f84b9 --- /dev/null +++ b/skills/instagram-webhooks/examples/fastapi/test_webhook.py @@ -0,0 +1,182 @@ +import os +import hmac +import hashlib +import json + +import pytest +from fastapi.testclient import TestClient + +APP_SECRET = "test_app_secret" +VERIFY_TOKEN = "test_verify_token" + +os.environ["INSTAGRAM_APP_SECRET"] = APP_SECRET +os.environ["INSTAGRAM_VERIFY_TOKEN"] = VERIFY_TOKEN + +from main import app # noqa: E402 + +client = TestClient(app) + +ACCOUNT_ID = "17841405726653026" + + +def sign(payload: bytes, secret: str = APP_SECRET) -> str: + """Meta signs the raw body: sha256=.""" + return "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + + +def change_body(field: str, value: dict, ensure_ascii: bool = False) -> bytes: + return json.dumps( + {"object": "instagram", "entry": [{"id": ACCOUNT_ID, "time": 1520622968, "changes": [{"field": field, "value": value}]}]}, + ensure_ascii=ensure_ascii, + ).encode("utf-8") + + +def messaging_body(item: dict, key: str = "messaging") -> bytes: + event = {"sender": {"id": "IGSID"}, "recipient": {"id": ACCOUNT_ID}, "timestamp": 1569262485349, **item} + return json.dumps( + {"object": "instagram", "entry": [{"id": ACCOUNT_ID, "time": 1569262486134, key: [event]}]} + ).encode() + + +def post(body: bytes, signature=None, content_type: str = "application/json"): + headers = {"Content-Type": content_type} + if signature is not None: + headers["X-Hub-Signature-256"] = signature + return client.post("/webhooks/instagram", content=body, headers=headers) + + +COMMENT = change_body( + "comments", + { + "id": "17865799348089039", + "from": {"id": "1234567890", "username": "test_user"}, + "text": "Nice post", + "media": {"id": "17918195224117851", "media_product_type": "FEED"}, + }, +) + +# Meta signs an escaped-unicode payload: "äöå" arrives as "äöå". +ESCAPED = change_body("comments", {"id": "1", "text": "äöå"}, ensure_ascii=True) + + +class TestHandshake: + def test_echoes_challenge(self): + res = client.get( + "/webhooks/instagram", + params={"hub.mode": "subscribe", "hub.verify_token": VERIFY_TOKEN, "hub.challenge": "1158201444"}, + ) + assert res.status_code == 200 + assert res.text == "1158201444" + + def test_wrong_token(self): + res = client.get( + "/webhooks/instagram", + params={"hub.mode": "subscribe", "hub.verify_token": "nope", "hub.challenge": "1"}, + ) + assert res.status_code == 403 + + def test_missing_token(self): + res = client.get("/webhooks/instagram", params={"hub.mode": "subscribe", "hub.challenge": "1"}) + assert res.status_code == 403 + + +class TestSignature: + def test_valid(self): + assert post(COMMENT, sign(COMMENT)).status_code == 200 + + def test_uppercase_hex(self): + assert post(COMMENT, "sha256=" + sign(COMMENT)[7:].upper()).status_code == 200 + + def test_any_content_type(self): + assert post(COMMENT, sign(COMMENT), "text/plain").status_code == 200 + + def test_escaped_unicode_bytes(self): + assert b"\\u00e4\\u00f6\\u00e5" in ESCAPED + assert post(ESCAPED, sign(ESCAPED)).status_code == 200 + + def test_signature_over_decoded_unicode_fails(self): + decoded = json.dumps(json.loads(ESCAPED), ensure_ascii=False).encode("utf-8") + assert decoded != ESCAPED + assert post(ESCAPED, sign(decoded)).status_code == 401 + + def test_raw_utf8_body(self): + body = change_body("comments", {"id": "1", "text": "héllo 📸"}) + assert post(body, sign(body)).status_code == 200 + + def test_missing_header(self): + assert post(COMMENT).status_code == 401 + + def test_wrong_secret(self): + assert post(COMMENT, sign(COMMENT, "other_secret")).status_code == 401 + + def test_tampered_body(self): + assert post(COMMENT.replace(b"Nice", b"Mean"), sign(COMMENT)).status_code == 401 + + def test_missing_prefix(self): + assert post(COMMENT, sign(COMMENT)[7:]).status_code == 401 + + def test_legacy_sha1_prefix(self): + assert post(COMMENT, "sha1=" + sign(COMMENT)[7:]).status_code == 401 + + def test_bad_digest_lengths_and_chars(self): + digest = sign(COMMENT)[7:] + assert post(COMMENT, "sha256=" + digest[:40]).status_code == 401 + assert post(COMMENT, "sha256=" + digest[:62] + "zz").status_code == 401 + assert post(COMMENT, "sha256=" + digest + "ab").status_code == 401 + + def test_non_ascii_header_is_401_not_500(self): + res = client.post( + "/webhooks/instagram", + content=COMMENT, + headers={"X-Hub-Signature-256": "sha256=é".encode("utf-8")}, + ) + assert res.status_code == 401 + + def test_signed_non_json(self): + assert post(b"not json", sign(b"not json"), "text/plain").status_code == 400 + + def test_signed_non_object_json(self): + assert post(b"42", sign(b"42")).status_code == 400 + + +class TestEvents: + @pytest.mark.parametrize( + "body", + [ + change_body("comments", {"id": "1", "from": {"username": "u"}, "media": {"id": "2"}}), + change_body("comments", {"comment_id": "1", "from": {"username": "u"}, "media": {"id": "2"}}), + change_body("live_comments", {"comment_id": "1", "from": {"username": "u"}, "media": {"id": "2"}}), + change_body("mentions", {"media_id": "1"}), + change_body("mentions", {"media_id": "1", "comment_id": "2"}), + change_body("story_insights", {"media_id": "1", "impressions": 44, "reach": 40, "exits": -1}), + messaging_body({"message": {"mid": "MID", "text": "Hi"}}), + messaging_body({"message": {"mid": "MID", "attachments": [{"type": "ig_reel", "payload": {"url": "https://x"}}]}}), + messaging_body({"message": {"mid": "MID", "text": "Hi", "is_echo": True}}), + messaging_body({"message": {"mid": "MID", "is_deleted": True}}), + messaging_body({"message": {"admin_text": "Needs a human", "handoff_reason": "human_requested"}}), + messaging_body({"reaction": {"mid": "MID", "action": "react", "reaction": "love", "emoji": "❤️"}}), + messaging_body({"postback": {"mid": "MID", "title": "Hours?", "payload": "HOURS"}}), + messaging_body({"referral": {"ref": "promo", "source": "https://ig.me/x", "type": "OPEN_THREAD"}}), + messaging_body({"read": {"mid": "MID"}}), + messaging_body({"message_edit": {"mid": "MID", "text": "edited", "num_edit": 1}}), + messaging_body({"message": {"mid": "MID", "text": "Hi"}}, "standby"), + ], + ) + def test_known_events(self, body, capsys): + assert post(body, sign(body)).status_code == 200 + out = capsys.readouterr().out + assert "Unhandled" not in out and "Ignoring" not in out + + def test_array_wrapped_notification(self): + body = b"[" + COMMENT + b"]" + assert post(body, sign(body)).status_code == 200 + + def test_unknown_field_and_object_acknowledged(self): + body = change_body("something_else", {}) + assert post(body, sign(body)).status_code == 200 + page = json.dumps({"object": "page", "entry": []}).encode() + assert post(page, sign(page)).status_code == 200 + + +def test_health(): + assert client.get("/health").json() == {"status": "ok"} diff --git a/skills/instagram-webhooks/examples/nextjs/.env.example b/skills/instagram-webhooks/examples/nextjs/.env.example new file mode 100644 index 000000000..826a0f803 --- /dev/null +++ b/skills/instagram-webhooks/examples/nextjs/.env.example @@ -0,0 +1,9 @@ +# Secret Meta signs webhook POSTs with (X-Hub-Signature-256). +# Meta's docs say the App Secret (App Dashboard > App settings > Basic). If your app +# uses Instagram API with Instagram Login and verification fails, try the Instagram +# app secret (Instagram > API setup with Instagram login > Business login settings). +INSTAGRAM_APP_SECRET=your_app_secret_here + +# Verify token: a string you choose. Must match the Verify Token entered next to the +# Callback URL in the App Dashboard. Used for the GET handshake. +INSTAGRAM_VERIFY_TOKEN=your_verify_token_here diff --git a/skills/instagram-webhooks/examples/nextjs/README.md b/skills/instagram-webhooks/examples/nextjs/README.md new file mode 100644 index 000000000..a17da7691 --- /dev/null +++ b/skills/instagram-webhooks/examples/nextjs/README.md @@ -0,0 +1,58 @@ +# Instagram Webhooks - Next.js Example + +Minimal example of receiving Instagram (Meta) webhooks with the GET verification +handshake and `X-Hub-Signature-256` signature verification. + +## Prerequisites + +- Node.js 18+ +- A [Meta for Developers](https://developers.facebook.com/) Business app with the Instagram product, published (Live) + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add `INSTAGRAM_APP_SECRET` and `INSTAGRAM_VERIFY_TOKEN` to `.env`. Meta's docs say + webhooks are signed with the App Secret (App settings > Basic). If your app uses + Instagram API with Instagram Login and verification fails, try the Instagram app + secret instead; the docs don't say which one Instagram Login webhooks use. + +## Run + +```bash +npm run dev +``` + +Server runs on http://localhost:3000 (`app/webhooks/instagram/route.ts`) + +## Test + +### Using Hookdeck CLI + +```bash +# Forward webhooks to localhost (no account required) +npx hookdeck-cli listen 3000 instagram --path /webhooks/instagram +``` + +Use the tunnel URL as the **Callback URL** in the App Dashboard. Meta sends a `GET` +handshake to verify the URL, then `POST`s events. Use **Test** next to a field in the +Dashboard to send a sample notification. + +### Run the tests + +```bash +npm test +``` + +## Endpoints + +- `GET /webhooks/instagram` - Verification handshake (echoes `hub.challenge`) +- `POST /webhooks/instagram` - Verifies the signature, then dispatches `changes[]` (`comments`, `live_comments`, `mentions`, `story_insights`) and `messaging[]` / `standby[]` events (`message`, `reaction`, `postback`, `referral`, `read`, `message_edit`) diff --git a/skills/instagram-webhooks/examples/nextjs/app/webhooks/instagram/route.ts b/skills/instagram-webhooks/examples/nextjs/app/webhooks/instagram/route.ts new file mode 100644 index 000000000..336eeb555 --- /dev/null +++ b/skills/instagram-webhooks/examples/nextjs/app/webhooks/instagram/route.ts @@ -0,0 +1,176 @@ +// Generated with: instagram-webhooks skill +// https://github.com/hookdeck/webhook-skills +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; + +const SIGNATURE_PATTERN = /^sha256=([0-9a-f]{64})$/i; + +/** + * Verify an Instagram (Meta) webhook signature. + * + * Meta sends X-Hub-Signature-256: sha256=, the HMAC-SHA256 hex digest of + * the RAW request body keyed with the app secret. The pattern check guarantees + * exactly 64 hex characters before Buffer.from(x, 'hex'), then the two 32-byte + * digests are compared in constant time. + */ +function verifyInstagramSignature( + rawBody: Buffer, + signatureHeader: string | null, + appSecret: string | undefined +): boolean { + if (!signatureHeader || !appSecret) { + return false; + } + + const match = SIGNATURE_PATTERN.exec(signatureHeader); + if (!match) { + return false; + } + + const received = Buffer.from(match[1], 'hex'); + const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest(); + + return received.length === expected.length && crypto.timingSafeEqual(received, expected); +} + +type Json = Record; + +interface InstagramEntry { + id?: string; + time?: number; + changes?: Array<{ field?: string; value?: Json }>; + messaging?: Json[]; + standby?: Json[]; +} + +interface InstagramNotification { + object?: string; + entry?: InstagramEntry[]; +} + +/** Field changes: comments, live_comments, mentions, story_insights. */ +function handleChange(change: { field?: string; value?: Json }, entry: InstagramEntry): void { + const value = change.value || {}; + + switch (change.field) { + case 'comments': + case 'live_comments': { + // Instagram Login samples use value.id; Unified/Facebook Login use value.comment_id. + const commentId = value.comment_id || value.id; + console.log(`${change.field}: comment ${commentId} by @${value.from?.username} on media ${value.media?.id}`); + // TODO: Moderate or reply to the comment. + break; + } + case 'mentions': + // comment_id is present only for a mention in a comment. + console.log(`Mention on media ${value.media_id}${value.comment_id ? ` in comment ${value.comment_id}` : ''}`); + break; + case 'story_insights': + console.log(`Story ${value.media_id} insights: reach ${value.reach}, impressions ${value.impressions}`); + break; + default: + console.log(`Unhandled Instagram field: ${change.field} (account ${entry.id})`); + } +} + +/** Messaging events: the event is the key present on the item. */ +function handleMessagingEvent(event: Json, source: string): void { + const from = event.sender?.id; + + if (event.message) { + const msg = event.message; + if (msg.is_echo) { + console.log(`Echo of message ${msg.mid} sent by the account to ${event.recipient?.id}`); + } else if (msg.is_deleted) { + console.log(`Message ${msg.mid} deleted by ${from}`); + } else if (msg.admin_text && !msg.mid) { + console.log(`Handoff notice (${msg.handoff_reason || 'no reason'}) for ${from}`); + } else { + const kind = msg.text ? 'text' : (msg.attachments || []).map((a: Json) => a.type).join(','); + console.log(`Message ${msg.mid} from ${from} (${kind})`); + // TODO: Reply via the Send API (respond to the webhook first). + } + } else if (event.reaction) { + console.log(`Reaction ${event.reaction.action} ${event.reaction.reaction || ''} on ${event.reaction.mid}`); + } else if (event.postback) { + console.log(`Postback "${event.postback.title}" (${event.postback.payload}) from ${from}`); + } else if (event.referral) { + console.log(`Referral ref=${event.referral.ref} source=${event.referral.source} from ${from}`); + } else if (event.read) { + console.log(`Message ${event.read.mid} seen by ${from}`); + } else if (event.message_edit) { + console.log(`Message ${event.message_edit.mid} edited (${event.message_edit.num_edit} edits)`); + } else { + console.log(`Unhandled Instagram ${source} event: ${Object.keys(event).join(',')}`); + } +} + +/** Dispatch a notification: { object: 'instagram', entry: [...] }. */ +function handleNotification(notification: InstagramNotification): void { + if (notification.object !== 'instagram') { + console.log(`Ignoring object: ${notification.object}`); + return; + } + + for (const entry of notification.entry || []) { + for (const change of entry.changes || []) handleChange(change, entry); + for (const event of entry.messaging || []) handleMessagingEvent(event, 'messaging'); + // Handover protocol: events for threads your app doesn't currently own. + for (const event of entry.standby || []) handleMessagingEvent(event, 'standby'); + } +} + +// Constant-time compare for the GET handshake's hub.verify_token. +function verifyTokenMatches(token: string | null, expected: string | undefined): boolean { + if (!token || !expected) return false; + const a = Buffer.from(token, 'utf8'); + const b = Buffer.from(expected, 'utf8'); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +// GET verification handshake: Meta sends this when the Callback URL is configured. +export async function GET(request: NextRequest) { + const params = request.nextUrl.searchParams; + const mode = params.get('hub.mode'); + const token = params.get('hub.verify_token'); + const challenge = params.get('hub.challenge'); + + if (mode === 'subscribe' && verifyTokenMatches(token, process.env.INSTAGRAM_VERIFY_TOKEN)) { + // Echo the challenge back verbatim as plain text. + return new NextResponse(challenge ?? '', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + return new NextResponse('Forbidden', { status: 403 }); +} + +// POST event delivery: read the raw bytes for signature verification. +export async function POST(request: NextRequest) { + const rawBody = Buffer.from(await request.arrayBuffer()); + const signature = request.headers.get('x-hub-signature-256'); + + if (!verifyInstagramSignature(rawBody, signature, process.env.INSTAGRAM_APP_SECRET)) { + console.error('Webhook signature verification failed'); + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); + } + + // Parse only after verification. + let payload: unknown; + try { + payload = JSON.parse(rawBody.toString('utf8')); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + // Some Meta samples wrap the notification in an array; accept both forms. + const notifications = Array.isArray(payload) ? payload : [payload]; + if (!notifications.every((n) => n !== null && typeof n === 'object')) { + return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + } + + (notifications as InstagramNotification[]).forEach(handleNotification); + + return new NextResponse('EVENT_RECEIVED', { status: 200 }); +} diff --git a/skills/instagram-webhooks/examples/nextjs/package.json b/skills/instagram-webhooks/examples/nextjs/package.json new file mode 100644 index 000000000..c69edb700 --- /dev/null +++ b/skills/instagram-webhooks/examples/nextjs/package.json @@ -0,0 +1,23 @@ +{ + "name": "instagram-webhooks-nextjs", + "version": "1.0.0", + "description": "Instagram (Meta) webhook handler with Next.js", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "vitest run" + }, + "dependencies": { + "next": "^16.4.5", + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@types/node": "^26.6.6", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "typescript": "^7.0.2", + "vitest": "^5.1.0" + } +} diff --git a/skills/instagram-webhooks/examples/nextjs/test/webhook.test.ts b/skills/instagram-webhooks/examples/nextjs/test/webhook.test.ts new file mode 100644 index 000000000..aa380aa7e --- /dev/null +++ b/skills/instagram-webhooks/examples/nextjs/test/webhook.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import crypto from 'crypto'; +import { NextRequest } from 'next/server'; + +const APP_SECRET = 'test_app_secret'; +const VERIFY_TOKEN = 'test_verify_token'; + +// Set test environment variables before the route is used +beforeAll(() => { + process.env.INSTAGRAM_APP_SECRET = APP_SECRET; + process.env.INSTAGRAM_VERIFY_TOKEN = VERIFY_TOKEN; +}); + +import { GET, POST } from '../app/webhooks/instagram/route'; + +const URL_BASE = 'http://localhost:3000/webhooks/instagram'; +const ACCOUNT_ID = '17841405726653026'; + +// Meta signs the raw body: sha256= +function sign(payload: string, secret: string = APP_SECRET): string { + return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +function changeBody(field: string, value: Record): string { + return JSON.stringify({ + object: 'instagram', + entry: [{ id: ACCOUNT_ID, time: 1520622968, changes: [{ field, value }] }], + }); +} + +function messagingBody(item: Record, key = 'messaging'): string { + return JSON.stringify({ + object: 'instagram', + entry: [ + { + id: ACCOUNT_ID, + time: 1569262486134, + [key]: [{ sender: { id: 'IGSID' }, recipient: { id: ACCOUNT_ID }, timestamp: 1569262485349, ...item }], + }, + ], + }); +} + +function post(body: string, signature?: string, contentType = 'application/json'): NextRequest { + const headers: Record = { 'Content-Type': contentType }; + if (signature !== undefined) headers['x-hub-signature-256'] = signature; + return new NextRequest(URL_BASE, { method: 'POST', headers, body }); +} + +const commentBody = changeBody('comments', { + id: '17865799348089039', + from: { id: '1234567890', username: 'test_user' }, + text: 'Nice post', + media: { id: '17918195224117851', media_product_type: 'FEED' }, +}); + +const escapedBody = changeBody('comments', { id: '1', text: 'PLACEHOLDER' }).replace( + 'PLACEHOLDER', + '\\u00e4\\u00f6\\u00e5' +); + +describe('GET /webhooks/instagram (handshake)', () => { + it('echoes hub.challenge when mode and token match', async () => { + const res = await GET( + new NextRequest(`${URL_BASE}?hub.mode=subscribe&hub.verify_token=${VERIFY_TOKEN}&hub.challenge=1158201444`) + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe('1158201444'); + }); + + it('returns 403 on a wrong verify token', async () => { + const res = await GET(new NextRequest(`${URL_BASE}?hub.mode=subscribe&hub.verify_token=nope&hub.challenge=1`)); + expect(res.status).toBe(403); + }); + + it('returns 403 when hub.verify_token is missing', async () => { + const res = await GET(new NextRequest(`${URL_BASE}?hub.mode=subscribe&hub.challenge=1`)); + expect(res.status).toBe(403); + }); +}); + +describe('POST /webhooks/instagram (signature verification)', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + it('accepts a valid signature', async () => { + expect((await POST(post(commentBody, sign(commentBody)))).status).toBe(200); + }); + + it('accepts an uppercase hex digest', async () => { + expect((await POST(post(commentBody, 'sha256=' + sign(commentBody).slice(7).toUpperCase()))).status).toBe(200); + }); + + it('verifies any content type', async () => { + expect((await POST(post(commentBody, sign(commentBody), 'text/plain'))).status).toBe(200); + }); + + it('verifies the escaped-unicode bytes Meta signs', async () => { + expect((await POST(post(escapedBody, sign(escapedBody)))).status).toBe(200); + }); + + it('rejects a signature computed over re-serialized (decoded) JSON', async () => { + const reserialized = JSON.stringify(JSON.parse(escapedBody)); + expect(reserialized).not.toBe(escapedBody); + expect((await POST(post(escapedBody, sign(reserialized)))).status).toBe(401); + }); + + it('verifies a raw UTF-8 body', async () => { + const body = changeBody('comments', { id: '1', text: 'héllo 📸' }); + expect((await POST(post(body, sign(body)))).status).toBe(200); + }); + + it('rejects a missing signature header', async () => { + expect((await POST(post(commentBody))).status).toBe(401); + }); + + it('rejects a signature made with the wrong secret', async () => { + expect((await POST(post(commentBody, sign(commentBody, 'other_secret')))).status).toBe(401); + }); + + it('rejects a tampered body', async () => { + expect((await POST(post(commentBody.replace('Nice', 'Mean'), sign(commentBody)))).status).toBe(401); + }); + + it('rejects a header without the sha256= prefix', async () => { + expect((await POST(post(commentBody, sign(commentBody).slice(7)))).status).toBe(401); + }); + + it('rejects the legacy sha1= prefix', async () => { + expect((await POST(post(commentBody, 'sha1=' + sign(commentBody).slice(7)))).status).toBe(401); + }); + + it('rejects a short, long or non-hex digest instead of truncating it', async () => { + const digest = sign(commentBody).slice(7); + expect((await POST(post(commentBody, 'sha256=' + digest.slice(0, 40)))).status).toBe(401); + expect((await POST(post(commentBody, 'sha256=' + digest.slice(0, 62) + 'zz'))).status).toBe(401); + expect((await POST(post(commentBody, 'sha256=' + digest + 'ab'))).status).toBe(401); + }); + + it('returns 400 for a validly signed non-JSON body', async () => { + expect((await POST(post('not json', sign('not json'), 'text/plain'))).status).toBe(400); + }); + + it('returns 400 for a validly signed non-object JSON body', async () => { + expect((await POST(post('42', sign('42')))).status).toBe(400); + }); +}); + +describe('event handling', () => { + const cases: Array<[string, string]> = [ + ['comments (Instagram Login)', changeBody('comments', { id: '1', from: { username: 'u' }, media: { id: '2' } })], + ['comments (Facebook Login)', changeBody('comments', { comment_id: '1', from: { username: 'u' }, media: { id: '2' } })], + ['live_comments', changeBody('live_comments', { comment_id: '1', from: { username: 'u' }, media: { id: '2' } })], + ['mentions', changeBody('mentions', { media_id: '1', comment_id: '2' })], + ['story_insights', changeBody('story_insights', { media_id: '1', impressions: 44, reach: 40 })], + ['messages', messagingBody({ message: { mid: 'MID', text: 'Hi' } })], + ['message echo', messagingBody({ message: { mid: 'MID', text: 'Hi', is_echo: true } })], + ['message deleted', messagingBody({ message: { mid: 'MID', is_deleted: true } })], + ['message_reactions', messagingBody({ reaction: { mid: 'MID', action: 'unreact' } })], + ['messaging_postbacks', messagingBody({ postback: { mid: 'MID', title: 'Hours?', payload: 'HOURS' } })], + ['messaging_referral', messagingBody({ referral: { ref: 'promo', source: 'https://ig.me/x', type: 'OPEN_THREAD' } })], + ['messaging_seen', messagingBody({ read: { mid: 'MID' } })], + ['message_edit', messagingBody({ message_edit: { mid: 'MID', text: 'edited', num_edit: 1 } })], + ['standby message', messagingBody({ message: { mid: 'MID', text: 'Hi' } }, 'standby')], + ]; + + it.each(cases)('handles %s', async (_name, body) => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const res = await POST(post(body, sign(body))); + expect(res.status).toBe(200); + expect(spy.mock.calls.flat().join('\n')).not.toMatch(/Unhandled|Ignoring/); + spy.mockRestore(); + }); + + it('accepts a notification wrapped in an array', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const body = `[${commentBody}]`; + expect((await POST(post(body, sign(body)))).status).toBe(200); + spy.mockRestore(); + }); + + it('acknowledges unknown fields', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const body = changeBody('something_else', {}); + expect((await POST(post(body, sign(body)))).status).toBe(200); + spy.mockRestore(); + }); +}); diff --git a/skills/instagram-webhooks/examples/nextjs/vitest.config.ts b/skills/instagram-webhooks/examples/nextjs/vitest.config.ts new file mode 100644 index 000000000..8e730d505 --- /dev/null +++ b/skills/instagram-webhooks/examples/nextjs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/skills/instagram-webhooks/references/overview.md b/skills/instagram-webhooks/references/overview.md new file mode 100644 index 000000000..3b6e789df --- /dev/null +++ b/skills/instagram-webhooks/references/overview.md @@ -0,0 +1,137 @@ +# Instagram Webhooks Overview + +## What Are Instagram Webhooks? + +Instagram webhooks send real-time notifications to your server when supported activity happens +on an Instagram professional account connected to your app: comments, mentions, story insights, +direct messages and related messaging events. They use Meta's webhook mechanism (a GET +handshake with a verify token, then POSTs signed with `X-Hub-Signature-256`). + +## Which API Setup Are You Using? + +Field availability, permissions, tokens and some payloads depend on the setup: + +| API setup | Token | API host | Account ID used to subscribe | +|-----------|-------|----------|------------------------------| +| Instagram API with Unified Login | Business Integration System User Access Token | `graph.facebook.com` | Instagram professional account ID | +| Instagram API with Instagram Login | Instagram User access token | `graph.instagram.com` | Instagram professional account ID or `/me` | +| Instagram API with Facebook Login | Facebook Page access token | `graph.facebook.com` | Facebook Page ID or `/me` (account must be linked to a Page) | + +Instagram messaging can also be built on the Messenger Platform ("Instagram Messaging API"), +which delivers the same `object: "instagram"` messaging payloads. + +## Fields and Permissions + +| Field | Instagram Login | Facebook Login | Messenger Platform (IG messaging) | Unified Login | +|-------|-----------------|----------------|-----------------------------------|---------------| +| `comments` | `instagram_business_basic`, `instagram_business_manage_comments` | `instagram_basic`, `instagram_manage_comments`, `pages_manage_metadata`, `pages_read_engagement`, `pages_show_list` | — | `instagram_manage_comments` | +| `live_comments` | same as `comments` | same as `comments` | — | `instagram_manage_comments` | +| `mentions` | Included in `comments` | same as `comments` | — | `instagram_manage_comments` | +| `story_insights` | — | `instagram_basic`, `instagram_manage_insights`, Page permissions | — | `instagram_manage_insights` | +| `messages` | `instagram_business_basic`, `instagram_business_manage_messages` | — | `instagram_basic`, `instagram_manage_messages`, Page permissions | `instagram_manage_messages` | +| `message_reactions` | same as `messages` | — | same as `messages` | `instagram_manage_messages` | +| `messaging_postbacks` | same as `messages` | — | same as `messages` | `instagram_manage_messages` | +| `messaging_referral` | same as `messages` | — | same as `messages` | `instagram_manage_messages` | +| `messaging_seen` | same as `messages` | — | same as `messages` | `instagram_manage_messages` | +| `messaging_optins` | same as `messages` | — | — | — | +| `messaging_handover`, `standby` | same as `messages` | — | same as `messages` | — | +| `message_echoes` | `instagram_business_basic`, `instagram_business_manage_comments` (as listed by Meta) | — | Included in `messages` (`is_echo: true`) | — | +| `messaging_policy_enforcement`, `response_feedback` | — | — | same as `messages` | — | + +"Page permissions" = `pages_manage_metadata`, `pages_read_engagement`, `pages_show_list`. See +[Instagram webhook fields and permissions](https://developers.facebook.com/documentation/instagram-platform/webhooks/fields) +for the authoritative matrix. + +## Event Payload Structure + +```json +{ + "object": "instagram", + "entry": [ + { "id": "", "time": 1569262486134, "changes": [ ... ] } + ] +} +``` + +- `entry[].id` is your app user's Instagram professional account ID. +- `entry[].time` is when Meta sent the notification. +- An entry has **`changes[]`** for field changes (comments, mentions, insights) or + **`messaging[]`** for messaging events. Handover-protocol deliveries use `standby[]`. +- Meta may batch up to 1000 updates in one POST; batching isn't guaranteed. Iterate every + `entry` and every item. +- Some samples on Meta's examples page are wrapped in a top-level JSON array; the messaging + pages and Meta's general guide show a single object. The examples here accept both. + +### `changes[]` events + +`comments` / `live_comments`: + +```json +{ + "field": "comments", + "value": { + "id": "", + "from": { "id": "", "username": "" }, + "text": "", + "media": { "id": "", "media_product_type": "" } + } +} +``` + +Instagram Login samples use `value.id`; Unified Login / Facebook Login samples use +`value.comment_id` plus optional `parent_id` and ad fields (`media.ad_id`, `media.ad_title`, +`media.original_media_id`). Read whichever is present. + +`mentions` (Facebook / Unified Login only): + +```json +{ "field": "mentions", "value": { "media_id": "17918195224117851", "comment_id": "17894227972186120" } } +``` + +`comment_id` is present only for a mention in a comment. + +`story_insights` (from the Graph API Instagram webhooks reference): `value.media_id`, +`impressions`, `reach`, `taps_forward`, `taps_back`, `exits`, `replies`. Counts under 5 are +returned as `-1`. The `story_insights` sample on the Instagram examples page is a copy of the +postback sample, so rely on the reference fields above. + +### `messaging[]` events + +Every item has `sender.id`, `recipient.id` and `timestamp`. When the account **receives** a +message, `recipient.id` is your account and `sender.id` is the user's Instagram-scoped ID +(IGSID); for a message the account **sends** (echo), they're swapped. + +| Key on the item | Field | Notes | +|-----------------|-------|-------| +| `message` | `messages` | `mid`, `text`, `attachments[]`, `is_echo`, `is_deleted`, `is_self`, `is_unsupported`, `quick_reply`, `referral`, `reply_to`; handoffs can carry `admin_text` and `handoff_reason` without `mid` | +| `reaction` | `message_reactions` | `mid`, `action` (`react`/`unreact`), `reaction`, `emoji` | +| `postback` | `messaging_postbacks` | `mid`, `title`, `payload` | +| `referral` | `messaging_referral` | `ref`, `source`, `type: OPEN_THREAD` | +| `read` | `messaging_seen` | `mid` of the message that was seen | +| `message_edit` | (shown on the examples page) | `mid`, `text`, `num_edit` | + +Attachment types include `image`, `video`, `audio`, `file`, `ig_post`, `ig_reel`, `reel`, +`story_mention`, `ig_story`; the legacy `share` type is deprecated after February 2026 in +favour of `ig_post`. + +## Limitations + +- The app must be Live; `comments` and `live_comments` need Advanced Access. +- Media owned by private accounts doesn't trigger comment or mention notifications. +- Live comment notifications stop when the broadcast ends. +- Fields are selected per app, not per account. +- No album IDs, and no ad IDs for media in dynamic ads. +- `story_insights` only covers the first 24 hours. +- Instagram messaging: GIFs and stickers don't trigger a webhook; disappearing media isn't supported. + +## Retries + +Meta retries failed deliveries immediately, then a few more times with decreasing frequency +over 36 hours, then drops them. Deduplicate (comment ID, message `mid`). + +## Full Event Reference + +- [Instagram Webhooks](https://developers.facebook.com/documentation/instagram-platform/webhooks) +- [Webhook fields and permissions](https://developers.facebook.com/documentation/instagram-platform/webhooks/fields) +- [Webhook notification examples](https://developers.facebook.com/documentation/instagram-platform/webhooks/examples) +- [Webhooks for Instagram Messaging](https://developers.facebook.com/documentation/business-messaging/instagram-messaging/webhooks) diff --git a/skills/instagram-webhooks/references/setup.md b/skills/instagram-webhooks/references/setup.md new file mode 100644 index 000000000..c62ba6092 --- /dev/null +++ b/skills/instagram-webhooks/references/setup.md @@ -0,0 +1,92 @@ +# Setting Up Instagram Webhooks + +## Prerequisites + +- A [Meta for Developers](https://developers.facebook.com/) **Business** app with the + **Instagram** product +- The app published / **Live** (required to receive notifications for app users), Advanced + Access for the fields that need it, and Business Verification for production access +- A publicly reachable HTTPS Callback URL with a valid (not self-signed) certificate +- An Instagram **professional** account (public, for comments and mentions) + +## Get Your Signing Secret + +Meta's Instagram webhooks guide says payloads are signed "using the payload and your app's +**App Secret**", and its sample app copies the secret from **App settings > Basic**. + +Apps that use **Instagram API with Instagram Login** also have an **Instagram app secret** +(App Dashboard > Instagram > API setup with Instagram login > 3. Set up Instagram business +login > Business login settings > Instagram app secret), used for the OAuth token exchange. +**The docs don't say whether webhooks for Instagram Login apps are signed with that secret.** +Use the App Secret first; if verification fails on an Instagram Login app, try the Instagram +app secret. Store the one that works as `INSTAGRAM_APP_SECRET`. + +## Choose a Verify Token + +Any string you invent (`openssl rand -hex 20`). Store it as `INSTAGRAM_VERIFY_TOKEN` and +enter the same value in the Dashboard's **Verify Token** field. + +## Register Your Endpoint (Callback URL and Fields) + +Open Webhooks for your API setup: + +- **Unified Login:** **Configure webhooks** on the **API setup with Unified login** tab. +- **Instagram Login:** **Instagram > API setup with Instagram login**, section + **Configure webhooks** > **Configure**. New apps are subscribed by default to `comments`, + `live_comments`, `message_reactions`, `messages`, `messaging_optins`, + `messaging_postbacks`, `messaging_referral` and `messaging_seen`. +- **Facebook Login / Messenger Platform:** the Webhooks product (or Messenger > + Instagram settings for Instagram messaging). + +Then: + +1. Enter the **Callback URL** (`https://your-domain.com/webhooks/instagram`) and the Verify Token. +2. Click verify/save. Meta sends a `GET` handshake; your endpoint must echo `hub.challenge`. +3. Select only the fields your app needs, and request only their permissions + (see [overview.md](overview.md)). + +## Enable Notifications for Each Account + +Subscribing the app is not enough: subscribe each app user's account with the matching token. + +```bash +# Unified Login +curl -X POST "https://graph.facebook.com/v27.0//subscribed_apps" \ + -d "subscribed_fields=comments,messages" \ + -d "access_token=" + +# Instagram Login +curl -X POST "https://graph.instagram.com/v27.0//subscribed_apps?subscribed_fields=comments,messages&access_token=" + +# Facebook Login (subscribe the linked Page) +curl -X POST "https://graph.facebook.com/v27.0//subscribed_apps?subscribed_fields=comments,messages&access_token=" +``` + +Each returns `{"success": true}`. Instagram User tokens and `graph.instagram.com` don't work +with Unified Login. + +## Hookdeck Setup + +Paste your Meta app's App Secret (App settings > Basic). If your app uses Instagram API with +Instagram Login and verification fails, use the Instagram app secret from the Instagram +product's API setup page instead. + +Hookdeck's edge answers the `hub.challenge` handshake itself, so the destination doesn't need +to handle the GET. Use the Hookdeck source URL as the Callback URL. + +For local development: + +```bash +npx hookdeck-cli listen 3000 instagram --path /webhooks/instagram +``` + +## Testing + +1. In the Dashboard, use **Test** next to a field to send a sample notification. +2. Trigger a real event (for messaging, send the account a test DM). +3. Confirm the signature verifies, the endpoint returns `200` quickly, and a duplicate + delivery doesn't repeat the action. + +Optional: Meta supports mTLS for webhooks (client certificate CN +`client.webhooks.fbclientcerts.com`, signed by Meta's outbound API CA). It doesn't replace +signature verification. diff --git a/skills/instagram-webhooks/references/verification.md b/skills/instagram-webhooks/references/verification.md new file mode 100644 index 000000000..fb55a076a --- /dev/null +++ b/skills/instagram-webhooks/references/verification.md @@ -0,0 +1,78 @@ +# How to Verify Instagram Webhook Signatures + +Instagram uses Meta's webhook verification: a GET handshake plus an `X-Hub-Signature-256` +signature on every POST. See also the +[Meta reference](https://github.com/hookdeck/webhook-skills/blob/main/skills/facebook-webhooks/references/verification.md). + +## 1. GET Handshake + +Meta sends `hub.mode=subscribe`, `hub.verify_token` and `hub.challenge` whenever you configure +the webhook in the App Dashboard. Respond `200` with the raw `hub.challenge` when the mode is +`subscribe` and the token matches `INSTAGRAM_VERIFY_TOKEN`; otherwise `403`. (Hookdeck's edge +answers this itself.) + +## 2. POST Signature + +- Header: `X-Hub-Signature-256: sha256=` +- Algorithm: HMAC-SHA256, hex-encoded +- Signed content: the raw request body, byte for byte +- Key: the app secret (see below) +- No timestamp is signed, so there is no tolerance window to check + +### Which secret signs? + +Meta's guide: "Generate a SHA256 signature using the payload and your app's App Secret." +Instagram Login apps also have an Instagram app secret; the docs don't say whether it's used +for webhooks. Try the App Secret first, then the Instagram app secret if verification fails on +an Instagram Login app. + +### Node.js + +Validate the format with a regex before any `Buffer.from(x, 'hex')` (invalid hex silently +truncates), then compare equal-length buffers with `crypto.timingSafeEqual`: + +```javascript +const crypto = require('crypto'); + +function verifyInstagramSignature(rawBody, signatureHeader, appSecret) { + const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader || ''); + if (!match || !appSecret) return false; + const received = Buffer.from(match[1], 'hex'); + const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest(); + return received.length === expected.length && crypto.timingSafeEqual(received, expected); +} +``` + +### Python + +```python +import hmac, hashlib, re + +def verify_instagram_signature(raw_body: bytes, signature_header: str, app_secret: str) -> bool: + match = re.fullmatch(r"sha256=([0-9a-fA-F]{64})", signature_header or "") + if not match or not app_secret: + return False + expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest() + # bytes on both sides: a non-ASCII header gives 401, not a TypeError/500 + return hmac.compare_digest(match.group(1).lower().encode(), expected.encode()) +``` + +## Common Gotchas + +- **Use the raw body.** Meta signs the bytes it sends. Non-ASCII text can arrive as escaped + unicode (`ä`), and `JSON.stringify(JSON.parse(body))` turns that into `ä`, which + changes the bytes and breaks the signature. In Express use `express.raw({ type: '*/*' })`; + in Next.js read `request.arrayBuffer()`; in FastAPI `await request.body()`. +- **Wrong secret.** App Secret vs Instagram app secret; never the verify token or an access token. +- **Prefix required.** Reject a header without `sha256=` (and don't accept the legacy SHA-1 + `X-Hub-Signature`). +- **Duplicates.** Retries over 36 hours mean the same event can arrive more than once. Dedupe + on comment ID or message `mid`. + +## Debugging Verification Failures + +| Symptom | Likely cause | +|---------|--------------| +| Handshake never succeeds | Returning JSON instead of the raw `hub.challenge`, or a verify token mismatch | +| Signature always invalid | Body parsed before verifying, or the other secret (App Secret vs Instagram app secret) signs | +| Only Dashboard tests arrive | App not Live, field lacks Advanced Access, or the account isn't subscribed via `/subscribed_apps` | diff --git a/skills/messenger-webhooks/SKILL.md b/skills/messenger-webhooks/SKILL.md new file mode 100644 index 000000000..52f215842 --- /dev/null +++ b/skills/messenger-webhooks/SKILL.md @@ -0,0 +1,196 @@ +--- +name: messenger-webhooks +description: > + Receive and verify Facebook Messenger Platform webhooks. Use when setting up + Messenger webhook handlers for a Facebook Page, completing the GET + hub.challenge handshake, debugging X-Hub-Signature-256 signature verification + (escaped-unicode raw body), or handling messages, messaging_postbacks, + message_reads, message_deliveries, message_reactions, messaging_optins, + messaging_referrals, and message_echoes events. +license: MIT +metadata: + author: hookdeck + version: "0.1.0" + repository: https://github.com/hookdeck/webhook-skills +--- + +# Facebook Messenger Webhooks + +Messenger Platform webhooks use the **Meta** webhook mechanism: a GET handshake when you save +the Callback URL, then POST deliveries signed with `X-Hub-Signature-256`. They do **not** +follow the Standard Webhooks spec. The body is `{ "object": "page", "entry": [...] }`, and +each entry carries a `messaging[]` array (or `standby[]` under the handover protocol). + +> Instagram DMs built on the Messenger Platform arrive with `object: "instagram"`; see +> [instagram-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/instagram-webhooks). +> Page feed and other Page `changes[]` webhooks are covered by +> [facebook-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/facebook-webhooks). + +## When to Use This Skill + +- How do I receive Facebook Messenger webhooks? +- How do I verify Messenger webhook signatures with X-Hub-Signature-256? +- How do I complete the Messenger hub.challenge verification handshake? +- Why does Messenger signature verification fail for messages with emoji or accents? +- How do I handle `messages`, `messaging_postbacks`, `message_reads` or `message_deliveries` events? + +## Two Requests, Two Jobs + +1. **`GET`: handshake.** Meta sends `hub.mode=subscribe`, `hub.verify_token` and + `hub.challenge`. If the token matches the Verify Token you set in the App Dashboard, reply + `200` with the raw `hub.challenge`. +2. **`POST`: event delivery.** A JSON body signed with `X-Hub-Signature-256`. Reply `200` + within 5 seconds. + +## Verification (core) + +Meta sends `X-Hub-Signature-256: sha256=`, the HMAC-SHA256 hex digest of the request +body keyed with your **App Secret** (App settings > Basic). Meta signs an **escaped-unicode** +form of the payload (`äöå` as `äöå`), so verify over the **raw bytes** +exactly as received; re-serializing parsed JSON produces different bytes. Require the +`sha256=` prefix, check the digest is 64 hex characters, compare in constant time. + +Node: + +```javascript +const crypto = require('crypto'); + +function verify(rawBody, signatureHeader, appSecret) { + const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader || ''); + if (!match || !appSecret) return false; + const received = Buffer.from(match[1], 'hex'); // safe: regex guarantees 64 hex chars + const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest(); + return received.length === expected.length && crypto.timingSafeEqual(received, expected); +} +``` + +Python: + +```python +import hmac, hashlib, re + +def verify(raw_body: bytes, signature_header: str, app_secret: str) -> bool: + match = re.fullmatch(r"sha256=([0-9a-fA-F]{64})", signature_header or "") + if not match or not app_secret: + return False + expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(match.group(1).lower().encode(), expected.encode()) +``` + +In Express, read the body with `express.raw({ type: '*/*' })` so every content type reaches +the verifier as unparsed bytes. + +> **For complete handlers with the GET handshake, event dispatch, and tests**, see: +> - [examples/express/](examples/express/) +> - [examples/nextjs/](examples/nextjs/) +> - [examples/fastapi/](examples/fastapi/) + +## Common Event Types + +The payload doesn't name the field you subscribed to: each `messaging[]` item has `sender`, +`recipient`, `timestamp` and **one** event key. + +| Subscribed field | Key on the item | Triggered when | +|------------------|-----------------|----------------| +| `messages` | `message` (`mid`, `text`, `attachments`, `quick_reply`, `reply_to`) | A person sends the Page a message | +| `message_echoes` | `message` with `is_echo: true`, `app_id`, `metadata` | The Page sends a message | +| `messaging_postbacks` | `postback` (`title`, `payload`, `mid`, optional `referral`) | A postback button, Get Started or persistent menu item is tapped | +| `message_reads` | `read` (`watermark`) | The person reads Page messages up to `watermark` | +| `message_deliveries` | `delivery` (`watermark`, optional `mids`) | Page messages are delivered | +| `message_reactions` | `reaction` (`reaction`, `emoji`, `action`, `mid`) | A person reacts or unreacts | +| `messaging_optins` | `optin` (`type: notification_messages`, `notification_messages_token`, ...) | A person opts in to Marketing Messages or taps Send to Messenger | +| `messaging_referrals` | `referral` (`ref`, `source`: `SHORTLINK`/`ADS`, `type`) | A person with an existing thread opens an m.me link or ad | +| `message_edits` | `message_edit` (`mid`, `text`, `num_edit`) | A person edits a message | +| `messaging_account_linking` | `account_linking` (`status`, `authorization_code`) | Link/Unlink Account is tapped | + +```json +{ + "object": "page", + "entry": [ + { + "id": "", + "time": 1458692752478, + "messaging": [ + { + "sender": { "id": "" }, + "recipient": { "id": "" }, + "timestamp": 1458692752478, + "message": { "mid": "mid.1457764197618:41d102a3e1ae206a38", "text": "hello, world!" } + } + ] + } + ] +} +``` + +## Important Headers + +| Header | Description | +|--------|-------------| +| `X-Hub-Signature-256` | `sha256=` HMAC-SHA256 of the raw body keyed with the App Secret | + +## Environment Variables + +```bash +MESSENGER_APP_SECRET=your_app_secret # App settings > Basic > App Secret +MESSENGER_VERIFY_TOKEN=your_verify_token # A string you choose; must match the Verify Token in the Dashboard +``` + +## Setup + +1. Publish your Meta app. Standard Access delivers webhooks only for people with a role on + the app; Advanced Access (App Review) is needed for everyone else. +2. App Dashboard > **Products > Messenger > Settings**: enter the Callback URL and Verify + Token, then subscribe to fields (`messages`, `messaging_postbacks`, ...). +3. Subscribe each Page: `POST //subscribed_apps?subscribed_fields=messages,...` with + a Page access token (`pages_messaging`, `pages_manage_metadata`). +4. **Hookdeck setup:** Paste your Meta app's App Secret (App settings > Basic). Hookdeck + answers the `hub.challenge` handshake itself. + +See [references/setup.md](references/setup.md). + +## Local Development + +```bash +# Start tunnel (no account needed) +npx hookdeck-cli listen 3000 messenger --path /webhooks/messenger +``` + +Use the tunnel URL as the Callback URL in the App Dashboard. + +## Reference Materials + +- [references/overview.md](references/overview.md) - Fields, payloads, retries and disabling +- [references/setup.md](references/setup.md) - App Dashboard configuration, App Secret, subscribing Pages +- [references/verification.md](references/verification.md) - Handshake, escaped-unicode signing, gotchas + +## Attribution + +When using this skill, add this comment at the top of generated files: + +```javascript +// Generated with: messenger-webhooks skill +// https://github.com/hookdeck/webhook-skills +``` + +## Recommended: webhook-handler-patterns + +We recommend installing the [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) skill alongside this one for handler sequence, idempotency, error handling, and retry logic. Key references (open on GitHub): + +- [Handler sequence](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/handler-sequence.md) — Verify first, parse second, handle idempotently third +- [Idempotency](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/idempotency.md) — Prevent duplicate processing (Meta retries) +- [Error handling](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/error-handling.md) — Return codes, logging, dead letter queues +- [Retry logic](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/retry-logic.md) — Provider retry schedules, backoff patterns + +## Related Skills + +- [instagram-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/instagram-webhooks) - Instagram webhooks, including Instagram messaging (`object: instagram`) +- [facebook-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/facebook-webhooks) - Meta Graph API webhooks for Pages (same handshake and signature) +- [whatsapp-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/whatsapp-webhooks) - WhatsApp Business Platform webhooks (same Meta mechanism) +- [threads-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/threads-webhooks) - Threads webhooks (same Meta mechanism) +- [github-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/github-webhooks) - GitHub webhooks (same X-Hub-Signature-256 scheme) +- [telegram-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/telegram-webhooks) - Telegram bot webhooks +- [slack-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/slack-webhooks) - Slack event webhooks +- [intercom-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/intercom-webhooks) - Intercom conversation webhooks +- [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) - Handler sequence, idempotency, error handling, retry logic +- [hookdeck-event-gateway](https://github.com/hookdeck/webhook-skills/tree/main/skills/hookdeck-event-gateway) - Webhook infrastructure that replaces your queue — guaranteed delivery, automatic retries, replay, rate limiting, and observability for your webhook handlers diff --git a/skills/messenger-webhooks/examples/express/.env.example b/skills/messenger-webhooks/examples/express/.env.example new file mode 100644 index 000000000..a2e3593aa --- /dev/null +++ b/skills/messenger-webhooks/examples/express/.env.example @@ -0,0 +1,7 @@ +# App Secret Meta signs webhook POSTs with (X-Hub-Signature-256). +# App Dashboard > App settings > Basic > App Secret. +MESSENGER_APP_SECRET=your_app_secret_here + +# Verify token: a string you choose. Must match the Verify Token entered next to the +# Callback URL in Messenger > Settings. Used for the GET handshake. +MESSENGER_VERIFY_TOKEN=your_verify_token_here diff --git a/skills/messenger-webhooks/examples/express/README.md b/skills/messenger-webhooks/examples/express/README.md new file mode 100644 index 000000000..63c1b1c82 --- /dev/null +++ b/skills/messenger-webhooks/examples/express/README.md @@ -0,0 +1,62 @@ +# Facebook Messenger Webhooks - Express Example + +Minimal example of receiving Facebook Messenger Platform webhooks with the GET +verification handshake and `X-Hub-Signature-256` signature verification over the +raw (escaped-unicode) body. + +## Prerequisites + +- Node.js 18+ +- A published [Meta for Developers](https://developers.facebook.com/) app with Messenger, and a Facebook Page + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add `MESSENGER_APP_SECRET` (App settings > Basic > App Secret) and + `MESSENGER_VERIFY_TOKEN` (any string; enter the same value in Messenger > Settings) to `.env`. + +## Run + +```bash +npm start +``` + +Server runs on http://localhost:3000 + +## Test + +### Using Hookdeck CLI + +```bash +# Forward webhooks to localhost (no account required) +npx hookdeck-cli listen 3000 messenger --path /webhooks/messenger +``` + +Use the tunnel URL as the **Callback URL** in Messenger > Settings, subscribe the +fields you need, then subscribe your Page via `POST //subscribed_apps`. + +Check the handshake locally: + +```bash +curl "localhost:3000/webhooks/messenger?hub.verify_token=YOUR-VERIFY-TOKEN&hub.challenge=CHALLENGE_ACCEPTED&hub.mode=subscribe" +``` + +### Run the tests + +```bash +npm test +``` + +## Endpoints + +- `GET /webhooks/messenger` - Verification handshake (echoes `hub.challenge`) +- `POST /webhooks/messenger` - Verifies the signature, then dispatches `entry[].messaging[]` and `standby[]` events: `messages`, `message_echoes`, `messaging_postbacks`, `message_reads`, `message_deliveries`, `message_reactions`, `messaging_optins`, `messaging_referrals`, `message_edits`, `messaging_account_linking` diff --git a/skills/messenger-webhooks/examples/express/package.json b/skills/messenger-webhooks/examples/express/package.json new file mode 100644 index 000000000..c3cf6b812 --- /dev/null +++ b/skills/messenger-webhooks/examples/express/package.json @@ -0,0 +1,18 @@ +{ + "name": "messenger-webhooks-express", + "version": "1.0.0", + "description": "Facebook Messenger webhook handler with Express", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "jest" + }, + "dependencies": { + "dotenv": "^18.0.9", + "express": "^5.3.0" + }, + "devDependencies": { + "jest": "^30.5.2", + "supertest": "^7.3.1" + } +} diff --git a/skills/messenger-webhooks/examples/express/src/index.js b/skills/messenger-webhooks/examples/express/src/index.js new file mode 100644 index 000000000..3a0a2b611 --- /dev/null +++ b/skills/messenger-webhooks/examples/express/src/index.js @@ -0,0 +1,182 @@ +// Generated with: messenger-webhooks skill +// https://github.com/hookdeck/webhook-skills +require('dotenv').config({ quiet: true }); +const express = require('express'); +const crypto = require('crypto'); + +const app = express(); + +const SIGNATURE_PATTERN = /^sha256=([0-9a-f]{64})$/i; + +/** + * Verify a Facebook Messenger (Meta) webhook signature. + * + * Meta sends X-Hub-Signature-256: sha256=, the HMAC-SHA256 hex digest of + * the request body keyed with the App Secret. Meta signs the escaped-unicode + * payload (e.g. "ä" rather than "ä"), so the HMAC must run over the RAW + * bytes as received, never over re-serialized JSON. The pattern check + * guarantees 64 hex characters before Buffer.from(x, 'hex'), then the two + * 32-byte digests are compared in constant time. + */ +function verifyMessengerSignature(rawBody, signatureHeader, appSecret) { + if (typeof signatureHeader !== 'string' || !appSecret) { + return false; + } + + const match = SIGNATURE_PATTERN.exec(signatureHeader); + if (!match) { + return false; + } + + const received = Buffer.from(match[1], 'hex'); + const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest(); + + return received.length === expected.length && crypto.timingSafeEqual(received, expected); +} + +/** + * Name the Messenger field an item belongs to. The payload carries one event + * key per messaging[] item rather than the subscribed field's name. + */ +function eventType(event) { + if (event.message) return event.message.is_echo ? 'message_echoes' : 'messages'; + if (event.postback) return 'messaging_postbacks'; + if (event.read) return 'message_reads'; + if (event.delivery) return 'message_deliveries'; + if (event.reaction) return 'message_reactions'; + if (event.optin) return 'messaging_optins'; + if (event.referral) return 'messaging_referrals'; + if (event.message_edit) return 'message_edits'; + if (event.account_linking) return 'messaging_account_linking'; + return 'unknown'; +} + +function handleMessagingEvent(event, channel) { + const psid = event.sender?.id ?? event.sender?.user_ref; + const type = eventType(event); + + switch (type) { + case 'messages': { + const msg = event.message; + if (msg.admin_text && !msg.mid) { + console.log(`Handoff notice (${msg.handoff_reason || 'no reason'}) for ${psid}`); + } else if (msg.quick_reply) { + console.log(`Quick reply ${msg.quick_reply.payload} from ${psid}`); + } else { + const kind = msg.text ? 'text' : (msg.attachments || []).map((a) => a.type).join(','); + console.log(`Message ${msg.mid} from ${psid} (${kind})`); + } + // TODO: Reply via the Send API. Respond 200 first: Meta expects it within 5s. + break; + } + case 'message_echoes': + console.log(`Echo of ${event.message.mid} sent by app ${event.message.app_id} to ${event.recipient?.id}`); + break; + case 'messaging_postbacks': + // In the standby channel postbacks arrive without a payload. + console.log(`Postback "${event.postback.title}" (${event.postback.payload}) from ${psid}`); + break; + case 'message_reads': + console.log(`${psid} read messages up to ${event.read.watermark}`); + break; + case 'message_deliveries': + console.log(`Delivered up to ${event.delivery.watermark} (${(event.delivery.mids || []).length} mids)`); + break; + case 'message_reactions': + console.log(`Reaction ${event.reaction.action} ${event.reaction.reaction || ''} on ${event.reaction.mid}`); + break; + case 'messaging_optins': + console.log(`Opt-in ${event.optin.type || 'plugin'} from ${psid}`); + // TODO: Store optin.notification_messages_token for Marketing Messages. + break; + case 'messaging_referrals': + console.log(`Referral ref=${event.referral.ref} source=${event.referral.source} from ${psid}`); + break; + case 'message_edits': + console.log(`Message ${event.message_edit.mid} edited (${event.message_edit.num_edit} edits)`); + break; + case 'messaging_account_linking': + console.log(`Account ${event.account_linking.status} for ${psid}`); + break; + default: + console.log(`Unhandled Messenger ${channel} event: ${Object.keys(event).join(',')}`); + } +} + +/** Dispatch a notification: { object: 'page', entry: [{ messaging: [...] }] }. */ +function handleNotification(notification) { + if (notification.object !== 'page') { + // Instagram messaging arrives as object "instagram": see instagram-webhooks. + console.log(`Ignoring object: ${notification.object}`); + return; + } + + for (const entry of notification.entry || []) { + for (const event of entry.messaging || []) handleMessagingEvent(event, 'messaging'); + // Handover protocol: events for threads your app doesn't currently own. + for (const event of entry.standby || []) handleMessagingEvent(event, 'standby'); + } +} + +// Constant-time compare for the GET handshake's hub.verify_token. +function verifyTokenMatches(token, expected) { + if (typeof token !== 'string' || !token || !expected) return false; + const a = Buffer.from(token, 'utf8'); + const b = Buffer.from(expected, 'utf8'); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +// GET verification handshake: Meta sends this when the Callback URL is configured. +app.get('/webhooks/messenger', (req, res) => { + const mode = req.query['hub.mode']; + const token = req.query['hub.verify_token']; + const challenge = req.query['hub.challenge']; + + if (mode === 'subscribe' && verifyTokenMatches(token, process.env.MESSENGER_VERIFY_TOKEN)) { + // Echo the challenge back verbatim as plain text. + return res.status(200).type('text/plain').send(String(challenge ?? '')); + } + + return res.status(403).send('Forbidden'); +}); + +// POST event delivery: the raw body is needed for signature verification. +// '*/*' makes sure every content type reaches the verifier as a Buffer. +app.post('/webhooks/messenger', express.raw({ type: '*/*' }), (req, res) => { + const signature = req.headers['x-hub-signature-256']; + const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); + + if (!verifyMessengerSignature(rawBody, signature, process.env.MESSENGER_APP_SECRET)) { + console.error('Webhook signature verification failed'); + return res.status(401).send('Invalid signature'); + } + + // Parse only after verification. + let notification; + try { + notification = JSON.parse(rawBody.toString('utf8')); + } catch { + return res.status(400).send('Invalid JSON'); + } + if (!notification || typeof notification !== 'object' || Array.isArray(notification)) { + return res.status(400).send('Invalid payload'); + } + + handleNotification(notification); + + return res.status(200).send('EVENT_RECEIVED'); +}); + +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +module.exports = { app, verifyMessengerSignature, eventType }; + +if (require.main === module) { + const PORT = process.env.PORT || 3000; + app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); + console.log(`Webhook endpoint: http://localhost:${PORT}/webhooks/messenger`); + }); +} diff --git a/skills/messenger-webhooks/examples/express/test/webhook.test.js b/skills/messenger-webhooks/examples/express/test/webhook.test.js new file mode 100644 index 000000000..194298948 --- /dev/null +++ b/skills/messenger-webhooks/examples/express/test/webhook.test.js @@ -0,0 +1,218 @@ +const request = require('supertest'); +const crypto = require('crypto'); + +const APP_SECRET = 'test_app_secret'; +const VERIFY_TOKEN = 'test_verify_token'; + +process.env.MESSENGER_APP_SECRET = APP_SECRET; +process.env.MESSENGER_VERIFY_TOKEN = VERIFY_TOKEN; + +const { app, verifyMessengerSignature, eventType } = require('../src/index'); + +// Meta signs the raw body: sha256= +function sign(payload, secret = APP_SECRET) { + return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +const PAGE_ID = '106000000000001'; + +function body(item, key = 'messaging') { + return JSON.stringify({ + object: 'page', + entry: [ + { + id: PAGE_ID, + time: 1458692752478, + [key]: [{ sender: { id: 'PSID_1' }, recipient: { id: PAGE_ID }, timestamp: 1458692752478, ...item }], + }, + ], + }); +} + +const textBody = body({ message: { mid: 'mid.1457764197618:41d102a3e1ae206a38', text: 'hello, world!' } }); + +// Meta signs the escaped-unicode payload: "äöå" as "äöå". +const escapedBody = body({ message: { mid: 'm_1', text: 'PLACEHOLDER' } }).replace( + 'PLACEHOLDER', + '\\u00e4\\u00f6\\u00e5' +); + +function post(payload, signature, contentType = 'application/json') { + const req = request(app).post('/webhooks/messenger').set('Content-Type', contentType); + if (signature !== undefined) req.set('X-Hub-Signature-256', signature); + return req.send(payload); +} + +describe('GET /webhooks/messenger (handshake)', () => { + it('echoes hub.challenge when mode and token match', async () => { + const res = await request(app).get('/webhooks/messenger').query({ + 'hub.mode': 'subscribe', + 'hub.verify_token': VERIFY_TOKEN, + 'hub.challenge': 'CHALLENGE_ACCEPTED', + }); + expect(res.status).toBe(200); + expect(res.text).toBe('CHALLENGE_ACCEPTED'); + }); + + it('returns 403 on a wrong verify token', async () => { + const res = await request(app).get('/webhooks/messenger').query({ + 'hub.mode': 'subscribe', + 'hub.verify_token': 'nope', + 'hub.challenge': '1158201444', + }); + expect(res.status).toBe(403); + }); + + it('returns 403 when hub.verify_token is missing', async () => { + const res = await request(app).get('/webhooks/messenger').query({ 'hub.mode': 'subscribe' }); + expect(res.status).toBe(403); + }); +}); + +describe('POST /webhooks/messenger (signature verification)', () => { + let logSpy; + let errSpy; + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); + }); + + it('accepts a valid signature', async () => { + expect((await post(textBody, sign(textBody))).status).toBe(200); + }); + + it('accepts an uppercase hex digest', async () => { + expect((await post(textBody, 'sha256=' + sign(textBody).slice(7).toUpperCase())).status).toBe(200); + }); + + it('verifies any content type', async () => { + expect((await post(textBody, sign(textBody), 'text/plain')).status).toBe(200); + }); + + it('verifies the escaped-unicode bytes Meta signs', async () => { + expect(escapedBody).toContain('\\u00e4\\u00f6\\u00e5'); + expect((await post(escapedBody, sign(escapedBody))).status).toBe(200); + }); + + it('rejects a signature computed over the decoded (re-serialized) payload', async () => { + const decoded = JSON.stringify(JSON.parse(escapedBody)); // "ä" becomes "ä" + expect(decoded).toContain('äöå'); + expect((await post(escapedBody, sign(decoded))).status).toBe(401); + }); + + it('rejects a missing signature header', async () => { + expect((await post(textBody, undefined)).status).toBe(401); + }); + + it('rejects a signature made with the wrong secret', async () => { + expect((await post(textBody, sign(textBody, 'other_secret'))).status).toBe(401); + }); + + it('rejects a tampered body', async () => { + expect((await post(textBody.replace('hello', 'HELLO'), sign(textBody))).status).toBe(401); + }); + + it('rejects a header without the sha256= prefix', async () => { + expect((await post(textBody, sign(textBody).slice(7))).status).toBe(401); + }); + + it('rejects the legacy sha1= prefix', async () => { + expect((await post(textBody, 'sha1=' + sign(textBody).slice(7))).status).toBe(401); + }); + + it('rejects a short, long or non-hex digest instead of truncating it', async () => { + const digest = sign(textBody).slice(7); + expect((await post(textBody, 'sha256=' + digest.slice(0, 40))).status).toBe(401); + expect((await post(textBody, 'sha256=' + digest.slice(0, 62) + 'zz')).status).toBe(401); + expect((await post(textBody, 'sha256=' + digest + 'ab')).status).toBe(401); + }); + + it('returns 400 for a validly signed non-JSON body', async () => { + expect((await post('not json', sign('not json'), 'text/plain')).status).toBe(400); + }); + + it('returns 400 for a validly signed non-object JSON body', async () => { + expect((await post('[1]', sign('[1]'))).status).toBe(400); + }); +}); + +describe('event handling', () => { + const cases = [ + ['messages', { message: { mid: 'm_1', text: 'hi' } }], + ['messages', { message: { mid: 'm_1', attachments: [{ type: 'image', payload: { url: 'https://x' } }] } }], + ['messages', { message: { mid: 'm_1', text: 'Red', quick_reply: { payload: 'RED' } } }], + ['messages', { message: { admin_text: 'Ready to buy', handoff_reason: 'purchase_intent' } }], + ['message_echoes', { message: { is_echo: true, app_id: 1517776481860111, mid: 'm_2', text: 'hi' } }], + ['messaging_postbacks', { postback: { mid: 'm_3', title: 'Get Started', payload: 'GET_STARTED' } }], + ['message_reads', { read: { watermark: 1458668856253 } }], + ['message_deliveries', { delivery: { mids: ['mid.1458668856218:ed81099e15d3f4f233'], watermark: 1458668856253 } }], + ['message_reactions', { reaction: { reaction: 'love', emoji: '❤️', action: 'react', mid: 'm_1' } }], + ['messaging_optins', { optin: { type: 'notification_messages', notification_messages_token: 'TOKEN' } }], + ['messaging_referrals', { referral: { ref: 'promo', source: 'SHORTLINK', type: 'OPEN_THREAD' } }], + ['message_edits', { message_edit: { mid: 'm_1', text: 'edited', num_edit: 1 } }], + ['messaging_account_linking', { account_linking: { status: 'linked', authorization_code: 'CODE' } }], + ]; + + it.each(cases)('handles %s', async (type, item) => { + expect(eventType({ sender: { id: 'x' }, ...item })).toBe(type); + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const payload = body(item); + expect((await post(payload, sign(payload))).status).toBe(200); + expect(spy.mock.calls.flat().join('\n')).not.toMatch(/Unhandled|Ignoring/); + spy.mockRestore(); + }); + + it('handles standby-channel events', async () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const payload = body({ message: { mid: 'm_1', text: 'hi' } }, 'standby'); + expect((await post(payload, sign(payload))).status).toBe(200); + expect(spy.mock.calls.flat().join('\n')).toMatch(/Message m_1/); + spy.mockRestore(); + }); + + it('handles several entries in one POST', async () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const payload = JSON.stringify({ + object: 'page', + entry: [ + { id: PAGE_ID, time: 1, messaging: [{ sender: { id: 'a' }, recipient: { id: PAGE_ID }, read: { watermark: 1 } }] }, + { id: PAGE_ID, time: 2, messaging: [{ sender: { id: 'b' }, recipient: { id: PAGE_ID }, read: { watermark: 2 } }] }, + ], + }); + expect((await post(payload, sign(payload))).status).toBe(200); + const out = spy.mock.calls.flat().join('\n'); + expect(out).toMatch(/a read messages up to 1/); + expect(out).toMatch(/b read messages up to 2/); + spy.mockRestore(); + }); + + it('acknowledges unknown events and other objects', async () => { + const spy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const unknown = body({ something_new: {} }); + expect((await post(unknown, sign(unknown))).status).toBe(200); + const ig = JSON.stringify({ object: 'instagram', entry: [] }); + expect((await post(ig, sign(ig))).status).toBe(200); + spy.mockRestore(); + }); +}); + +describe('verifyMessengerSignature', () => { + it('returns false when the secret is empty', () => { + expect(verifyMessengerSignature(Buffer.from('x'), sign('x'), '')).toBe(false); + }); + + it('returns false for a non-string header', () => { + expect(verifyMessengerSignature(Buffer.from('x'), undefined, APP_SECRET)).toBe(false); + }); +}); + +describe('GET /health', () => { + it('returns ok', async () => { + const res = await request(app).get('/health'); + expect(res.body).toEqual({ status: 'ok' }); + }); +}); diff --git a/skills/messenger-webhooks/examples/fastapi/.env.example b/skills/messenger-webhooks/examples/fastapi/.env.example new file mode 100644 index 000000000..a2e3593aa --- /dev/null +++ b/skills/messenger-webhooks/examples/fastapi/.env.example @@ -0,0 +1,7 @@ +# App Secret Meta signs webhook POSTs with (X-Hub-Signature-256). +# App Dashboard > App settings > Basic > App Secret. +MESSENGER_APP_SECRET=your_app_secret_here + +# Verify token: a string you choose. Must match the Verify Token entered next to the +# Callback URL in Messenger > Settings. Used for the GET handshake. +MESSENGER_VERIFY_TOKEN=your_verify_token_here diff --git a/skills/messenger-webhooks/examples/fastapi/README.md b/skills/messenger-webhooks/examples/fastapi/README.md new file mode 100644 index 000000000..335fc7486 --- /dev/null +++ b/skills/messenger-webhooks/examples/fastapi/README.md @@ -0,0 +1,63 @@ +# Facebook Messenger Webhooks - FastAPI Example + +Minimal example of receiving Facebook Messenger Platform webhooks with the GET +verification handshake and `X-Hub-Signature-256` signature verification over the +raw (escaped-unicode) body. + +## Prerequisites + +- Python 3.9+ +- A published [Meta for Developers](https://developers.facebook.com/) app with Messenger, and a Facebook Page + +## Setup + +1. Install dependencies: + ```bash + python3 -m venv venv && source venv/bin/activate + pip install -r requirements.txt + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add `MESSENGER_APP_SECRET` (App settings > Basic > App Secret) and + `MESSENGER_VERIFY_TOKEN` (any string; enter the same value in Messenger > Settings) to `.env`. + +## Run + +```bash +uvicorn main:app --reload --port 8000 +``` + +Server runs on http://localhost:8000 + +## Test + +### Using Hookdeck CLI + +```bash +# Forward webhooks to localhost (no account required) +npx hookdeck-cli listen 8000 messenger --path /webhooks/messenger +``` + +Use the tunnel URL as the **Callback URL** in Messenger > Settings, subscribe the +fields you need, then subscribe your Page via `POST //subscribed_apps`. + +Check the handshake locally: + +```bash +curl "localhost:8000/webhooks/messenger?hub.verify_token=YOUR-VERIFY-TOKEN&hub.challenge=CHALLENGE_ACCEPTED&hub.mode=subscribe" +``` + +### Run the tests + +```bash +pytest test_webhook.py -v +``` + +## Endpoints + +- `GET /webhooks/messenger` - Verification handshake (echoes `hub.challenge`) +- `POST /webhooks/messenger` - Verifies the signature, then dispatches `entry[].messaging[]` and `standby[]` events: `messages`, `message_echoes`, `messaging_postbacks`, `message_reads`, `message_deliveries`, `message_reactions`, `messaging_optins`, `messaging_referrals`, `message_edits`, `messaging_account_linking` diff --git a/skills/messenger-webhooks/examples/fastapi/main.py b/skills/messenger-webhooks/examples/fastapi/main.py new file mode 100644 index 000000000..2d425684f --- /dev/null +++ b/skills/messenger-webhooks/examples/fastapi/main.py @@ -0,0 +1,183 @@ +# Generated with: messenger-webhooks skill +# https://github.com/hookdeck/webhook-skills +import os +import re +import hmac +import hashlib +import json +from typing import Optional + +from dotenv import load_dotenv +from fastapi import FastAPI, Request, HTTPException +from fastapi.responses import PlainTextResponse + +load_dotenv() + +app = FastAPI() + +SIGNATURE_PATTERN = re.compile(r"sha256=([0-9a-fA-F]{64})") + + +def get_app_secret() -> str: + return os.environ.get("MESSENGER_APP_SECRET", "") + + +def get_verify_token() -> str: + return os.environ.get("MESSENGER_VERIFY_TOKEN", "") + + +def verify_messenger_signature(raw_body: bytes, signature_header: Optional[str], app_secret: str) -> bool: + """Verify a Facebook Messenger (Meta) webhook signature. + + Meta sends ``X-Hub-Signature-256: sha256=``, the HMAC-SHA256 hex digest + of the request body keyed with the App Secret. Meta signs the escaped-unicode + payload, so the HMAC runs over the RAW bytes as received (never a + re-serialized copy). The pattern requires exactly 64 hex characters (a + non-ASCII header simply fails to match, giving 401 instead of a 500), and the + comparison is done on bytes. + """ + if not signature_header or not app_secret: + return False + + match = SIGNATURE_PATTERN.fullmatch(signature_header) + if not match: + return False + + expected = hmac.new(app_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + + return hmac.compare_digest( + match.group(1).lower().encode("ascii"), + expected.encode("ascii"), + ) + + +def event_type(event: dict) -> str: + """Name the Messenger field an item belongs to (one event key per item).""" + if "message" in event: + return "message_echoes" if (event.get("message") or {}).get("is_echo") else "messages" + for key, field in ( + ("postback", "messaging_postbacks"), + ("read", "message_reads"), + ("delivery", "message_deliveries"), + ("reaction", "message_reactions"), + ("optin", "messaging_optins"), + ("referral", "messaging_referrals"), + ("message_edit", "message_edits"), + ("account_linking", "messaging_account_linking"), + ): + if key in event: + return field + return "unknown" + + +def handle_messaging_event(event: dict, channel: str) -> None: + sender = event.get("sender") or {} + psid = sender.get("id") or sender.get("user_ref") + kind = event_type(event) + + if kind == "messages": + msg = event.get("message") or {} + if msg.get("admin_text") and not msg.get("mid"): + print(f"Handoff notice ({msg.get('handoff_reason') or 'no reason'}) for {psid}") + elif msg.get("quick_reply"): + print(f"Quick reply {msg['quick_reply'].get('payload')} from {psid}") + else: + content = "text" if msg.get("text") else ",".join(a.get("type", "") for a in msg.get("attachments") or []) + print(f"Message {msg.get('mid')} from {psid} ({content})") + # TODO: Reply via the Send API. Respond 200 first: Meta expects it within 5s. + elif kind == "message_echoes": + msg = event["message"] + print(f"Echo of {msg.get('mid')} sent by app {msg.get('app_id')} to {(event.get('recipient') or {}).get('id')}") + elif kind == "messaging_postbacks": + p = event["postback"] or {} + print(f"Postback \"{p.get('title')}\" ({p.get('payload')}) from {psid}") + elif kind == "message_reads": + print(f"{psid} read messages up to {(event['read'] or {}).get('watermark')}") + elif kind == "message_deliveries": + d = event["delivery"] or {} + print(f"Delivered up to {d.get('watermark')} ({len(d.get('mids') or [])} mids)") + elif kind == "message_reactions": + r = event["reaction"] or {} + print(f"Reaction {r.get('action')} {r.get('reaction') or ''} on {r.get('mid')}") + elif kind == "messaging_optins": + print(f"Opt-in {(event['optin'] or {}).get('type') or 'plugin'} from {psid}") + # TODO: Store optin.notification_messages_token for Marketing Messages. + elif kind == "messaging_referrals": + r = event["referral"] or {} + print(f"Referral ref={r.get('ref')} source={r.get('source')} from {psid}") + elif kind == "message_edits": + e = event["message_edit"] or {} + print(f"Message {e.get('mid')} edited ({e.get('num_edit')} edits)") + elif kind == "messaging_account_linking": + print(f"Account {(event['account_linking'] or {}).get('status')} for {psid}") + else: + print(f"Unhandled Messenger {channel} event: {','.join(event.keys())}") + + +def handle_notification(notification: dict) -> None: + """Dispatch a notification: { object: 'page', entry: [{ messaging: [...] }] }.""" + if notification.get("object") != "page": + # Instagram messaging arrives as object "instagram": see instagram-webhooks. + print(f"Ignoring object: {notification.get('object')}") + return + + for entry in notification.get("entry") or []: + for event in entry.get("messaging") or []: + handle_messaging_event(event, "messaging") + # Handover protocol: events for threads your app doesn't currently own. + for event in entry.get("standby") or []: + handle_messaging_event(event, "standby") + + +@app.get("/webhooks/messenger") +async def verify_webhook(request: Request): + """GET verification handshake: Meta sends this when the Callback URL is configured.""" + params = request.query_params + mode = params.get("hub.mode") + token = params.get("hub.verify_token") + challenge = params.get("hub.challenge") + + verify_token = get_verify_token() + if ( + mode == "subscribe" + and token + and verify_token + and hmac.compare_digest(token.encode("utf-8"), verify_token.encode("utf-8")) + ): + # Echo the challenge back verbatim as plain text. + return PlainTextResponse(content=challenge or "", status_code=200) + + raise HTTPException(status_code=403, detail="Forbidden") + + +@app.post("/webhooks/messenger") +async def receive_webhook(request: Request): + """POST event delivery: use the raw body for signature verification.""" + raw_body = await request.body() + signature_header = request.headers.get("x-hub-signature-256") + + if not verify_messenger_signature(raw_body, signature_header, get_app_secret()): + raise HTTPException(status_code=401, detail="Invalid signature") + + # Parse only after verification. + try: + notification = json.loads(raw_body) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid JSON") + if not isinstance(notification, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + + handle_notification(notification) + + return PlainTextResponse(content="EVENT_RECEIVED", status_code=200) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/skills/messenger-webhooks/examples/fastapi/requirements.txt b/skills/messenger-webhooks/examples/fastapi/requirements.txt new file mode 100644 index 000000000..d553dbe46 --- /dev/null +++ b/skills/messenger-webhooks/examples/fastapi/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.162.0 +uvicorn>=0.54.0 +python-dotenv>=1.2.4 +pytest>=9.2.0 +httpx>=0.28.1 diff --git a/skills/messenger-webhooks/examples/fastapi/test_webhook.py b/skills/messenger-webhooks/examples/fastapi/test_webhook.py new file mode 100644 index 000000000..6a6357907 --- /dev/null +++ b/skills/messenger-webhooks/examples/fastapi/test_webhook.py @@ -0,0 +1,163 @@ +import os +import hmac +import hashlib +import json + +import pytest +from fastapi.testclient import TestClient + +APP_SECRET = "test_app_secret" +VERIFY_TOKEN = "test_verify_token" + +os.environ["MESSENGER_APP_SECRET"] = APP_SECRET +os.environ["MESSENGER_VERIFY_TOKEN"] = VERIFY_TOKEN + +from main import app, event_type # noqa: E402 + +client = TestClient(app) + +PAGE_ID = "106000000000001" + + +def sign(payload: bytes, secret: str = APP_SECRET) -> str: + """Meta signs the raw body: sha256=.""" + return "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() + + +def body(item: dict, key: str = "messaging", ensure_ascii: bool = False) -> bytes: + event = {"sender": {"id": "PSID_1"}, "recipient": {"id": PAGE_ID}, "timestamp": 1458692752478, **item} + return json.dumps( + {"object": "page", "entry": [{"id": PAGE_ID, "time": 1458692752478, key: [event]}]}, + ensure_ascii=ensure_ascii, + ).encode("utf-8") + + +def post(payload: bytes, signature=None, content_type: str = "application/json"): + headers = {"Content-Type": content_type} + if signature is not None: + headers["X-Hub-Signature-256"] = signature + return client.post("/webhooks/messenger", content=payload, headers=headers) + + +TEXT = body({"message": {"mid": "mid.1457764197618:41d102a3e1ae206a38", "text": "hello, world!"}}) + +# Meta signs the escaped-unicode payload: "äöå" as "äöå". +ESCAPED = body({"message": {"mid": "m_1", "text": "äöå"}}, ensure_ascii=True) + + +class TestHandshake: + def test_echoes_challenge(self): + res = client.get( + "/webhooks/messenger", + params={"hub.mode": "subscribe", "hub.verify_token": VERIFY_TOKEN, "hub.challenge": "CHALLENGE_ACCEPTED"}, + ) + assert res.status_code == 200 + assert res.text == "CHALLENGE_ACCEPTED" + + def test_wrong_token(self): + res = client.get( + "/webhooks/messenger", + params={"hub.mode": "subscribe", "hub.verify_token": "nope", "hub.challenge": "1"}, + ) + assert res.status_code == 403 + + def test_missing_token(self): + res = client.get("/webhooks/messenger", params={"hub.mode": "subscribe", "hub.challenge": "1"}) + assert res.status_code == 403 + + +class TestSignature: + def test_valid(self): + assert post(TEXT, sign(TEXT)).status_code == 200 + + def test_uppercase_hex(self): + assert post(TEXT, "sha256=" + sign(TEXT)[7:].upper()).status_code == 200 + + def test_any_content_type(self): + assert post(TEXT, sign(TEXT), "text/plain").status_code == 200 + + def test_escaped_unicode_bytes(self): + assert b"\\u00e4\\u00f6\\u00e5" in ESCAPED + assert post(ESCAPED, sign(ESCAPED)).status_code == 200 + + def test_signature_over_decoded_unicode_fails(self): + decoded = json.dumps(json.loads(ESCAPED), ensure_ascii=False).encode("utf-8") + assert "äöå".encode("utf-8") in decoded + assert post(ESCAPED, sign(decoded)).status_code == 401 + + def test_missing_header(self): + assert post(TEXT).status_code == 401 + + def test_wrong_secret(self): + assert post(TEXT, sign(TEXT, "other_secret")).status_code == 401 + + def test_tampered_body(self): + assert post(TEXT.replace(b"hello", b"HELLO"), sign(TEXT)).status_code == 401 + + def test_missing_prefix(self): + assert post(TEXT, sign(TEXT)[7:]).status_code == 401 + + def test_legacy_sha1_prefix(self): + assert post(TEXT, "sha1=" + sign(TEXT)[7:]).status_code == 401 + + def test_bad_digest_lengths_and_chars(self): + digest = sign(TEXT)[7:] + assert post(TEXT, "sha256=" + digest[:40]).status_code == 401 + assert post(TEXT, "sha256=" + digest[:62] + "zz").status_code == 401 + assert post(TEXT, "sha256=" + digest + "ab").status_code == 401 + + def test_non_ascii_header_is_401_not_500(self): + res = client.post( + "/webhooks/messenger", + content=TEXT, + headers={"X-Hub-Signature-256": "sha256=é".encode("utf-8")}, + ) + assert res.status_code == 401 + + def test_signed_non_json(self): + assert post(b"not json", sign(b"not json"), "text/plain").status_code == 400 + + def test_signed_non_object_json(self): + assert post(b"[1]", sign(b"[1]")).status_code == 400 + + +class TestEvents: + @pytest.mark.parametrize( + "expected,item", + [ + ("messages", {"message": {"mid": "m_1", "text": "hi"}}), + ("messages", {"message": {"mid": "m_1", "attachments": [{"type": "image", "payload": {"url": "https://x"}}]}}), + ("messages", {"message": {"mid": "m_1", "text": "Red", "quick_reply": {"payload": "RED"}}}), + ("messages", {"message": {"admin_text": "Ready to buy", "handoff_reason": "purchase_intent"}}), + ("message_echoes", {"message": {"is_echo": True, "app_id": 1517776481860111, "mid": "m_2", "text": "hi"}}), + ("messaging_postbacks", {"postback": {"mid": "m_3", "title": "Get Started", "payload": "GET_STARTED"}}), + ("message_reads", {"read": {"watermark": 1458668856253}}), + ("message_deliveries", {"delivery": {"mids": ["mid.1"], "watermark": 1458668856253}}), + ("message_reactions", {"reaction": {"reaction": "love", "emoji": "❤️", "action": "react", "mid": "m_1"}}), + ("messaging_optins", {"optin": {"type": "notification_messages", "notification_messages_token": "T"}}), + ("messaging_referrals", {"referral": {"ref": "promo", "source": "SHORTLINK", "type": "OPEN_THREAD"}}), + ("message_edits", {"message_edit": {"mid": "m_1", "text": "edited", "num_edit": 1}}), + ("messaging_account_linking", {"account_linking": {"status": "linked", "authorization_code": "CODE"}}), + ], + ) + def test_known_events(self, expected, item, capsys): + assert event_type(item) == expected + payload = body(item) + assert post(payload, sign(payload)).status_code == 200 + out = capsys.readouterr().out + assert "Unhandled" not in out and "Ignoring" not in out + + def test_standby_channel(self, capsys): + payload = body({"read": {"watermark": 5}}, key="standby") + assert post(payload, sign(payload)).status_code == 200 + assert "read messages up to 5" in capsys.readouterr().out + + def test_unknown_event_and_object_acknowledged(self): + unknown = body({"something_new": {}}) + assert post(unknown, sign(unknown)).status_code == 200 + ig = json.dumps({"object": "instagram", "entry": []}).encode() + assert post(ig, sign(ig)).status_code == 200 + + +def test_health(): + assert client.get("/health").json() == {"status": "ok"} diff --git a/skills/messenger-webhooks/examples/nextjs/.env.example b/skills/messenger-webhooks/examples/nextjs/.env.example new file mode 100644 index 000000000..a2e3593aa --- /dev/null +++ b/skills/messenger-webhooks/examples/nextjs/.env.example @@ -0,0 +1,7 @@ +# App Secret Meta signs webhook POSTs with (X-Hub-Signature-256). +# App Dashboard > App settings > Basic > App Secret. +MESSENGER_APP_SECRET=your_app_secret_here + +# Verify token: a string you choose. Must match the Verify Token entered next to the +# Callback URL in Messenger > Settings. Used for the GET handshake. +MESSENGER_VERIFY_TOKEN=your_verify_token_here diff --git a/skills/messenger-webhooks/examples/nextjs/README.md b/skills/messenger-webhooks/examples/nextjs/README.md new file mode 100644 index 000000000..67d0be464 --- /dev/null +++ b/skills/messenger-webhooks/examples/nextjs/README.md @@ -0,0 +1,62 @@ +# Facebook Messenger Webhooks - Next.js Example + +Minimal example of receiving Facebook Messenger Platform webhooks with the GET +verification handshake and `X-Hub-Signature-256` signature verification over the +raw (escaped-unicode) body. + +## Prerequisites + +- Node.js 18+ +- A published [Meta for Developers](https://developers.facebook.com/) app with Messenger, and a Facebook Page + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add `MESSENGER_APP_SECRET` (App settings > Basic > App Secret) and + `MESSENGER_VERIFY_TOKEN` (any string; enter the same value in Messenger > Settings) to `.env`. + +## Run + +```bash +npm run dev +``` + +Server runs on http://localhost:3000 (`app/webhooks/messenger/route.ts`) + +## Test + +### Using Hookdeck CLI + +```bash +# Forward webhooks to localhost (no account required) +npx hookdeck-cli listen 3000 messenger --path /webhooks/messenger +``` + +Use the tunnel URL as the **Callback URL** in Messenger > Settings, subscribe the +fields you need, then subscribe your Page via `POST //subscribed_apps`. + +Check the handshake locally: + +```bash +curl "localhost:3000/webhooks/messenger?hub.verify_token=YOUR-VERIFY-TOKEN&hub.challenge=CHALLENGE_ACCEPTED&hub.mode=subscribe" +``` + +### Run the tests + +```bash +npm test +``` + +## Endpoints + +- `GET /webhooks/messenger` - Verification handshake (echoes `hub.challenge`) +- `POST /webhooks/messenger` - Verifies the signature, then dispatches `entry[].messaging[]` and `standby[]` events: `messages`, `message_echoes`, `messaging_postbacks`, `message_reads`, `message_deliveries`, `message_reactions`, `messaging_optins`, `messaging_referrals`, `message_edits`, `messaging_account_linking` diff --git a/skills/messenger-webhooks/examples/nextjs/app/webhooks/messenger/route.ts b/skills/messenger-webhooks/examples/nextjs/app/webhooks/messenger/route.ts new file mode 100644 index 000000000..b50986829 --- /dev/null +++ b/skills/messenger-webhooks/examples/nextjs/app/webhooks/messenger/route.ts @@ -0,0 +1,172 @@ +// Generated with: messenger-webhooks skill +// https://github.com/hookdeck/webhook-skills +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; + +const SIGNATURE_PATTERN = /^sha256=([0-9a-f]{64})$/i; + +/** + * Verify a Facebook Messenger (Meta) webhook signature. + * + * Meta sends X-Hub-Signature-256: sha256=, the HMAC-SHA256 hex digest of + * the request body keyed with the App Secret. Meta signs the escaped-unicode + * payload, so the HMAC must run over the RAW bytes as received. The pattern + * check guarantees 64 hex characters before Buffer.from(x, 'hex'), then the + * two 32-byte digests are compared in constant time. + */ +function verifyMessengerSignature( + rawBody: Buffer, + signatureHeader: string | null, + appSecret: string | undefined +): boolean { + if (!signatureHeader || !appSecret) { + return false; + } + + const match = SIGNATURE_PATTERN.exec(signatureHeader); + if (!match) { + return false; + } + + const received = Buffer.from(match[1], 'hex'); + const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest(); + + return received.length === expected.length && crypto.timingSafeEqual(received, expected); +} + +type Json = Record; + +interface MessengerNotification { + object?: string; + entry?: Array<{ id?: string; time?: number; messaging?: Json[]; standby?: Json[] }>; +} + +/** Name the Messenger field an item belongs to (one event key per item). */ +function eventType(event: Json): string { + if (event.message) return event.message.is_echo ? 'message_echoes' : 'messages'; + if (event.postback) return 'messaging_postbacks'; + if (event.read) return 'message_reads'; + if (event.delivery) return 'message_deliveries'; + if (event.reaction) return 'message_reactions'; + if (event.optin) return 'messaging_optins'; + if (event.referral) return 'messaging_referrals'; + if (event.message_edit) return 'message_edits'; + if (event.account_linking) return 'messaging_account_linking'; + return 'unknown'; +} + +function handleMessagingEvent(event: Json, channel: string): void { + const psid = event.sender?.id ?? event.sender?.user_ref; + + switch (eventType(event)) { + case 'messages': { + const msg = event.message; + if (msg.admin_text && !msg.mid) { + console.log(`Handoff notice (${msg.handoff_reason || 'no reason'}) for ${psid}`); + } else if (msg.quick_reply) { + console.log(`Quick reply ${msg.quick_reply.payload} from ${psid}`); + } else { + const kind = msg.text ? 'text' : (msg.attachments || []).map((a: Json) => a.type).join(','); + console.log(`Message ${msg.mid} from ${psid} (${kind})`); + } + // TODO: Reply via the Send API. Respond 200 first: Meta expects it within 5s. + break; + } + case 'message_echoes': + console.log(`Echo of ${event.message.mid} sent by app ${event.message.app_id} to ${event.recipient?.id}`); + break; + case 'messaging_postbacks': + console.log(`Postback "${event.postback.title}" (${event.postback.payload}) from ${psid}`); + break; + case 'message_reads': + console.log(`${psid} read messages up to ${event.read.watermark}`); + break; + case 'message_deliveries': + console.log(`Delivered up to ${event.delivery.watermark} (${(event.delivery.mids || []).length} mids)`); + break; + case 'message_reactions': + console.log(`Reaction ${event.reaction.action} ${event.reaction.reaction || ''} on ${event.reaction.mid}`); + break; + case 'messaging_optins': + console.log(`Opt-in ${event.optin.type || 'plugin'} from ${psid}`); + break; + case 'messaging_referrals': + console.log(`Referral ref=${event.referral.ref} source=${event.referral.source} from ${psid}`); + break; + case 'message_edits': + console.log(`Message ${event.message_edit.mid} edited (${event.message_edit.num_edit} edits)`); + break; + case 'messaging_account_linking': + console.log(`Account ${event.account_linking.status} for ${psid}`); + break; + default: + console.log(`Unhandled Messenger ${channel} event: ${Object.keys(event).join(',')}`); + } +} + +/** Dispatch a notification: { object: 'page', entry: [{ messaging: [...] }] }. */ +function handleNotification(notification: MessengerNotification): void { + if (notification.object !== 'page') { + // Instagram messaging arrives as object "instagram": see instagram-webhooks. + console.log(`Ignoring object: ${notification.object}`); + return; + } + + for (const entry of notification.entry || []) { + for (const event of entry.messaging || []) handleMessagingEvent(event, 'messaging'); + // Handover protocol: events for threads your app doesn't currently own. + for (const event of entry.standby || []) handleMessagingEvent(event, 'standby'); + } +} + +// Constant-time compare for the GET handshake's hub.verify_token. +function verifyTokenMatches(token: string | null, expected: string | undefined): boolean { + if (!token || !expected) return false; + const a = Buffer.from(token, 'utf8'); + const b = Buffer.from(expected, 'utf8'); + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +// GET verification handshake: Meta sends this when the Callback URL is configured. +export async function GET(request: NextRequest) { + const params = request.nextUrl.searchParams; + const mode = params.get('hub.mode'); + const token = params.get('hub.verify_token'); + const challenge = params.get('hub.challenge'); + + if (mode === 'subscribe' && verifyTokenMatches(token, process.env.MESSENGER_VERIFY_TOKEN)) { + // Echo the challenge back verbatim as plain text. + return new NextResponse(challenge ?? '', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + return new NextResponse('Forbidden', { status: 403 }); +} + +// POST event delivery: read the raw bytes for signature verification. +export async function POST(request: NextRequest) { + const rawBody = Buffer.from(await request.arrayBuffer()); + const signature = request.headers.get('x-hub-signature-256'); + + if (!verifyMessengerSignature(rawBody, signature, process.env.MESSENGER_APP_SECRET)) { + console.error('Webhook signature verification failed'); + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); + } + + // Parse only after verification. + let notification: unknown; + try { + notification = JSON.parse(rawBody.toString('utf8')); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + if (!notification || typeof notification !== 'object' || Array.isArray(notification)) { + return NextResponse.json({ error: 'Invalid payload' }, { status: 400 }); + } + + handleNotification(notification as MessengerNotification); + + return new NextResponse('EVENT_RECEIVED', { status: 200 }); +} diff --git a/skills/messenger-webhooks/examples/nextjs/package.json b/skills/messenger-webhooks/examples/nextjs/package.json new file mode 100644 index 000000000..9a7ac5f46 --- /dev/null +++ b/skills/messenger-webhooks/examples/nextjs/package.json @@ -0,0 +1,23 @@ +{ + "name": "messenger-webhooks-nextjs", + "version": "1.0.0", + "description": "Facebook Messenger webhook handler with Next.js", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "vitest run" + }, + "dependencies": { + "next": "^16.4.5", + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@types/node": "^26.6.6", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "typescript": "^7.0.2", + "vitest": "^5.1.0" + } +} diff --git a/skills/messenger-webhooks/examples/nextjs/test/webhook.test.ts b/skills/messenger-webhooks/examples/nextjs/test/webhook.test.ts new file mode 100644 index 000000000..73e7c83a1 --- /dev/null +++ b/skills/messenger-webhooks/examples/nextjs/test/webhook.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import crypto from 'crypto'; +import { NextRequest } from 'next/server'; + +const APP_SECRET = 'test_app_secret'; +const VERIFY_TOKEN = 'test_verify_token'; + +// Set test environment variables before the route is used +beforeAll(() => { + process.env.MESSENGER_APP_SECRET = APP_SECRET; + process.env.MESSENGER_VERIFY_TOKEN = VERIFY_TOKEN; +}); + +import { GET, POST } from '../app/webhooks/messenger/route'; + +const URL_BASE = 'http://localhost:3000/webhooks/messenger'; +const PAGE_ID = '106000000000001'; + +// Meta signs the raw body: sha256= +function sign(payload: string, secret: string = APP_SECRET): string { + return 'sha256=' + crypto.createHmac('sha256', secret).update(payload).digest('hex'); +} + +function body(item: Record, key = 'messaging'): string { + return JSON.stringify({ + object: 'page', + entry: [ + { + id: PAGE_ID, + time: 1458692752478, + [key]: [{ sender: { id: 'PSID_1' }, recipient: { id: PAGE_ID }, timestamp: 1458692752478, ...item }], + }, + ], + }); +} + +function post(payload: string, signature?: string, contentType = 'application/json'): NextRequest { + const headers: Record = { 'Content-Type': contentType }; + if (signature !== undefined) headers['x-hub-signature-256'] = signature; + return new NextRequest(URL_BASE, { method: 'POST', headers, body: payload }); +} + +const textBody = body({ message: { mid: 'mid.1457764197618:41d102a3e1ae206a38', text: 'hello, world!' } }); + +// Meta signs the escaped-unicode payload: "äöå" as "äöå". +const escapedBody = body({ message: { mid: 'm_1', text: 'PLACEHOLDER' } }).replace( + 'PLACEHOLDER', + '\\u00e4\\u00f6\\u00e5' +); + +describe('GET /webhooks/messenger (handshake)', () => { + it('echoes hub.challenge when mode and token match', async () => { + const res = await GET( + new NextRequest(`${URL_BASE}?hub.mode=subscribe&hub.verify_token=${VERIFY_TOKEN}&hub.challenge=CHALLENGE_ACCEPTED`) + ); + expect(res.status).toBe(200); + expect(await res.text()).toBe('CHALLENGE_ACCEPTED'); + }); + + it('returns 403 on a wrong verify token', async () => { + const res = await GET(new NextRequest(`${URL_BASE}?hub.mode=subscribe&hub.verify_token=nope&hub.challenge=1`)); + expect(res.status).toBe(403); + }); + + it('returns 403 when hub.verify_token is missing', async () => { + const res = await GET(new NextRequest(`${URL_BASE}?hub.mode=subscribe&hub.challenge=1`)); + expect(res.status).toBe(403); + }); +}); + +describe('POST /webhooks/messenger (signature verification)', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + it('accepts a valid signature', async () => { + expect((await POST(post(textBody, sign(textBody)))).status).toBe(200); + }); + + it('accepts an uppercase hex digest', async () => { + expect((await POST(post(textBody, 'sha256=' + sign(textBody).slice(7).toUpperCase()))).status).toBe(200); + }); + + it('verifies any content type', async () => { + expect((await POST(post(textBody, sign(textBody), 'text/plain'))).status).toBe(200); + }); + + it('verifies the escaped-unicode bytes Meta signs', async () => { + expect((await POST(post(escapedBody, sign(escapedBody)))).status).toBe(200); + }); + + it('rejects a signature computed over the decoded (re-serialized) payload', async () => { + const decoded = JSON.stringify(JSON.parse(escapedBody)); + expect(decoded).toContain('äöå'); + expect((await POST(post(escapedBody, sign(decoded)))).status).toBe(401); + }); + + it('rejects a missing signature header', async () => { + expect((await POST(post(textBody))).status).toBe(401); + }); + + it('rejects a signature made with the wrong secret', async () => { + expect((await POST(post(textBody, sign(textBody, 'other_secret')))).status).toBe(401); + }); + + it('rejects a tampered body', async () => { + expect((await POST(post(textBody.replace('hello', 'HELLO'), sign(textBody)))).status).toBe(401); + }); + + it('rejects a header without the sha256= prefix', async () => { + expect((await POST(post(textBody, sign(textBody).slice(7)))).status).toBe(401); + }); + + it('rejects the legacy sha1= prefix', async () => { + expect((await POST(post(textBody, 'sha1=' + sign(textBody).slice(7)))).status).toBe(401); + }); + + it('rejects a short, long or non-hex digest instead of truncating it', async () => { + const digest = sign(textBody).slice(7); + expect((await POST(post(textBody, 'sha256=' + digest.slice(0, 40)))).status).toBe(401); + expect((await POST(post(textBody, 'sha256=' + digest.slice(0, 62) + 'zz'))).status).toBe(401); + expect((await POST(post(textBody, 'sha256=' + digest + 'ab'))).status).toBe(401); + }); + + it('returns 400 for a validly signed non-JSON body', async () => { + expect((await POST(post('not json', sign('not json'), 'text/plain'))).status).toBe(400); + }); + + it('returns 400 for a validly signed non-object JSON body', async () => { + expect((await POST(post('[1]', sign('[1]')))).status).toBe(400); + }); +}); + +describe('event handling', () => { + const cases: Array<[string, Record]> = [ + ['messages', { message: { mid: 'm_1', text: 'hi' } }], + ['messages (quick reply)', { message: { mid: 'm_1', text: 'Red', quick_reply: { payload: 'RED' } } }], + ['message_echoes', { message: { is_echo: true, app_id: 1517776481860111, mid: 'm_2', text: 'hi' } }], + ['messaging_postbacks', { postback: { mid: 'm_3', title: 'Get Started', payload: 'GET_STARTED' } }], + ['message_reads', { read: { watermark: 1458668856253 } }], + ['message_deliveries', { delivery: { mids: ['mid.1'], watermark: 1458668856253 } }], + ['message_reactions', { reaction: { reaction: 'love', emoji: '❤️', action: 'react', mid: 'm_1' } }], + ['messaging_optins', { optin: { type: 'notification_messages', notification_messages_token: 'TOKEN' } }], + ['messaging_referrals', { referral: { ref: 'promo', source: 'ADS', type: 'OPEN_THREAD' } }], + ['message_edits', { message_edit: { mid: 'm_1', text: 'edited', num_edit: 1 } }], + ['messaging_account_linking', { account_linking: { status: 'unlinked' } }], + ]; + + it.each(cases)('handles %s', async (_name, item) => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const payload = body(item); + expect((await POST(post(payload, sign(payload)))).status).toBe(200); + expect(spy.mock.calls.flat().join('\n')).not.toMatch(/Unhandled|Ignoring/); + spy.mockRestore(); + }); + + it('handles standby-channel events', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const payload = body({ read: { watermark: 5 } }, 'standby'); + expect((await POST(post(payload, sign(payload)))).status).toBe(200); + expect(spy.mock.calls.flat().join('\n')).toMatch(/read messages up to 5/); + spy.mockRestore(); + }); + + it('acknowledges unknown events', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const payload = body({ something_new: {} }); + expect((await POST(post(payload, sign(payload)))).status).toBe(200); + spy.mockRestore(); + }); +}); diff --git a/skills/messenger-webhooks/examples/nextjs/vitest.config.ts b/skills/messenger-webhooks/examples/nextjs/vitest.config.ts new file mode 100644 index 000000000..8e730d505 --- /dev/null +++ b/skills/messenger-webhooks/examples/nextjs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/skills/messenger-webhooks/references/overview.md b/skills/messenger-webhooks/references/overview.md new file mode 100644 index 000000000..b1104bb1e --- /dev/null +++ b/skills/messenger-webhooks/references/overview.md @@ -0,0 +1,82 @@ +# Facebook Messenger Webhooks Overview + +## What Are Messenger Webhooks? + +Messenger Platform webhooks notify your server when people interact with your Facebook Page in +Messenger: incoming messages, postbacks, reads, deliveries, reactions, opt-ins, referrals and +more. They use Meta's webhook mechanism (a GET handshake with a verify token, then POSTs signed +with `X-Hub-Signature-256`). + +## Common Event Types + +| Field | Key on `messaging[]` item | Description | +|-------|---------------------------|-------------| +| `messages` | `message` | A person sent the Page a message (`text`, `attachments`, `quick_reply`, `reply_to`). Meta Business Agent handoffs can carry `admin_text` and `handoff_reason` without `mid` | +| `message_echoes` | `message` (`is_echo: true`) | The Page sent a message; includes `app_id` and optional `metadata` | +| `messaging_postbacks` | `postback` | Postback button, Get Started button or persistent menu item tapped | +| `message_reads` | `read` | All Page messages sent at or before `read.watermark` were read | +| `message_deliveries` | `delivery` | Messages before `delivery.watermark` delivered; `mids` may be missing | +| `message_reactions` | `reaction` | `reaction` is `smile`, `angry`, `sad`, `wow`, `love`, `like`, `dislike` or `other`; `action` is `react`/`unreact` | +| `messaging_optins` | `optin` | Marketing Messages opt-in (`type: notification_messages`, `notification_messages_token`, `notification_messages_status`, ...) or Send to Messenger plugin (`ref`) | +| `messaging_referrals` | `referral` | Existing thread re-entered from an m.me link (`source: SHORTLINK`) or ad (`source: ADS`, `ads_context_data`) | +| `message_edits` | `message_edit` | `mid`, new `text`, `num_edit` | +| `messaging_account_linking` | `account_linking` | `status` `linked` (with `authorization_code`) or `unlinked` | +| `messaging_handovers` | `pass_thread_control`, ... | Handover protocol changes | +| `standby` | (any of the above, in `entry.standby[]`) | Events for threads your app doesn't own; postbacks there have no `payload` | + +Other fields: `messaging_feedback`, `messaging_game_plays`, `messaging_policy_enforcement`, +`messenger_template_status_update`, `response_feedback`, `send_cart`. Several (`message_deliveries`, +`message_echoes`, `message_edits`, `messaging_optins`, ...) are Messenger-only; Instagram +conversations use `messaging_seen` instead of `message_reads` and get echoes inside `messages`. + +## Event Payload Structure + +```json +{ + "object": "page", + "entry": [ + { + "id": "", + "time": 1458692752478, + "messaging": [ + { + "sender": { "id": "" }, + "recipient": { "id": "" }, + "timestamp": 1458692752478, + "postback": { "title": "Get Started", "payload": "GET_STARTED" } + } + ] + } + ] +} +``` + +| Property | Description | +|----------|-------------| +| `object` | `page` | +| `entry[].id` | Page ID | +| `entry[].time` | Time of update (epoch **milliseconds**) | +| `entry[].messaging` | Array with the event; Meta says it holds one item, but iterate it anyway | +| `entry[].standby` | Used instead of `messaging` for handover-protocol standby events | +| `sender.id` | Page-scoped ID (PSID) of the person; the Page ID for echoes. Some plugin flows send `sender.user_ref` instead | +| `recipient.id` | Page ID; the PSID for echoes | + +Multiple entries can arrive in one POST: iterate every `entry` and every item. + +The `messaging_postbacks` reference page shows its sample as `{ "field": ..., "value": {...} }` +with `sender.user_ref` and a string `timestamp`; the webhooks guide and the other event pages +show the `entry[].messaging[]` form above, which is what the examples handle. + +## Ordering, Retries and Disabling + +- Respond `200 OK` within 5 seconds. +- Failed deliveries are retried immediately a few more times. After 15 minutes of failures an + alert is sent; after **1 hour** you get a **Webhooks Disabled** alert and the app is + unsubscribed from the Page, so you must subscribe again. +- After failures, messages may arrive out of order: sort by `timestamp`. +- Deduplicate on `message.mid` (or `watermark` for reads/deliveries). + +## Full Event Reference + +- [Meta Webhooks for Messenger Platform](https://developers.facebook.com/documentation/business-messaging/messenger-platform/webhooks) +- [Webhook Events Reference](https://developers.facebook.com/documentation/business-messaging/messenger-platform/webhooks/webhook-events) diff --git a/skills/messenger-webhooks/references/setup.md b/skills/messenger-webhooks/references/setup.md new file mode 100644 index 000000000..9b90e463e --- /dev/null +++ b/skills/messenger-webhooks/references/setup.md @@ -0,0 +1,80 @@ +# Setting Up Facebook Messenger Webhooks + +## Prerequisites + +- A [Meta for Developers](https://developers.facebook.com/) app with Messenger (the "Engage + with customers on Messenger" use case), **published** +- A Facebook Page, and a Page access token from someone who can perform the `MODERATE` task on it +- `pages_messaging` and `pages_manage_metadata` permissions +- Access level: **Standard Access** delivers notifications only from people with a role on + your app (admins, developers, testers); **Advanced Access** (App Review) is needed for + everyone else +- A publicly reachable HTTPS Callback URL with a valid (not self-signed) certificate + +## Get Your Signing Secret + +Meta signs every payload with your app's **App Secret**: App Dashboard > **App settings > +Basic** > App Secret (click **Show**). Store it as `MESSENGER_APP_SECRET`. It is not the verify +token and not a Page access token. + +## Choose a Verify Token + +Any string you invent (`openssl rand -hex 20`). Store it as `MESSENGER_VERIFY_TOKEN` and enter +the same value in the Dashboard's **Verify Token** field. + +## Register Your Endpoint + +1. App Dashboard > **Products > Messenger > Settings** (for Instagram messaging only, use + **Instagram settings** there instead). +2. Enter the **Callback URL** (`https://your-domain.com/webhooks/messenger`) and the Verify Token. +3. Meta sends a `GET` handshake; your endpoint must echo `hub.challenge`. +4. Subscribe to the fields you need, e.g. `messages`, `messaging_postbacks`, + `message_reads`, `message_deliveries`, `message_reactions`, `messaging_optins`, + `messaging_referrals`, and click **Save**. + +You can also manage the app subscription with the Graph API `/app/subscriptions` endpoint. +Use the latest API version to get all fields in each payload. + +## Subscribe Each Page + +Connect the app to the Page (Meta Business Suite > All Tools > Business Apps), then subscribe +the Page to the fields: + +```bash +curl -i -X POST "https://graph.facebook.com//subscribed_apps?subscribed_fields=messages,messaging_postbacks&access_token=" +# {"success": "true"} + +# Check which apps the Page has installed +curl -i -X GET "https://graph.facebook.com//subscribed_apps?access_token=" +``` + +Every messaging app for the business must be subscribed to the messaging webhooks. + +## Hookdeck Setup + +Paste your Meta app's App Secret (App settings > Basic). + +Hookdeck's edge answers the `hub.challenge` handshake itself, so the destination doesn't need +to handle the GET. Use the Hookdeck source URL as the Callback URL. Hookdeck acknowledges Meta +quickly, which helps you stay inside Meta's 5-second response window and avoid the 1-hour +**Webhooks Disabled** unsubscribe. + +For local development: + +```bash +npx hookdeck-cli listen 3000 messenger --path /webhooks/messenger +``` + +## Testing + +```bash +# Handshake +curl -X GET "localhost:3000/webhooks/messenger?hub.verify_token=YOUR-VERIFY-TOKEN&hub.challenge=CHALLENGE_ACCEPTED&hub.mode=subscribe" +``` + +A POST without a valid signature gets `401` from the examples, so sign test payloads with your +App Secret (see the example tests), or send a test notification from the Dashboard / message +the Page from an account with a role on the app. + +If the app was unsubscribed after an outage (Webhooks Disabled alert), fix the endpoint and +subscribe the Page again. diff --git a/skills/messenger-webhooks/references/verification.md b/skills/messenger-webhooks/references/verification.md new file mode 100644 index 000000000..f04375677 --- /dev/null +++ b/skills/messenger-webhooks/references/verification.md @@ -0,0 +1,82 @@ +# How to Verify Facebook Messenger Webhook Signatures + +Messenger uses Meta's webhook verification: a GET handshake plus an `X-Hub-Signature-256` +signature on every POST. See also the +[Meta reference](https://github.com/hookdeck/webhook-skills/blob/main/skills/facebook-webhooks/references/verification.md). + +## 1. GET Handshake + +Meta sends `hub.mode=subscribe`, `hub.verify_token` and `hub.challenge` whenever you configure +the webhook. Respond `200` with the raw `hub.challenge` when the mode is `subscribe` and the +token matches `MESSENGER_VERIFY_TOKEN`; otherwise `403`. (Hookdeck's edge answers this itself.) + +## 2. POST Signature + +- Header: `X-Hub-Signature-256: sha256=` +- Algorithm: HMAC-SHA256, hex-encoded +- Signed content: the payload bytes as sent (escaped-unicode JSON) +- Key: the App Secret (App settings > Basic) +- No timestamp is signed, so there is no tolerance window to check + +### Escaped unicode: verify the raw bytes + +Meta's docs: "we generate the signature using an *escaped unicode* version of the payload, +with lowercase hex digits. If you just calculate against the decoded bytes, you will end up +with a different signature. For example, the string `äöå` should be escaped to +`äöå`." + +The note describes the signed form, not the wire format, but the practical rule is the same: +HMAC the body bytes exactly as they arrive and never a decoded or re-serialized copy. +`JSON.stringify(JSON.parse(body))` (or Python's `json.dumps(..., ensure_ascii=False)`) turns +`ä` into `ä`, the bytes change, and the signature no longer matches. Meta's own sample +computes the HMAC over the raw `buf` passed to its body-parser verify function. + +### Node.js + +Validate the format with a regex before any `Buffer.from(x, 'hex')` (invalid hex silently +truncates), then compare equal-length buffers with `crypto.timingSafeEqual`. (Meta's sample +uses `!=`, which isn't constant-time.) + +```javascript +const crypto = require('crypto'); + +function verifyMessengerSignature(rawBody, signatureHeader, appSecret) { + const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader || ''); + if (!match || !appSecret) return false; + const received = Buffer.from(match[1], 'hex'); + const expected = crypto.createHmac('sha256', appSecret).update(rawBody).digest(); + return received.length === expected.length && crypto.timingSafeEqual(received, expected); +} +``` + +### Python + +```python +import hmac, hashlib, re + +def verify_messenger_signature(raw_body: bytes, signature_header: str, app_secret: str) -> bool: + match = re.fullmatch(r"sha256=([0-9a-fA-F]{64})", signature_header or "") + if not match or not app_secret: + return False + expected = hmac.new(app_secret.encode(), raw_body, hashlib.sha256).hexdigest() + # bytes on both sides: a non-ASCII header gives 401, not a TypeError/500 + return hmac.compare_digest(match.group(1).lower().encode(), expected.encode()) +``` + +## Common Gotchas + +- **Raw body only.** In Express use `express.raw({ type: '*/*' })` (not `express.json()`); in + Next.js `request.arrayBuffer()`; in FastAPI `await request.body()`. +- **Missing header.** Meta's sample only warns when the header is missing. Reject it instead. +- **Prefix required.** Reject a header without `sha256=`; ignore the legacy SHA-1 `X-Hub-Signature`. +- **Wrong secret.** Use the App Secret, not the verify token or a Page access token. +- **Slow handlers.** Respond within 5 seconds; an hour of failures unsubscribes the app. + +## Debugging Verification Failures + +| Symptom | Likely cause | +|---------|--------------| +| Handshake never succeeds | Returning JSON instead of the raw `hub.challenge`, or verify token mismatch | +| Fails only for messages with emoji/accents | HMAC computed over decoded or re-serialized JSON instead of the raw bytes | +| Signature always invalid | Body parsed by a JSON middleware first, or the wrong app's secret | +| Only your own test messages arrive | App has Standard Access; customers need Advanced Access | From ea9bba989b4240973571331704348e23a70f200b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:51:37 +0000 Subject: [PATCH 2/2] fix: address review on instagram/messenger webhook skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the literal äöå escapes in the escaped-unicode prose and test comments, drop the README/marketplace hunks (Sync Catalog regenerates them), cite Meta's create-app guide for Instagram's default field subscriptions, use British spelling in prose, and note that Hookdeck answers hub.challenge without checking the Verify Token. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Mp6z12EgZRFuEhuPqFwxMT --- .claude-plugin/marketplace.json | 48 +------------------ README.md | 2 - providers/messenger.yaml | 4 +- skills/instagram-webhooks/SKILL.md | 5 +- .../examples/express/test/webhook.test.js | 2 +- .../examples/fastapi/test_webhook.py | 2 +- skills/instagram-webhooks/references/setup.md | 13 +++-- .../references/verification.md | 2 +- skills/messenger-webhooks/SKILL.md | 9 ++-- .../examples/express/src/index.js | 2 +- .../examples/express/test/webhook.test.js | 4 +- .../examples/fastapi/test_webhook.py | 2 +- .../examples/nextjs/test/webhook.test.ts | 2 +- skills/messenger-webhooks/references/setup.md | 5 +- .../references/verification.md | 8 ++-- 15 files changed, 35 insertions(+), 75 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ea69baf70..046f91b8b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,7 +5,7 @@ "email": "phil@hookdeck.com" }, "metadata": { - "description": "Webhook integration skills for AI coding agents: provider skills (Stripe, Shopify, GitHub, and 804 others) plus framework-agnostic handler patterns and Hookdeck infrastructure plugins for receiving, verifying, and delivering webhooks across Express, Next.js, and FastAPI.", + "description": "Webhook integration skills for AI coding agents: provider skills (Stripe, Shopify, GitHub, and 802 others) plus framework-agnostic handler patterns and Hookdeck infrastructure plugins for receiving, verifying, and delivering webhooks across Express, Next.js, and FastAPI.", "version": "0.1.0" }, "plugins": [ @@ -6869,27 +6869,6 @@ "build" ] }, - { - "name": "instagram-webhooks", - "description": "Receive and verify Instagram (Meta) webhooks. Use when setting up Instagram webhook handlers, completing the GET hub.challenge handshake, debugging X-Hub-Signature-256 signature verification, or handling Instagram comments, live_comments, mentions, story_insights, messages, message_reactions, messaging_postbacks, messaging_referral, and messaging_seen notifications.", - "source": "./skills/instagram-webhooks", - "strict": false, - "skills": [ - "./" - ], - "category": "integration", - "license": "MIT", - "author": { - "name": "Hookdeck", - "email": "phil@hookdeck.com" - }, - "repository": "https://github.com/hookdeck/webhook-skills", - "homepage": "https://github.com/hookdeck/webhook-skills/tree/main/skills/instagram-webhooks", - "keywords": [ - "webhooks", - "instagram" - ] - }, { "name": "instantly-webhooks", "description": "Receive and authenticate Instantly (Instantly.ai cold email) webhooks. Use when setting up an Instantly webhook handler, securing it with the custom headers Instantly lets you attach (e.g. Authorization: Basic ... or a secret header), debugging why the header check fails, or handling campaign events like email_sent, email_opened, reply_received, email_bounced, lead_interested, lead_unsubscribed and lead_meeting_booked. Instantly does not sign webhooks: there is no signature header or HMAC.", @@ -8574,27 +8553,6 @@ "mercury" ] }, - { - "name": "messenger-webhooks", - "description": "Receive and verify Facebook Messenger Platform webhooks. Use when setting up Messenger webhook handlers for a Facebook Page, completing the GET hub.challenge handshake, debugging X-Hub-Signature-256 signature verification (escaped-unicode raw body), or handling messages, messaging_postbacks, message_reads, message_deliveries, message_reactions, messaging_optins, messaging_referrals, and message_echoes events.", - "source": "./skills/messenger-webhooks", - "strict": false, - "skills": [ - "./" - ], - "category": "integration", - "license": "MIT", - "author": { - "name": "Hookdeck", - "email": "phil@hookdeck.com" - }, - "repository": "https://github.com/hookdeck/webhook-skills", - "homepage": "https://github.com/hookdeck/webhook-skills/tree/main/skills/messenger-webhooks", - "keywords": [ - "webhooks", - "messenger" - ] - }, { "name": "metabase-webhooks", "description": "Receive and authenticate Metabase alert webhooks. Use when setting up a Metabase webhook endpoint, checking the HTTP Basic Authorization header, debugging 401s, or handling alert payloads with question results (alert_id, question_url, raw_data, base64 PNG visualization).", @@ -17348,7 +17306,7 @@ }, { "name": "webhook-skills", - "description": "Bundle of all webhook integration skills in this repo: 807 provider skills (Stripe, Shopify, GitHub, and 804 others) for verifying signatures and handling provider-specific events, framework-agnostic webhook handler patterns covering idempotency, error handling and retries, three Hookdeck infrastructure skills for the Event Gateway and Outpost, and a webhook DX audit skill for scoring how a platform sends outbound webhooks. Each provider skill includes Express, Next.js, and FastAPI examples. Install this plugin to get all 812 skills; install individual `-webhooks@webhook-skills` plugins for narrower per-provider use.", + "description": "Bundle of all webhook integration skills in this repo: 805 provider skills (Stripe, Shopify, GitHub, and 802 others) for verifying signatures and handling provider-specific events, framework-agnostic webhook handler patterns covering idempotency, error handling and retries, three Hookdeck infrastructure skills for the Event Gateway and Outpost, and a webhook DX audit skill for scoring how a platform sends outbound webhooks. Each provider skill includes Express, Next.js, and FastAPI examples. Install this plugin to get all 810 skills; install individual `-webhooks@webhook-skills` plugins for narrower per-provider use.", "source": "./", "strict": false, "skills": [ @@ -17674,7 +17632,6 @@ "./skills/increase-webhooks", "./skills/infinite-giving-webhooks", "./skills/ingenious-build-webhooks", - "./skills/instagram-webhooks", "./skills/instantly-webhooks", "./skills/intercom-webhooks", "./skills/invoice-ninja-webhooks", @@ -17754,7 +17711,6 @@ "./skills/memos-webhooks", "./skills/meraki-webhooks", "./skills/mercury-webhooks", - "./skills/messenger-webhooks", "./skills/metabase-webhooks", "./skills/metronome-webhooks", "./skills/mews-pos-webhooks", diff --git a/README.md b/README.md index 1cb492ff9..a40191234 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,6 @@ Skills for receiving and verifying webhooks from specific providers. Each includ | [Exact Online](https://support.exactonline.com/community/s/article/All-All-DNO-Content-webhooksc?language=en_GB) | [`exact-online-webhooks`](skills/exact-online-webhooks/) | Verify Exact Online `HashCode` body signatures (HMAC-SHA256 over the `Content` node, hex, uppercased), subscribe to topics, handle `Accounts`, `Items`, `StockPositions`, `GoodsDeliveries` events | | [Expo (EAS)](https://docs.expo.dev/eas/webhooks/) | [`expo-webhooks`](skills/expo-webhooks/) | Verify Expo EAS webhook signatures (`expo-signature` `sha1=`, HMAC-SHA1 of the raw body), handle EAS Build and Submit completion payloads with finished, errored and canceled statuses | | [Facebook](https://developers.facebook.com/docs/graph-api/webhooks) | [`facebook-webhooks`](skills/facebook-webhooks/) | Verify Facebook Graph API webhook signatures (`X-Hub-Signature-256`, HMAC-SHA256), complete the hub.challenge handshake, handle Page and User field updates | -| [Facebook Messenger](https://developers.facebook.com/documentation/business-messaging/messenger-platform/webhooks) | [`messenger-webhooks`](skills/messenger-webhooks/) | Verify Facebook Messenger webhook signatures (`X-Hub-Signature-256` `sha256=`, HMAC-SHA256 of the raw escaped-unicode body keyed with the App Secret), answer the `hub.challenge` handshake, handle `messages`, `messaging_postbacks`, `message_reads`, `message_deliveries`, `message_reactions`, `messaging_optins` and `messaging_referrals` | | [Factorial](https://apidoc.factorialhr.com/docs/webhooks-what) | [`factorial-webhooks`](skills/factorial-webhooks/) | Authenticate Factorial webhooks (unsigned: the `x-factorial-wh-challenge` value compared in constant time), handle subscription types like `employees/employee/create_with_contract` and `timeoff/leave/create` | | [Fanvue](https://api.fanvue.com/docs/webhooks) | [`fanvue-webhooks`](skills/fanvue-webhooks/) | Verify Fanvue creator-platform webhook signatures (`X-Fanvue-Signature: t=,v0=`, HMAC-SHA256 hex over `.`, with a bare second MAC during secret rotation that must also be accepted), answer the unsigned `fanvue.webhook.verification` probe, and handle the `creator.*`, `checkout_link.*`, `payout.*` and `app.*` event families plus the deprecated flat legacy events | | [FastSpring](https://developer.fastspring.com/reference/webhooks-overview) | [`fastspring-webhooks`](skills/fastspring-webhooks/) | Verify FastSpring webhook signatures (`X-FS-Signature`, HMAC-SHA256 base64 over the raw body), iterate the batched `events` array, handle order.completed, subscription.activated, subscription.charge.completed, and subscription.canceled events | @@ -353,7 +352,6 @@ Skills for receiving and verifying webhooks from specific providers. Each includ | [Increase](https://increase.com/documentation/webhooks) | [`increase-webhooks`](skills/increase-webhooks/) | Verify Increase webhooks (Standard Webhooks headers, HMAC-SHA256 base64 over `{id}.{timestamp}.{body}` keyed with the raw `shared_secret`, not `whsec_`), handle event categories like `transaction.created` | | [Infinite Giving](https://docs.infinitegiving.com/guide/webhooks/overview) | [`infinite-giving-webhooks`](skills/infinite-giving-webhooks/) | Verify Infinite Giving webhook signatures (`X-IG-Signature`, HMAC-SHA256 of `{X-IG-Timestamp}.{body}`), handle donation:created and donation:updated events | | [INGENIOUS.BUILD](https://help.ingenious.build/custom-accounting-integration-setup-guide-ingenious.build-help-center) | [`ingenious-build-webhooks`](skills/ingenious-build-webhooks/) | Authenticate INGENIOUS.BUILD Custom Accounting Integration webhooks (unsigned: a header you name, its whole value compared in constant time), handle budget, contract and invoice exports | -| [Instagram](https://developers.facebook.com/documentation/instagram-platform/webhooks/setup) | [`instagram-webhooks`](skills/instagram-webhooks/) | Verify Instagram (Meta) webhook signatures (`X-Hub-Signature-256` `sha256=`, HMAC-SHA256 of the raw body keyed with the app secret), answer the `hub.challenge` handshake, handle `comments`, `live_comments`, `mentions`, `story_insights` and messaging events (`messages`, `message_reactions`, `messaging_postbacks`, `messaging_seen`) | | [Instantly](https://help.instantly.ai/en/articles/6261906-webhooks) | [`instantly-webhooks`](skills/instantly-webhooks/) | Authenticate Instantly cold email webhooks — no signature; constant-time compare of the custom header you attach to the webhook (`Authorization: Basic ...` or a secret header), handle `email_sent`, `reply_received`, `email_bounced`, `lead_interested`, lead status and custom label events | | [Intercom](https://developers.intercom.com/docs/webhooks) | [`intercom-webhooks`](skills/intercom-webhooks/) | Verify Intercom `X-Hub-Signature` (HMAC-SHA1), handle conversation, contact, and ticket events | | [Invoice Ninja](https://api-docs.invoicing.co/#tag/webhooks) | [`invoice-ninja-webhooks`](skills/invoice-ninja-webhooks/) | Authenticate Invoice Ninja webhooks (unsigned: a secret header compared in full in constant time), handle invoice, payment and client entity payloads (and avoid the 404/405/410 responses that delete the subscription) | diff --git a/providers/messenger.yaml b/providers/messenger.yaml index 0e9742453..fb74d9c1b 100644 --- a/providers/messenger.yaml +++ b/providers/messenger.yaml @@ -7,8 +7,8 @@ docs: notes: > Facebook Messenger Platform webhooks for a Facebook Page. Meta signs every notification: header X-Hub-Signature-256: 'sha256=', HMAC-SHA256 hex of the payload keyed with the app's App Secret - (App settings > Basic). Meta signs the escaped-unicode payload (äöå as äöå): verify - over the raw bytes as received, never re-serialized JSON. Require and strip the sha256= prefix. + (App settings > Basic). Meta signs the escaped-unicode payload (äöå as \u00e4\u00f6\u00e5): verify + over the raw bytes as received, never re-serialised JSON. Require and strip the sha256= prefix. Body is {object: 'page', entry[]} with entry.messaging[] (or entry.standby[] under the handover protocol); each item has sender, recipient, timestamp and one event key: message (messages; is_echo for message_echoes), postback (messaging_postbacks), read (message_reads), delivery diff --git a/skills/instagram-webhooks/SKILL.md b/skills/instagram-webhooks/SKILL.md index 6c9b2b8ec..30b7ddc7c 100644 --- a/skills/instagram-webhooks/SKILL.md +++ b/skills/instagram-webhooks/SKILL.md @@ -150,8 +150,9 @@ INSTAGRAM_VERIFY_TOKEN=your_verify_token # A string you choose; must match the 3. Enable notifications for each account with `POST //subscribed_apps?subscribed_fields=...`. 4. **Hookdeck setup:** Paste your Meta app's App Secret (App settings > Basic). If your app uses Instagram API with Instagram Login and verification fails, use the Instagram app - secret from the Instagram product's API setup page instead. Hookdeck answers the - `hub.challenge` handshake itself. + secret from the Instagram product's API setup page instead. + Hookdeck answers the `hub.challenge` handshake itself without checking the Verify Token, + so any value you enter in the Meta dashboard works. See [references/setup.md](references/setup.md) for per-setup tokens, hosts and permissions. diff --git a/skills/instagram-webhooks/examples/express/test/webhook.test.js b/skills/instagram-webhooks/examples/express/test/webhook.test.js index 0b9484497..92a4962b0 100644 --- a/skills/instagram-webhooks/examples/express/test/webhook.test.js +++ b/skills/instagram-webhooks/examples/express/test/webhook.test.js @@ -107,7 +107,7 @@ describe('POST /webhooks/instagram (signature verification)', () => { }); it('verifies the escaped-unicode bytes Meta signs', async () => { - // Meta signs e.g. "äöå" as "äöå"; verify over those exact bytes. + // Meta signs e.g. "äöå" as "\u00e4\u00f6\u00e5"; verify over those exact bytes. const body = changeBody('comments', { id: '1', text: 'PLACEHOLDER' }).replace( 'PLACEHOLDER', '\\u00e4\\u00f6\\u00e5' diff --git a/skills/instagram-webhooks/examples/fastapi/test_webhook.py b/skills/instagram-webhooks/examples/fastapi/test_webhook.py index 4605f84b9..9f20cd184 100644 --- a/skills/instagram-webhooks/examples/fastapi/test_webhook.py +++ b/skills/instagram-webhooks/examples/fastapi/test_webhook.py @@ -55,7 +55,7 @@ def post(body: bytes, signature=None, content_type: str = "application/json"): }, ) -# Meta signs an escaped-unicode payload: "äöå" arrives as "äöå". +# Meta signs an escaped-unicode payload: "äöå" arrives as "\u00e4\u00f6\u00e5". ESCAPED = change_body("comments", {"id": "1", "text": "äöå"}, ensure_ascii=True) diff --git a/skills/instagram-webhooks/references/setup.md b/skills/instagram-webhooks/references/setup.md index c62ba6092..0f7c1be4e 100644 --- a/skills/instagram-webhooks/references/setup.md +++ b/skills/instagram-webhooks/references/setup.md @@ -32,9 +32,11 @@ Open Webhooks for your API setup: - **Unified Login:** **Configure webhooks** on the **API setup with Unified login** tab. - **Instagram Login:** **Instagram > API setup with Instagram login**, section - **Configure webhooks** > **Configure**. New apps are subscribed by default to `comments`, - `live_comments`, `message_reactions`, `messages`, `messaging_optins`, - `messaging_postbacks`, `messaging_referral` and `messaging_seen`. + **Configure webhooks** > **Configure**. Meta's + [Create a Meta app for the Instagram API](https://developers.facebook.com/documentation/instagram-platform/create-an-instagram-app) + guide (Step 8) says you're subscribed by default to all available webhooks for the + Instagram product: `comments`, `live_comments`, `message_reactions`, `messages`, + `messaging_optins`, `messaging_postbacks`, `messaging_referral` and `messaging_seen`. - **Facebook Login / Messenger Platform:** the Webhooks product (or Messenger > Instagram settings for Instagram messaging). @@ -71,8 +73,9 @@ Paste your Meta app's App Secret (App settings > Basic). If your app uses Instag Instagram Login and verification fails, use the Instagram app secret from the Instagram product's API setup page instead. -Hookdeck's edge answers the `hub.challenge` handshake itself, so the destination doesn't need -to handle the GET. Use the Hookdeck source URL as the Callback URL. +Hookdeck answers the `hub.challenge` handshake itself without checking the Verify Token, so +any value you enter in the Meta dashboard works. The destination doesn't need to handle the +GET. Use the Hookdeck source URL as the Callback URL. For local development: diff --git a/skills/instagram-webhooks/references/verification.md b/skills/instagram-webhooks/references/verification.md index fb55a076a..fd5eb003e 100644 --- a/skills/instagram-webhooks/references/verification.md +++ b/skills/instagram-webhooks/references/verification.md @@ -60,7 +60,7 @@ def verify_instagram_signature(raw_body: bytes, signature_header: str, app_secre ## Common Gotchas - **Use the raw body.** Meta signs the bytes it sends. Non-ASCII text can arrive as escaped - unicode (`ä`), and `JSON.stringify(JSON.parse(body))` turns that into `ä`, which + unicode (`\u00e4`), and `JSON.stringify(JSON.parse(body))` turns that into `ä`, which changes the bytes and breaks the signature. In Express use `express.raw({ type: '*/*' })`; in Next.js read `request.arrayBuffer()`; in FastAPI `await request.body()`. - **Wrong secret.** App Secret vs Instagram app secret; never the verify token or an access token. diff --git a/skills/messenger-webhooks/SKILL.md b/skills/messenger-webhooks/SKILL.md index 52f215842..fe040ad7f 100644 --- a/skills/messenger-webhooks/SKILL.md +++ b/skills/messenger-webhooks/SKILL.md @@ -46,8 +46,8 @@ each entry carries a `messaging[]` array (or `standby[]` under the handover prot Meta sends `X-Hub-Signature-256: sha256=`, the HMAC-SHA256 hex digest of the request body keyed with your **App Secret** (App settings > Basic). Meta signs an **escaped-unicode** -form of the payload (`äöå` as `äöå`), so verify over the **raw bytes** -exactly as received; re-serializing parsed JSON produces different bytes. Require the +form of the payload (`äöå` as `\u00e4\u00f6\u00e5`), so verify over the **raw bytes** +exactly as received; re-serialising parsed JSON produces different bytes. Require the `sha256=` prefix, check the digest is 64 hex characters, compare in constant time. Node: @@ -144,8 +144,9 @@ MESSENGER_VERIFY_TOKEN=your_verify_token # A string you choose; must match the Token, then subscribe to fields (`messages`, `messaging_postbacks`, ...). 3. Subscribe each Page: `POST //subscribed_apps?subscribed_fields=messages,...` with a Page access token (`pages_messaging`, `pages_manage_metadata`). -4. **Hookdeck setup:** Paste your Meta app's App Secret (App settings > Basic). Hookdeck - answers the `hub.challenge` handshake itself. +4. **Hookdeck setup:** Paste your Meta app's App Secret (App settings > Basic). + Hookdeck answers the `hub.challenge` handshake itself without checking the Verify Token, + so any value you enter in the Meta dashboard works. See [references/setup.md](references/setup.md). diff --git a/skills/messenger-webhooks/examples/express/src/index.js b/skills/messenger-webhooks/examples/express/src/index.js index 3a0a2b611..78e2b1255 100644 --- a/skills/messenger-webhooks/examples/express/src/index.js +++ b/skills/messenger-webhooks/examples/express/src/index.js @@ -13,7 +13,7 @@ const SIGNATURE_PATTERN = /^sha256=([0-9a-f]{64})$/i; * * Meta sends X-Hub-Signature-256: sha256=, the HMAC-SHA256 hex digest of * the request body keyed with the App Secret. Meta signs the escaped-unicode - * payload (e.g. "ä" rather than "ä"), so the HMAC must run over the RAW + * payload (e.g. "\u00e4" rather than "ä"), so the HMAC must run over the RAW * bytes as received, never over re-serialized JSON. The pattern check * guarantees 64 hex characters before Buffer.from(x, 'hex'), then the two * 32-byte digests are compared in constant time. diff --git a/skills/messenger-webhooks/examples/express/test/webhook.test.js b/skills/messenger-webhooks/examples/express/test/webhook.test.js index 194298948..f3d23ed26 100644 --- a/skills/messenger-webhooks/examples/express/test/webhook.test.js +++ b/skills/messenger-webhooks/examples/express/test/webhook.test.js @@ -31,7 +31,7 @@ function body(item, key = 'messaging') { const textBody = body({ message: { mid: 'mid.1457764197618:41d102a3e1ae206a38', text: 'hello, world!' } }); -// Meta signs the escaped-unicode payload: "äöå" as "äöå". +// Meta signs the escaped-unicode payload: "äöå" as "\u00e4\u00f6\u00e5". const escapedBody = body({ message: { mid: 'm_1', text: 'PLACEHOLDER' } }).replace( 'PLACEHOLDER', '\\u00e4\\u00f6\\u00e5' @@ -99,7 +99,7 @@ describe('POST /webhooks/messenger (signature verification)', () => { }); it('rejects a signature computed over the decoded (re-serialized) payload', async () => { - const decoded = JSON.stringify(JSON.parse(escapedBody)); // "ä" becomes "ä" + const decoded = JSON.stringify(JSON.parse(escapedBody)); // "\u00e4" becomes "ä" expect(decoded).toContain('äöå'); expect((await post(escapedBody, sign(decoded))).status).toBe(401); }); diff --git a/skills/messenger-webhooks/examples/fastapi/test_webhook.py b/skills/messenger-webhooks/examples/fastapi/test_webhook.py index 6a6357907..94122721f 100644 --- a/skills/messenger-webhooks/examples/fastapi/test_webhook.py +++ b/skills/messenger-webhooks/examples/fastapi/test_webhook.py @@ -41,7 +41,7 @@ def post(payload: bytes, signature=None, content_type: str = "application/json") TEXT = body({"message": {"mid": "mid.1457764197618:41d102a3e1ae206a38", "text": "hello, world!"}}) -# Meta signs the escaped-unicode payload: "äöå" as "äöå". +# Meta signs the escaped-unicode payload: "äöå" as "\u00e4\u00f6\u00e5". ESCAPED = body({"message": {"mid": "m_1", "text": "äöå"}}, ensure_ascii=True) diff --git a/skills/messenger-webhooks/examples/nextjs/test/webhook.test.ts b/skills/messenger-webhooks/examples/nextjs/test/webhook.test.ts index 73e7c83a1..3c21770c9 100644 --- a/skills/messenger-webhooks/examples/nextjs/test/webhook.test.ts +++ b/skills/messenger-webhooks/examples/nextjs/test/webhook.test.ts @@ -42,7 +42,7 @@ function post(payload: string, signature?: string, contentType = 'application/js const textBody = body({ message: { mid: 'mid.1457764197618:41d102a3e1ae206a38', text: 'hello, world!' } }); -// Meta signs the escaped-unicode payload: "äöå" as "äöå". +// Meta signs the escaped-unicode payload: "äöå" as "\u00e4\u00f6\u00e5". const escapedBody = body({ message: { mid: 'm_1', text: 'PLACEHOLDER' } }).replace( 'PLACEHOLDER', '\\u00e4\\u00f6\\u00e5' diff --git a/skills/messenger-webhooks/references/setup.md b/skills/messenger-webhooks/references/setup.md index 9b90e463e..7e1e57653 100644 --- a/skills/messenger-webhooks/references/setup.md +++ b/skills/messenger-webhooks/references/setup.md @@ -54,8 +54,9 @@ Every messaging app for the business must be subscribed to the messaging webhook Paste your Meta app's App Secret (App settings > Basic). -Hookdeck's edge answers the `hub.challenge` handshake itself, so the destination doesn't need -to handle the GET. Use the Hookdeck source URL as the Callback URL. Hookdeck acknowledges Meta +Hookdeck answers the `hub.challenge` handshake itself without checking the Verify Token, so +any value you enter in the Meta dashboard works. The destination doesn't need to handle the +GET. Use the Hookdeck source URL as the Callback URL. Hookdeck acknowledges Meta quickly, which helps you stay inside Meta's 5-second response window and avoid the 1-hour **Webhooks Disabled** unsubscribe. diff --git a/skills/messenger-webhooks/references/verification.md b/skills/messenger-webhooks/references/verification.md index f04375677..33c29cefd 100644 --- a/skills/messenger-webhooks/references/verification.md +++ b/skills/messenger-webhooks/references/verification.md @@ -23,12 +23,12 @@ token matches `MESSENGER_VERIFY_TOKEN`; otherwise `403`. (Hookdeck's edge answer Meta's docs: "we generate the signature using an *escaped unicode* version of the payload, with lowercase hex digits. If you just calculate against the decoded bytes, you will end up with a different signature. For example, the string `äöå` should be escaped to -`äöå`." +`\u00e4\u00f6\u00e5`." The note describes the signed form, not the wire format, but the practical rule is the same: -HMAC the body bytes exactly as they arrive and never a decoded or re-serialized copy. +HMAC the body bytes exactly as they arrive and never a decoded or re-serialised copy. `JSON.stringify(JSON.parse(body))` (or Python's `json.dumps(..., ensure_ascii=False)`) turns -`ä` into `ä`, the bytes change, and the signature no longer matches. Meta's own sample +`\u00e4` into `ä`, the bytes change, and the signature no longer matches. Meta's own sample computes the HMAC over the raw `buf` passed to its body-parser verify function. ### Node.js @@ -77,6 +77,6 @@ def verify_messenger_signature(raw_body: bytes, signature_header: str, app_secre | Symptom | Likely cause | |---------|--------------| | Handshake never succeeds | Returning JSON instead of the raw `hub.challenge`, or verify token mismatch | -| Fails only for messages with emoji/accents | HMAC computed over decoded or re-serialized JSON instead of the raw bytes | +| Fails only for messages with emoji/accents | HMAC computed over decoded or re-serialised JSON instead of the raw bytes | | Signature always invalid | Body parsed by a JSON middleware first, or the wrong app's secret | | Only your own test messages arrive | App has Standard Access; customers need Advanced Access |