{"_id":"@1440io/msp-webhooks","_rev":"4-cb7e61bde8ce66606425857b86a04b9b","name":"@1440io/msp-webhooks","dist-tags":{"latest":"0.4.0"},"versions":{"0.1.0":{"name":"@1440io/msp-webhooks","version":"0.1.0","keywords":["1440","webhooks","hmac","signature","apple-messages-for-business","amb","msp"],"author":{"name":"1440"},"license":"MIT","_id":"@1440io/msp-webhooks@0.1.0","maintainers":[{"name":"jtjessup","email":"jon@1440.io"}],"homepage":"https://github.com/1440io/msp-sdk/tree/main/packages/msp-webhooks#readme","bugs":{"url":"https://github.com/1440io/msp-sdk/issues"},"dist":{"shasum":"89ae115d2411ef2002d6de382d2b84e87fb11e4b","tarball":"https://registry.npmjs.org/@1440io/msp-webhooks/-/msp-webhooks-0.1.0.tgz","fileCount":38,"integrity":"sha512-6beHNw2MwbAOIWV8sBvGbycSVMAU6a5K0MbM7pYtuxS7j5InLHujiLymfhVNEe+2Jt6Zc33N1a4SlMDAErIwSw==","signatures":[{"sig":"MEUCIQC+aD/m5I/6mopanokrisj8hwJfscymdyYUlFDjPVqPGgIgeM429CkHUlH4/DAKW7y8B6oEjFYDnPC56ImlgmVocIA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":324794},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20.10"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./fetch":{"types":"./dist/adapters/fetch.d.ts","import":"./dist/adapters/fetch.js","require":"./dist/adapters/fetch.cjs"},"./lambda":{"types":"./dist/adapters/lambda.d.ts","import":"./dist/adapters/lambda.js","require":"./dist/adapters/lambda.cjs"},"./express":{"types":"./dist/adapters/express.d.ts","import":"./dist/adapters/express.js","require":"./dist/adapters/express.cjs"},"./fastify":{"types":"./dist/adapters/fastify.d.ts","import":"./dist/adapters/fastify.js","require":"./dist/adapters/fastify.cjs"},"./package.json":"./package.json"},"gitHead":"871499b0c8db29bd7baf9273fc90286ae14c16b6","scripts":{"build":"tsup","typecheck":"tsc --noEmit -p tsconfig.json"},"_npmUser":{"name":"jtjessup","email":"jon@1440.io"},"repository":{"url":"git+https://github.com/1440io/msp-sdk.git","type":"git","directory":"packages/msp-webhooks"},"_npmVersion":"11.14.1","description":"Signature verification and framework adapters for 1440 MSP API webhooks (Web Crypto — Node, edge, Workers, Deno).","directories":{},"sideEffects":false,"_nodeVersion":"22.11.0","dependencies":{"@1440io/msp-types":"0.1.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","vitest":"^4.1.11","typescript":"^5.7.2"},"_npmOperationalInternal":{"tmp":"tmp/msp-webhooks_0.1.0_1787704736300_0.8349175067736321","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@1440io/msp-webhooks","version":"0.2.0","keywords":["1440","webhooks","hmac","signature","apple-messages-for-business","amb","msp"],"author":{"name":"1440"},"license":"MIT","_id":"@1440io/msp-webhooks@0.2.0","maintainers":[{"name":"jtjessup","email":"jon@1440.io"}],"homepage":"https://github.com/1440io/msp-sdk/tree/main/packages/msp-webhooks#readme","bugs":{"url":"https://github.com/1440io/msp-sdk/issues"},"dist":{"shasum":"e062f5593bd6e7ecd54a0adb19f8535aa091f8a2","tarball":"https://registry.npmjs.org/@1440io/msp-webhooks/-/msp-webhooks-0.2.0.tgz","fileCount":38,"integrity":"sha512-9LadIS9ZUWK1VscwxX6+esOJwnxmGzh7um/CGFfobmdLs9fqWqdtcuf4F8CkPSt2GgSRRwdjfmUvXa8oaduUgQ==","signatures":[{"sig":"MEYCIQD8ymrYz+DOC8T9AFSBnqZBfvz7ox0Bpii2G77JauG52QIhAM1I0IE0Wvax/Rjtj5kpN39BUK+upa3zCsdx8H9WiaiQ","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":351887},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20.10"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./fetch":{"types":"./dist/adapters/fetch.d.ts","import":"./dist/adapters/fetch.js","require":"./dist/adapters/fetch.cjs"},"./lambda":{"types":"./dist/adapters/lambda.d.ts","import":"./dist/adapters/lambda.js","require":"./dist/adapters/lambda.cjs"},"./express":{"types":"./dist/adapters/express.d.ts","import":"./dist/adapters/express.js","require":"./dist/adapters/express.cjs"},"./fastify":{"types":"./dist/adapters/fastify.d.ts","import":"./dist/adapters/fastify.js","require":"./dist/adapters/fastify.cjs"},"./package.json":"./package.json"},"gitHead":"e7c50bc7a1cb758fe903bbd6cb62068f640de86b","scripts":{"build":"tsup","typecheck":"tsc --noEmit -p tsconfig.json"},"_npmUser":{"name":"jtjessup","email":"jon@1440.io"},"repository":{"url":"git+https://github.com/1440io/msp-sdk.git","type":"git","directory":"packages/msp-webhooks"},"_npmVersion":"11.14.1","description":"Signature verification and framework adapters for 1440 MSP API webhooks (Web Crypto — Node, edge, Workers, Deno).","directories":{},"sideEffects":false,"_nodeVersion":"22.11.0","dependencies":{"@1440io/msp-types":"0.2.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","vitest":"^4.1.11","typescript":"^5.7.2"},"_npmOperationalInternal":{"tmp":"tmp/msp-webhooks_0.2.0_1789008811323_0.435040211400582","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@1440io/msp-webhooks","version":"0.3.0","keywords":["1440","webhooks","hmac","signature","apple-messages-for-business","amb","msp"],"author":{"name":"1440"},"license":"MIT","_id":"@1440io/msp-webhooks@0.3.0","maintainers":[{"name":"jtjessup","email":"jon@1440.io"}],"homepage":"https://github.com/1440io/msp-sdk/tree/main/packages/msp-webhooks#readme","bugs":{"url":"https://github.com/1440io/msp-sdk/issues"},"dist":{"shasum":"25d092e77e2d39bb79bb88bcd98bb0eae9bc6f9d","tarball":"https://registry.npmjs.org/@1440io/msp-webhooks/-/msp-webhooks-0.3.0.tgz","fileCount":38,"integrity":"sha512-EF19tn3JCkwVNToCnCk9F0pTK5rym3uUGVNb6oZgxAguNBluDILEOrq5gF6wdHW2H6tFWUg7ga+tmJJ42kOQqg==","signatures":[{"sig":"MEUCICI5SYohRTVWe9rG9pvK2IDNaUEXcr1ahQXRHDWOwl/yAiEAppW2OnHEEkZNOkLiMRVVq36rwBOoRhfW/xOK0M7p8Sg=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCIQDqqzokAeH4TYdQ0Qv9Jnk6H3n4nlWb015NeuGWwBFRZQIgWgRdS3FXObqqvobxfOsc3QFTpx30qetzt14xB6BZ/SM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":358419},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=20.10"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./fetch":{"types":"./dist/adapters/fetch.d.ts","import":"./dist/adapters/fetch.js","require":"./dist/adapters/fetch.cjs"},"./lambda":{"types":"./dist/adapters/lambda.d.ts","import":"./dist/adapters/lambda.js","require":"./dist/adapters/lambda.cjs"},"./express":{"types":"./dist/adapters/express.d.ts","import":"./dist/adapters/express.js","require":"./dist/adapters/express.cjs"},"./fastify":{"types":"./dist/adapters/fastify.d.ts","import":"./dist/adapters/fastify.js","require":"./dist/adapters/fastify.cjs"},"./package.json":"./package.json"},"gitHead":"449408485d0365df0e0edb776316e8652119d941","scripts":{"build":"tsup","typecheck":"tsc --noEmit -p tsconfig.json"},"_npmUser":{"name":"jtjessup","email":"jon@1440.io"},"repository":{"url":"git+https://github.com/1440io/msp-sdk.git","type":"git","directory":"packages/msp-webhooks"},"_npmVersion":"11.14.1","description":"Signature verification and framework adapters for 1440 MSP API webhooks (Web Crypto — Node, edge, Workers, Deno).","directories":{},"sideEffects":false,"_nodeVersion":"22.11.0","dependencies":{"@1440io/msp-types":"0.3.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","vitest":"^4.1.11","typescript":"^5.7.2"},"_npmOperationalInternal":{"tmp":"tmp/msp-webhooks_0.3.0_1789768879101_0.6555638958448495","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"_id":"@1440io/msp-webhooks@0.4.0","bugs":{"url":"https://github.com/1440io/msp-sdk/issues"},"dist":{"shasum":"776a2538b3ad0c3ea3f012fd80a18583e84dd832","tarball":"https://registry.npmjs.org/@1440io/msp-webhooks/-/msp-webhooks-0.4.0.tgz","fileCount":38,"integrity":"sha512-EBGJBmOQpbP3kmkGVO1Bl2k/JIMsQBbofMP7KIthsQEw/H1e/wmhPOaSWQ0E6w7YtReJzl+BGK/bIF5cxsK7Lw==","signatures":[{"sig":"MEUCIQDXRzaI0P0er5hwghSmjOWyjo1LVleR3XpNsVegA6ErpgIgXBsBBw1/5+bmh5iaWAbENuast/5jCESpEdfgCIT23AY=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIQCIzhnr9vaWaIfeQh2cVx/yw1hqRxACD5fXQj0NTtR8NAIgcpDbwzM94cHTzsKIwKN/isTNsekYlFHv6M8U2kOwXTQ="}],"unpackedSize":358510},"main":"./dist/index.cjs","name":"@1440io/msp-webhooks","type":"module","types":"./dist/index.d.ts","author":{"name":"1440"},"module":"./dist/index.js","engines":{"node":">=20.10"},"exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./fetch":{"types":"./dist/adapters/fetch.d.ts","import":"./dist/adapters/fetch.js","require":"./dist/adapters/fetch.cjs"},"./lambda":{"types":"./dist/adapters/lambda.d.ts","import":"./dist/adapters/lambda.js","require":"./dist/adapters/lambda.cjs"},"./express":{"types":"./dist/adapters/express.d.ts","import":"./dist/adapters/express.js","require":"./dist/adapters/express.cjs"},"./fastify":{"types":"./dist/adapters/fastify.d.ts","import":"./dist/adapters/fastify.js","require":"./dist/adapters/fastify.cjs"},"./package.json":"./package.json"},"gitHead":"5122f14ef8e132fb62e9ca50b47c63eb95a0ed2a","license":"MIT","scripts":{"build":"tsup","typecheck":"tsc --noEmit -p tsconfig.json"},"version":"0.4.0","_npmUser":{"name":"jtjessup","email":"jon@1440.io"},"homepage":"https://github.com/1440io/msp-sdk/tree/main/packages/msp-webhooks#readme","keywords":["1440","webhooks","hmac","signature","apple-messages-for-business","amb","msp"],"repository":{"url":"git+https://github.com/1440io/msp-sdk.git","type":"git","directory":"packages/msp-webhooks"},"_npmVersion":"11.14.1","description":"Signature verification and framework adapters for 1440 MSP API webhooks (Web Crypto — Node, edge, Workers, Deno).","directories":{},"maintainers":[{"name":"jtjessup","email":"jon@1440.io"}],"sideEffects":false,"_nodeVersion":"22.11.0","dependencies":{"@1440io/msp-types":"0.4.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.1","vitest":"^4.1.11","typescript":"^5.7.2"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/msp-webhooks_0.4.0_1790034011737_0.4396128703124942"}}},"time":{"created":"2026-08-26T00:38:56.140Z","modified":"2026-09-21T23:40:12.039Z","0.1.0":"2026-08-26T00:38:56.508Z","0.2.0":"2026-09-10T02:53:31.466Z","0.3.0":"2026-09-18T22:01:19.340Z","0.4.0":"2026-09-21T23:40:11.815Z"},"bugs":{"url":"https://github.com/1440io/msp-sdk/issues"},"author":{"name":"1440"},"license":"MIT","homepage":"https://github.com/1440io/msp-sdk/tree/main/packages/msp-webhooks#readme","keywords":["1440","webhooks","hmac","signature","apple-messages-for-business","amb","msp"],"repository":{"url":"git+https://github.com/1440io/msp-sdk.git","type":"git","directory":"packages/msp-webhooks"},"description":"Signature verification and framework adapters for 1440 MSP API webhooks (Web Crypto — Node, edge, Workers, Deno).","maintainers":[{"name":"jtjessup","email":"jon@1440.io"}],"readme":"# @1440io/msp-webhooks\n\nSignature verification and framework adapters for [1440](https://1440.io) MSP API webhooks.\n\n```bash\nnpm install @1440io/msp-webhooks\n```\n\nVerification is built on Web Crypto, so the same code runs on Node 20.10+, Cloudflare Workers, Vercel Edge, Deno, and Bun. Ships ESM and CJS. Zero runtime dependencies beyond `@1440io/msp-types`.\n\n## Events\n\n| Type | Fires when |\n| --- | --- |\n| `message.received` | A customer sends an inbound message. `message.content` is a union tagged by `kind` — `text`, `opt_out`, `amb.quick_reply_response`, `amb.list_picker_response`, `amb.time_picker_response`, `amb.form_response`, `amb.authentication_response`, and a few more. |\n| `messaging_invitation.updated` | A messaging invitation changes status. |\n\nThe envelope carries `eventId`, `v`, `organizationId`, `conversationId`, `channelAddress`, `intentId`, `groupId`, `locale`, and `capabilityList` — the rich-messaging capabilities the customer's device advertised, which is what to check before choosing a template.\n\nBoth arrive as an HTTP POST with a `Webhook-Id`, `Webhook-Timestamp`, and `Webhook-Signature` header. Any 2xx acknowledges; a non-2xx or a timeout is retried with backoff over several minutes and then abandoned. A `410 Gone` is terminal and disables delivery for the integration, so never return one by accident.\n\n## The rules this package implements\n\nVerification is easy to get subtly wrong, so all of it is handled here:\n\n- HMAC-SHA256 over `{Webhook-Id}.{Webhook-Timestamp}.{rawBody}`, where the key is the base64-decode of the signing secret **after** the `whsec_` prefix.\n- The HMAC covers the **exact request bytes**. Parsing and re-serializing the JSON changes key order or whitespace and breaks the signature — every adapter here passes raw bytes through.\n- `Webhook-Signature` may carry several space-delimited `v1,…` tokens during a key rotation. The delivery is accepted if **any** token matches, and unknown scheme versions are ignored rather than rejected.\n- Deliveries more than five minutes from now are rejected.\n- The envelope's `eventId` must match the `Webhook-Id` header; a mismatch means the body and the signature headers describe different events, and is rejected.\n- Retries reuse the same `Webhook-Id`, so dedupe on it.\n- Header lookups are case-insensitive, and any verification error is a rejection.\n\n## Quick start\n\n```ts\nimport { WebhookReceiver, MemoryReplayCache } from '@1440io/msp-webhooks';\n\nconst receiver = new WebhookReceiver({\n  secret: process.env.MSP_WEBHOOK_SECRET!,   // 'whsec_…', shown once at integration creation\n  replayCache: new MemoryReplayCache(),\n  on: {\n    'message.received': async (event, context) => {\n      console.log(context.id, event.conversationId, event.message.content?.kind);\n    },\n    'messaging_invitation.updated': async (event) => {\n      const { messagingInvitationId, status } = event.messagingInvitation;\n      console.log(messagingInvitationId, status);\n    },\n  },\n  onError: (error) => logger.warn({ error }, 'webhook rejected'),\n});\n\nconst result = await receiver.handle({ headers, body: rawBody });\n// result.status: 200 verified (or already handled) · 400 rejected · 500 your handler threw\n```\n\nThe status mapping is deliberate: a handler that throws returns 500 so the platform retries, while a duplicate returns 200 so it stops.\n\n## Adapters\n\n### Next.js App Router, Hono, Remix, Deno, Workers\n\n```ts\n// app/api/webhooks/1440/route.ts\nimport { createFetchWebhookHandler } from '@1440io/msp-webhooks/fetch';\n\nexport const POST = createFetchWebhookHandler({\n  secret: process.env.MSP_WEBHOOK_SECRET!,\n  on: { 'message.received': async (event) => { await enqueue(event); } },\n});\n```\n\n### Express\n\n```ts\nimport express from 'express';\nimport { createExpressWebhookHandler } from '@1440io/msp-webhooks/express';\n\nconst app = express();\n\n// Raw body for this route only — express.json() would destroy the signed bytes.\napp.post(\n  '/webhooks/1440',\n  express.raw({ type: '*/*' }),\n  createExpressWebhookHandler({\n    secret: process.env.MSP_WEBHOOK_SECRET!,\n    on: { 'message.received': async (event) => { await enqueue(event); } },\n  }),\n);\n\napp.use(express.json()); // everything else, as usual\n```\n\nIf a JSON parser has already consumed the body, the handler forwards a clear error to `next()` instead of failing verification for no visible reason.\n\n### Fastify\n\n```ts\nimport Fastify from 'fastify';\nimport { fastifyMspWebhooks } from '@1440io/msp-webhooks/fastify';\n\nconst app = Fastify();\n\n// Register in its own scope so the raw parser does not leak onto other routes.\nawait app.register(fastifyMspWebhooks, {\n  path: '/webhooks/1440',\n  secret: process.env.MSP_WEBHOOK_SECRET!,\n  on: { 'message.received': async (event) => { await enqueue(event); } },\n});\n```\n\n### AWS Lambda\n\n```ts\nimport { createLambdaWebhookHandler } from '@1440io/msp-webhooks/lambda';\n\nexport const handler = createLambdaWebhookHandler({\n  secret: process.env.MSP_WEBHOOK_SECRET!,\n  on: { 'message.received': async (event) => { await enqueue(event); } },\n});\n```\n\nHandles API Gateway payload formats 1.0 and 2.0 and Lambda Function URLs, base64 bodies included. A fresh execution environment starts with an empty in-memory replay cache, so back `replayCache` with DynamoDB or Redis if you need deduplication across invocations.\n\n## Verifying by hand\n\n```ts\nimport { WebhookVerifier } from '@1440io/msp-webhooks';\n\nconst verifier = new WebhookVerifier({ secret: process.env.MSP_WEBHOOK_SECRET! });\nconst { event, id, timestamp } = await verifier.verify({ headers, body: rawBody });\n```\n\nKeep one verifier around rather than constructing per request — it imports the HMAC key once.\n\n`verify()` throws `WebhookVerificationError` with a `code`: `missing_headers`, `malformed_timestamp`, `stale_timestamp`, `invalid_signature_header`, `invalid_signature`, `invalid_secret`, `invalid_payload`, or `duplicate`. Treat them all as rejections except `duplicate`, which means you have already handled the event — acknowledge it with a 2xx.\n\n## Rotating a secret\n\nPass both, and either verifies:\n\n```ts\nnew WebhookVerifier({ secret: [process.env.MSP_WEBHOOK_SECRET_OLD!, process.env.MSP_WEBHOOK_SECRET_NEW!] });\n```\n\n## Replay protection\n\n`MemoryReplayCache` is bounded and TTL'd (an hour by default), which is enough for a single long-lived process. Across instances, or anywhere the process is short-lived, implement the one-method `ReplayCache` interface over shared storage:\n\n```ts\nimport type { ReplayCache } from '@1440io/msp-webhooks';\n\nconst redisCache: ReplayCache = {\n  async seen(id) {\n    // SET NX returns null when the key already exists.\n    return (await redis.set(`webhook:${id}`, '1', 'EX', 3600, 'NX')) === null;\n  },\n};\n```\n\n## Narrowing events\n\n```ts\nimport { isMessageReceived, isInitiationUpdated } from '@1440io/msp-webhooks';\n\nif (isMessageReceived(event)) {\n  event.data.message; // narrowed to the inbound message\n}\n```\n\n`dataVersion` is date-pinned and changes additively, so pin the version you understand and ignore fields you do not recognize.\n\n## Handling customer replies\n\n`content` is a union tagged by `kind`, so TypeScript narrows it natively — no guard needed:\n\n```ts\nif (message.content?.kind === 'text') {\n  message.content.body;      // narrowed\n}\n```\n\nRedaction is a discriminated union, so one guard settles both fields:\n\n```ts\nimport { isVisible, isRedacted } from '@1440io/msp-webhooks';\n\nif (isVisible(message)) {\n  message.content.kind;      // non-null, no check needed\n} else if (isRedacted(message)) {\n  // A private form response: the body was withheld.\n}\n```\n\nEvery reader below accepts `null | undefined` and returns an empty result, so a redacted message needs no special casing.\n\nWhat the helpers add is normalization. The interactive payloads arrive in Apple's native shape — hyphenated keys, per-kind nesting, timestamps that are not RFC 3339 — and reading them by hand is where the bugs live:\n\n```ts\nimport {\n  textBody, selectedIds, selectedTitles, selectedTimeslot,\n  formAnswers, authenticationStatus, respondsTo, sessionOf,\n  isInteractiveResponse, isRedacted, isKind,\n} from '@1440io/msp-webhooks';\n\non: {\n  'message.received': async (event) => {\n    const { content } = event.message;\n\n    // A private form response arrives with its body withheld.\n    if (isRedacted(event.message)) return;\n\n    const body = textBody(content);\n    if (body !== null) console.log(body);\n\n    if (isInteractiveResponse(content)) {\n      const prompt = respondsTo(content);   // which rich message was answered\n      const session = sessionOf(content);\n\n      switch (content.kind) {\n        case 'amb.quick_reply_response':\n        case 'amb.list_picker_response':\n          console.log(selectedIds(content), selectedTitles(content));\n          break;\n        case 'amb.time_picker_response': {\n          const slot = selectedTimeslot(content);   // { id, startsAt: Date, durationSeconds }\n          break;\n        }\n        case 'amb.form_response':\n          console.log(formAnswers(content));        // keyed by page identifier\n          break;\n        case 'amb.authentication_response':\n          console.log(authenticationStatus(content));  // success | failure | cancel | unknown\n          break;\n      }\n    }\n\n    if (isKind(content, 'opt_out')) await suppress(event.conversationId);\n  },\n}\n```\n\n### Correlating a reply with its prompt\n\n`respondsTo(content)` returns the identifier of the rich message being answered, so a bot holding several prompts open knows which was tapped. It returns `null` when the channel made no correlation promise — custom iMessage apps carry none — so never assume it is present.\n\n### Two things production does that the spec does not describe\n\n**Reactions arrive as text.** A \"Liked\" reaction comes through as `kind: 'text'` with the body `\"Liked 1 Business Message\"`. The spec has no `tapback` kind, which matches the behaviour, so there is nothing to narrow to.\n\n**Time-picker times are not RFC 3339.** The schema pins them to `YYYY-MM-DDTHH:mm+0000` — no seconds, no colon in the offset. JavaScript's `Date` accepts it, so the problem stays hidden until the value reaches a stricter parser (`Temporal.Instant.from`, `date-fns/parseISO`, Go, Java, Python). `selectedTimeslot()` and `parseAppleTimestamp()` handle both forms and hand back a real `Date`.\n\n## Rotating a secret\n\nPass both, and either verifies:\n\n```ts\nnew WebhookVerifier({ secret: [process.env.MSP_WEBHOOK_SECRET_OLD!, process.env.MSP_WEBHOOK_SECRET_NEW!] });\n```\n\n## Replay protection\n\n`MemoryReplayCache` is bounded and TTL'd (an hour by default), which is enough for a single long-lived process. Across instances, or anywhere the process is short-lived, implement the one-method `ReplayCache` interface over shared storage:\n\n```ts\nimport type { ReplayCache } from '@1440io/msp-webhooks';\n\nconst redisCache: ReplayCache = {\n  async seen(id) {\n    // SET NX returns null when the key already exists.\n    return (await redis.set(`webhook:${id}`, '1', 'EX', 3600, 'NX')) === null;\n  },\n};\n```\n\n## Narrowing events\n\n```ts\nimport { isMessageReceived, isInitiationUpdated } from '@1440io/msp-webhooks';\n\nif (isMessageReceived(event)) {\n  event.data.message; // narrowed to the inbound message\n}\n```\n\n`dataVersion` is date-pinned and changes additively, so pin the version you understand and ignore fields you do not recognize.\n\n## Handling customer replies\n\nA message's `content` shape is decided by its sibling `messageType`, which\nTypeScript cannot narrow on its own — inside `if (message.messageType === 'interactive')`,\n`message.content.responseType` is still an error. These guards do it properly:\n\n```ts\nimport {\n  isTextMessage,\n  isInteractiveMessage,\n  isTapbackMessage,\n  isOptOutMessage,\n  selectedIds,\n  selectedTitles,\n  formValuesByPage,\n  respondsTo,\n} from '@1440io/msp-webhooks';\n\non: {\n  'message.received': async (event) => {\n    const { message } = event.data;\n\n    if (isTextMessage(message)) {\n      console.log(message.content.body);          // no cast\n    }\n\n    if (isInteractiveMessage(message)) {\n      // Which rich message was this a reply to?\n      const prompt = respondsTo(message);          // requestIdentifier, or null\n\n      switch (message.content.responseType) {\n        case 'quick_reply':\n        case 'list_picker':\n          console.log(selectedIds(message.content), selectedTitles(message.content));\n          break;\n        case 'time_picker':\n          console.log(message.content.selectedStartTime);\n          break;\n        case 'form':\n          console.log(formValuesByPage(message.content));\n          if (message.content.private) {\n            // Private form responses are access-restricted — do not log or forward.\n          }\n          break;\n      }\n    }\n\n    if (isTapbackMessage(message)) {\n      console.log(message.content.kind, 'on', message.content.targetMessageId);\n    }\n\n    if (isOptOutMessage(message)) {\n      await suppress(event.conversationId, message.content.reason);\n    }\n  },\n}\n```\n\n### Correlating a reply with its prompt\n\n`respondsTo(message)` returns the identifier of the rich message being answered, so a bot holding several prompts open at once knows which was tapped. Send a raw interactive payload with your own `requestIdentifier` and the platform preserves it:\n\n```ts\nconst requestIdentifier = uuidv7();\n\nawait client.messaging.sendRaw({\n  conversationId,\n  channel: 'amb',\n  messageType: 'quick_reply',\n  payload: {\n    type: 'interactive',\n    interactiveData: {\n      bid: AMB_INTERACTIVE_BID,\n      data: {\n        version: '1.0',\n        requestIdentifier,\n        'quick-reply': { summaryText: 'How did we do?', items },\n      },\n    },\n  },\n});\n```\n\n`respondsTo` returns `null` when the channel made no correlation promise — custom iMessage apps are documented as carrying none at all, so never assume it is present.\n\n### Two things production does that the spec does not describe\n\nBoth confirmed against the live API, and both will bite silently.\n\n**Time-picker times are not RFC 3339.** `selectedStartTime` is declared as a `date-time`, but what arrives is Apple's basic format — `2026-08-25T23:55+0000`, with no seconds and no colon in the offset. JavaScript's `Date` happens to accept it, so `new Date(value)` works and the problem stays hidden until the value reaches something stricter: `Temporal.Instant.from`, `date-fns/parseISO`, Go's `time.RFC3339`, Java's `Instant.parse`, and Python's `fromisoformat` all reject it.\n\n```ts\nimport { selectedStartTime, parseAppleTimestamp } from '@1440io/msp-webhooks';\n\nconst when = selectedStartTime(message.content);   // Date | null, handles both forms\nwhen?.toISOString();                               // safe to hand downstream\n```\n\n**Reactions arrive as text, not tapbacks.** A \"Liked\" reaction on AMB has been observed arriving as `messageType: 'text'` with the body `\"Liked 1 Business Message\"` — not as `messageType: 'tapback'` with a structured `kind` and `targetMessageId`. `isTapbackMessage()` is there for when a channel does send a structured one, but do not build reaction handling on it alone; a text body matching that prose is what you will actually receive on AMB today.\n\n## Replying to a message\n\nVerification is inbound-only — the signing secret is never used to send. To reply, use [`@1440io/msp-api`](../msp-api) with the event's `conversationId` and your integration API key.\n\n```ts\nimport { MspClient } from '@1440io/msp-api';\n\nconst client = new MspClient({ apiKey: process.env.MSP_API_KEY! });\n\non: {\n  'message.received': async (event) => {\n    await client.messaging.sendText({\n      conversationId: event.conversationId,\n      body: 'Thanks — an agent will be with you shortly.',\n    });\n  },\n}\n```\n\nAcknowledge fast and do the work asynchronously where you can: the platform retries on a timeout, and a slow handler turns into duplicate deliveries.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}