Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ PORT=3000

# WhatsApp block (sandbox env writes WHATSAPP_API_URL; production channels env writes META_GRAPH_API_URL, the kit accepts either)
WHATSAPP_API_URL=https://sandbox.hookmyapp.com/v22.0
WHATSAPP_ACCESS_TOKEN=your-sandbox-activation-code
WHATSAPP_ACCESS_TOKEN=your-sandbox-channel-token
WHATSAPP_PHONE_NUMBER_ID=your-sandbox-phone

# Instagram block (sandbox env writes INSTAGRAM_API_URL; a real Instagram
# channel's channels env writes INSTAGRAM_GRAPH_API_URL instead. Both write
# INSTAGRAM_ACCOUNT_ID. The kit reads either base-URL shape, so the same code
# runs against the sandbox and production unchanged.)
INSTAGRAM_API_URL=https://sandbox.hookmyapp.com/v25.0
INSTAGRAM_ACCESS_TOKEN=your-sandbox-activation-code
INSTAGRAM_ACCESS_TOKEN=your-sandbox-channel-token
INSTAGRAM_ACCOUNT_ID=your-sandbox-ig-account-id

# ===================================================================
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/copy-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Retired-copy ban (MAR-3). This kit is cloned by every new integrator, so a
# stale term here becomes their vocabulary. Bans reseller-category framing and
# the retired "activation code" name for the sandbox channel token.
name: Copy guard

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
banned-copy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Ban retired copy in the kit
run: |
set -uo pipefail
status=0
HITS=$(grep -rn -i -E '\bBSPs?\b|passthrough|activation code' README.md AGENTS.md .env.example src/) || status=$?
# grep exits 1 for "no match" and >1 for a real error. Without this the
# `|| true` idiom fails OPEN and passes on a scan that never ran.
if [ "$status" -gt 1 ]; then
echo "FAIL: grep exited $status — the scan did not run, refusing to pass"
exit 1
fi
if [ -n "$HITS" ]; then
echo "FAIL: retired copy found in the starter kit:"
echo "$HITS"
echo ""
echo " Fix: reseller-category framing (say what the reader gets, never"
echo " the category or the plumbing) and \"activation code\" (the"
echo " env value is the sandbox channel token; the code the user"
echo " sends is the BIND code). See MAR-3."
exit 1
fi
echo "PASS: no retired copy in the starter kit"
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This kit is an **Express webhook receiver wired to `@gethookmyapp/cli`**. The CL
- `WEBHOOK_HMAC_SECRET` — the HMAC-SHA256 key for `X-HookMyApp-Signature-256`. Written by both `sandbox env` and `channels env`. As of v3 there is NO `VERIFY_TOKEN` fallback.
- `PORT` — port the Express server listens on (defaults to `3000` if absent).
- `META_GRAPH_API_URL` — Meta Graph API base URL. Sandbox: `https://sandbox.hookmyapp.com/v22.0`. Production: `https://graph.facebook.com/v24.0` (or whatever Graph version your channel is pinned to). Renamed from `WHATSAPP_API_URL` in v2.0.0 — the name now reflects that the Graph API is Meta-level, not WhatsApp-specific.
- `WHATSAPP_ACCESS_TOKEN` — sandbox activation code (CLI-issued) or production Meta access token.
- `WHATSAPP_ACCESS_TOKEN` — Bearer credential for the chosen transport: a HookMyApp channel token (`hmat_`) from `sandbox env` / `channels env`, or your own Meta access token on the direct-Meta transport (see "Choose exactly one transport" below).
- `WHATSAPP_PHONE_NUMBER_ID` — sandbox session phone or production Meta phone number ID.
- **Sandbox vs production:** sandbox is a shared HookMyApp WABA with no Meta paperwork; recipient is pinned server-side to the session phone and templates are blocked. Production is the user's own WABA via Meta embedded signup; templates work and any opted-in recipient is reachable. The env keys above stay the same — only their values change.

Expand Down Expand Up @@ -179,9 +179,9 @@ Type into the bottom input and press Enter to send a message. This posts to `POS

## Signature verification

Every inbound `POST /webhook/whatsapp` or `POST /webhook/instagram` from HookMyApp, in both sandbox and production, carries an `X-HookMyApp-Signature-256` header set to `sha256=<hex>` where the HMAC key is your `WEBHOOK_HMAC_SECRET`. As of v3 there is no `VERIFY_TOKEN` fallback. HookMyApp's forwarder signs every outbound request this way; the customer-facing contract is a single shape, not two.
Every inbound `POST /webhook/whatsapp` or `POST /webhook/instagram` from HookMyApp, in both sandbox and production, carries an `X-HookMyApp-Signature-256` header set to `sha256=<hex>` where the HMAC key is your `WEBHOOK_HMAC_SECRET`. As of v3 there is no `VERIFY_TOKEN` fallback. HookMyApp signs every outbound request this way; the customer-facing contract is a single shape, not two.

This kit's `src/index.js` uses the parsed-then-restringified body shape because the kit ships with `express.json()` middleware. The forwarder signs `JSON.stringify(parsedBody)` on its side, and V8's `JSON.stringify` is deterministic, so parsed+restringified on your side is byte-equivalent to raw.
This kit's `src/index.js` uses the parsed-then-restringified body shape because the kit ships with `express.json()` middleware. HookMyApp signs `JSON.stringify(parsedBody)` on its side, and V8's `JSON.stringify` is deterministic, so parsed+restringified on your side is byte-equivalent to raw.

```js
import { createHmac } from 'node:crypto';
Expand All @@ -195,9 +195,9 @@ function verifySignature(body, signature, hmacSecret) {
}
```

If you extend the kit and swap `express.json()` for `express.raw({ type: 'application/json' })`, update `.update(JSON.stringify(body))` to `.update(rawBody)` — the signature still matches because the forwarder sent the same bytes. What you must NOT do is mix the two (e.g., keep `express.json()` but hash the stringified representation of a manually re-encoded object with different whitespace) — that will break verification.
If you extend the kit and swap `express.json()` for `express.raw({ type: 'application/json' })`, update `.update(JSON.stringify(body))` to `.update(rawBody)` — the signature still matches because HookMyApp sent the same bytes. What you must NOT do is mix the two (e.g., keep `express.json()` but hash the stringified representation of a manually re-encoded object with different whitespace) — that will break verification.

> **Note:** Earlier versions of this guide and the HookMyApp skill mentioned a separate `X-Hub-Signature-256` path keyed on Meta's `APP_SECRET` for production. That path does **not** exist on the customer-facing interface — the forwarder verifies Meta's signature internally and re-signs with your `WEBHOOK_HMAC_SECRET` before forwarding. Do not wire an `APP_SECRET` verification branch on your server.
> **Note:** Earlier versions of this guide and the HookMyApp skill mentioned a separate `X-Hub-Signature-256` path keyed on Meta's `APP_SECRET` for production. That path does **not** exist on the customer-facing interface — HookMyApp verifies Meta's signature internally and re-signs with your `WEBHOOK_HMAC_SECRET` before forwarding. Do not wire an `APP_SECRET` verification branch on your server.

## Troubleshooting

Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,24 +98,24 @@ The `.env.example` file lists the keys the server expects, but you should not ne
| `WEBHOOK_HMAC_SECRET` | Signing key for verifying incoming `X-HookMyApp-Signature-256` headers (HMAC-SHA256). Written by both `sandbox env` and `channels env`. As of v3 there is no `VERIFY_TOKEN` fallback. |
| `PORT` | Port the webhook server listens on. Default `3000`. |
| `WHATSAPP_API_URL` | WhatsApp Graph API base URL. Sandbox: `https://sandbox.hookmyapp.com/v22.0`. Production `channels env` writes this as `META_GRAPH_API_URL`; the kit reads either. |
| `WHATSAPP_ACCESS_TOKEN` | Sandbox activation code (CLI-provided) or Meta access token in production. |
| `WHATSAPP_ACCESS_TOKEN` | The Bearer credential for whichever transport you chose. `sandbox env` and `channels env` both write a HookMyApp channel token (`hmat_`); if you point the base at `graph.facebook.com` yourself, put your own Meta access token here. |
| `WHATSAPP_PHONE_NUMBER_ID` | Phone number ID from your sandbox session or Meta app. |
| `INSTAGRAM_API_URL` | Instagram Graph API base URL. Sandbox `sandbox env` writes this. A real Instagram channel's `channels env` writes it as `INSTAGRAM_GRAPH_API_URL`; the kit reads either. |
| `INSTAGRAM_ACCESS_TOKEN` | Sandbox activation code (CLI-provided) or Meta access token for Instagram. |
| `INSTAGRAM_ACCESS_TOKEN` | Same, for Instagram: a HookMyApp channel token (`hmat_`) from the CLI, or your own Meta access token on the direct-Meta transport. |
| `INSTAGRAM_ACCOUNT_ID` | Instagram account ID the kit sends from. |
| `INSTAGRAM_USERNAME` | Optional. The connected account's @username. Used by `/comments` to filter out the account's own reply echoes (comment webhooks can report the account under a different id than `INSTAGRAM_ACCOUNT_ID`). |

## How it works

```
```text
WhatsApp user Meta HookMyApp Your server
sends message ──────> Cloud API ──> Forwarder ──────> POST /webhook/whatsapp
sends message ──────> Cloud API ──> HookMyApp ──────> POST /webhook/whatsapp
webhook signs with verifies
HMAC-SHA256 signature
```

1. A WhatsApp user sends a message to your sandbox business number.
2. Meta's Cloud API delivers the webhook to HookMyApp's forwarder.
2. Meta's Cloud API delivers the webhook to HookMyApp.
3. HookMyApp signs the payload with your webhook signing secret (HMAC-SHA256) and forwards it through a Cloudflare tunnel to your local server.
4. Your server verifies the signature and processes the message.

Expand All @@ -125,7 +125,7 @@ The payload arrives in the **original Meta format**. HookMyApp does not transfor

### Verification challenge

When you register your own public webhook URL with `hookmyapp channels webhook set <channel> --url ...` or `hookmyapp sandbox webhook set --url ...`, HookMyApp sends a one-time `GET /webhook/whatsapp` (or `/webhook/instagram`) to that URL. Your server must respond with `VERIFY_TOKEN` as the entire response body. This kit handles that automatically in `src/index.js`. The sandbox `listen` tunnel does not issue this GET; it only forwards live POSTs from the forwarder to your local routes.
When you register your own public webhook URL with `hookmyapp channels webhook set <channel> --url ...` or `hookmyapp sandbox webhook set --url ...`, HookMyApp sends a one-time `GET /webhook/whatsapp` (or `/webhook/instagram`) to that URL. Your server must respond with `VERIFY_TOKEN` as the entire response body. This kit handles that automatically in `src/index.js`. The sandbox `listen` tunnel does not issue this GET; it only forwards live POSTs from HookMyApp to your local routes.

### Signature verification

Expand Down
Loading