{"_id":"@1440io/msp-api","_rev":"2-5d00e2e37e9e373c641ec35918a218f1","name":"@1440io/msp-api","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@1440io/msp-api","version":"0.1.0","keywords":["1440","apple-messages-for-business","amb","msp","messaging","api-client"],"author":{"name":"1440"},"license":"MIT","_id":"@1440io/msp-api@0.1.0","maintainers":[{"name":"jtjessup","email":"jon@1440.io"}],"homepage":"https://github.com/1440io/msp-sdk/tree/main/packages/msp-api#readme","bugs":{"url":"https://github.com/1440io/msp-sdk/issues"},"dist":{"shasum":"899205e304288fd403fc12b8a57f0f0fc87ffa18","tarball":"https://registry.npmjs.org/@1440io/msp-api/-/msp-api-0.1.0.tgz","fileCount":10,"integrity":"sha512-dTQ/It9flSMRk7pm7q+kUuoZcuOZWpW6+xbNMkhu9BHHfmxy3bv6fP81SP+kg8LUA//BuvP1zubUzSrynN1+1A==","signatures":[{"sig":"MEUCIQDImsSbrRwXdkI7c3fBZrZ6vIug1iJ4UUOFRjX5uFdUxQIgagaGRH9DEsysFAi7DcuZPecpg+SwWgGy6fQfbIFxJRA=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":359765},"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"},"./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-api"},"_npmVersion":"11.14.1","description":"Typed client for the 1440 Apple Messages for Business MSP API — auto JWT exchange, cursor pagination, typed errors.","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-api_0.1.0_1787704734231_0.3059738091673372","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@1440io/msp-api","version":"0.2.0","description":"Typed client for the 1440 Apple Messages for Business MSP API — auto JWT exchange, cursor pagination, typed errors.","license":"MIT","type":"module","sideEffects":false,"engines":{"node":">=20.10"},"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./package.json":"./package.json"},"publishConfig":{"access":"public"},"scripts":{"build":"tsup","typecheck":"tsc --noEmit -p tsconfig.json"},"keywords":["1440","apple-messages-for-business","amb","msp","messaging","api-client"],"dependencies":{"@1440io/msp-types":"0.2.0"},"devDependencies":{"tsup":"^8.5.1","typescript":"^5.7.2","vitest":"^4.1.11"},"repository":{"type":"git","url":"git+https://github.com/1440io/msp-sdk.git","directory":"packages/msp-api"},"homepage":"https://github.com/1440io/msp-sdk/tree/main/packages/msp-api#readme","bugs":{"url":"https://github.com/1440io/msp-sdk/issues"},"author":{"name":"1440"},"gitHead":"e7c50bc7a1cb758fe903bbd6cb62068f640de86b","_id":"@1440io/msp-api@0.2.0","_nodeVersion":"22.11.0","_npmVersion":"11.14.1","dist":{"integrity":"sha512-3uTCsnlASSCtMesCWDuOwqCymJCoEdfqjqmLY027zGo8hCQEWKkqbnYWWAfDOxVRxSPGMzXZ+8NcMb0x6jG3zg==","shasum":"132713d5c14c3732155a4bd2ddd67331b3968029","tarball":"https://registry.npmjs.org/@1440io/msp-api/-/msp-api-0.2.0.tgz","fileCount":10,"unpackedSize":330763,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIARyrZtTmKRrqdA/mKG6Bd9PAtuS3sH5RJeZqaBIH+WIAiBl6caDow6AnQSPTkOuta26pC7Wp8/yJrxHzFUFLkuRrA=="}]},"_npmUser":{"name":"jtjessup","email":"jon@1440.io"},"directories":{},"maintainers":[{"name":"jtjessup","email":"jon@1440.io"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/msp-api_0.2.0_1789008801938_0.3087721828369452"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-26T00:38:53.984Z","modified":"2026-09-10T02:53:22.257Z","0.1.0":"2026-08-26T00:38:54.428Z","0.2.0":"2026-09-10T02:53:22.077Z"},"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-api#readme","keywords":["1440","apple-messages-for-business","amb","msp","messaging","api-client"],"repository":{"type":"git","url":"git+https://github.com/1440io/msp-sdk.git","directory":"packages/msp-api"},"description":"Typed client for the 1440 Apple Messages for Business MSP API — auto JWT exchange, cursor pagination, typed errors.","maintainers":[{"name":"jtjessup","email":"jon@1440.io"}],"readme":"# @1440io/msp-api\n\nTyped client for the [1440](https://1440.io) Apple Messages for Business MSP API.\n\n```bash\nnpm install @1440io/msp-api\n```\n\nNode 20.10+. Ships ESM and CJS. Zero runtime dependencies beyond `@1440io/msp-types`.\n\n## Authenticating\n\nThe API uses a long-lived integration API key (`msp_…`) to mint a short-lived (~15 minute) business access JWT. Hand the client the key and it handles the rest — minting on first use, caching until a minute before expiry, collapsing concurrent refreshes onto one exchange, and retrying once if a token is rejected early.\n\n```ts\nimport { MspClient } from '@1440io/msp-api';\n\nconst client = new MspClient({ apiKey: process.env.MSP_API_KEY! });\n```\n\nAn API key is a server-side credential. Never ship one to a browser.\n\nOther ways in:\n\n```ts\n// A pre-minted JWT you manage yourself.\nnew MspClient({ token: process.env.MSP_TOKEN! });\n\n// A token you fetch from your own service. Return `expiresAt` and the client caches it.\nnew MspClient({\n  getToken: async () => {\n    const { token, expiresAt } = await myVault.getMspToken();\n    return { token, expiresAt };\n  },\n});\n\n// From MSP_API_KEY / MSP_TOKEN / MSP_BASE_URL.\nMspClient.fromEnv();\n```\n\n### Client options\n\n| Option | Default | Notes |\n| --- | --- | --- |\n| `apiKey` / `token` / `getToken` | — | Exactly one is required. |\n| `baseUrl` | `https://1440.cloud` | Point at a sandbox or staging host. |\n| `timeoutMs` | `30000` | Per request. Uploads default to `120000`. |\n| `retry` | `{ maxRetries: 2, initialDelayMs: 500, maxDelayMs: 8000 }` | Applies to GETs and idempotent writes. |\n| `refreshSkewMs` | `60000` | Refresh this long before the token expires. |\n| `headers` | — | Merged into every request. |\n| `fetch` | global `fetch` | Swap in a proxy-aware fetch, or a stub in tests. |\n| `onRequest` / `onResponse` | — | Called per attempt, retries included. Good hooks for logging and metrics. |\n\n## Conversations\n\n`list()` returns a paginator. Await it for one page, `for await` over it to walk them all.\n\n```ts\nconst page = await client.conversations.list({ count: 50, status: 'active' });\npage.conversations; // ConversationListItem[]\npage.nextCursor;    // string | null\n\nfor await (const conversation of client.conversations.list({ platform: 'amb' })) {\n  // Cursors are followed for you.\n}\n\nconst recent = await client.conversations.list().toArray(200); // stop after 200\n\nconst detail = await client.conversations.get(conversationId, { count: 50 });\ndetail.messages;\n\nawait client.conversations.updateName(conversationId, {\n  firstName: 'Ada',\n  lastName: 'Lovelace',\n});\n```\n\n## Sending\n\nEvery send carries a `requestMessageId` idempotency key. Omit it and the client mints a UUIDv7; a replay returns the original result with `duplicate: true` rather than sending twice.\n\n```ts\nawait client.messaging.sendText({\n  conversationId,\n  body: 'Your appointment is confirmed for 2pm tomorrow.',\n  subject: 'Appointment confirmed',   // optional\n});\n\nconst { mediaAssetId } = await client.media.upload({\n  body: await fs.readFile('receipt.jpg'),\n  filename: 'receipt.jpg',\n  contentType: 'image/jpeg',\n  targetChannel: 'amb',\n});\n\nawait client.messaging.sendText({\n  conversationId,\n  body: 'Here is your receipt.',\n  attachmentIds: [mediaAssetId],\n});\n\nawait client.messaging.sendTemplate({\n  conversationId,\n  templateId,\n  variables: {\n    customerName: 'Ada',\n    slots: [{ id: 'slot-1', startTime: '2026-08-26T14:00:00Z', durationSeconds: 1800 }],\n  },\n});\n\n// Channel-native passthrough, when a template will not do. `content` is a\n// typed union tagged by `kind`; quick replies take 2–5 items.\nawait client.messaging.sendRaw({\n  conversationId,\n  content: {\n    kind: 'amb.quick_reply',\n    data: { 'quick-reply': { summaryText: 'How did we do?', items } },\n  },\n});\n\n// Start an OAuth flow on the device. The outcome arrives as an\n// `amb.authentication_response` on the message.received webhook.\nawait client.messaging.sendAuthentication({\n  conversationId,\n  templateId: authTemplateId,\n  state: `session-${sessionId}`,\n});\n```\n\nSupply your own `requestMessageId` when a retry might span a process restart — store it with the work item, and a redelivery collapses onto the original send.\n\n## Messaging invitations\n\nReaching a customer first. Creation is asynchronous: an invitation starts at `submitting` and lands on `accepted`, `declined`, `provider_rejected`, or `error`. Watch the `messaging_invitation.updated` webhook instead of polling where you can.\n\n```ts\nconst invitation = await client.invitations.create({\n  phoneNumber: '+15551234567',\n  targetFirstName: 'Ada',\n  targetAgentStatus: 'live',\n  branding: { brandName: 'Acme Dental', brandLogoPngBase64: logo },\n});\n\nfor await (const item of client.invitations.list({ status: 'submitted' })) {\n  // …\n}\n```\n\nA `422` carrying `messaging_invitation_unavailable` means the capability is not enabled for the org, not that your request was wrong.\n\n## Media\n\n```ts\nconst { mediaAssetId } = await client.media.upload({\n  body: bytes,              // Uint8Array | ArrayBuffer | Blob | ReadableStream | string\n  filename: 'photo.jpg',\n  contentType: 'image/jpeg',\n  targetChannel: 'amb',\n});\n\n// Read URLs take an *attachment* id, from message history or an inbound\n// webhook — not the mediaAssetId you just uploaded.\nconst { url, expiresAt } = await client.media.getAccessUrl(attachment.id);\n```\n\nThe 100 MiB ceiling is checked client-side whenever the length is known, so an oversized upload fails before the transfer rather than after it. Pass `contentLength` when streaming to get the same early check.\n\n**`mediaAssetId` is not an `attachmentId`.** The spec says to reference the uploaded `mediaAssetId` when minting a read URL; measured against production it is a `404`, before and after the asset is attached to a message. Uploading creates a media asset, and attaching it to a message mints a separate attachment with its own id. Take that id from the conversation's message history or from an inbound webhook.\n\n## Templates and channels\n\n```ts\nfor await (const template of client.templates.list({ templateType: 'quick_reply' })) {\n  // Published templates, ready to send.\n}\n\nconst channels = await client.channels.list();\n```\n\n## Admin\n\nBusiness-admin routes live under `client.admin` and require the `admin` membership tier.\n\n```ts\nawait client.admin.settings();\nawait client.admin.channels.list();\nawait client.admin.channels.tiktokStatus();\n\nconst draft = await client.admin.templates.create({ name: 'Appointment picker', definition, slotBindings: [] });\nawait client.admin.templates.publish(draft.id);\nawait client.admin.templates.uploadAsset({\n  channel: 'amb',\n  usage: 'rich_image_200',\n  displayName: 'Hero image',\n  file: pngBytes,\n});\n```\n\nMembers, sandboxes, integrations, permission sets, and the business context left the documented API surface and were removed from the client in 0.2.0. Some still answer on the server; call them with `fetch` if you need them, knowing they may be withdrawn.\n\n## Errors\n\nEvery non-2xx becomes a typed error carrying the server's canonical `{ error, code }` envelope.\n\n```ts\nimport { MspApiError, MspNotFoundError, MspRateLimitError, isMspApiError } from '@1440io/msp-api';\n\ntry {\n  await client.messaging.sendText({ conversationId, body: 'Hi' });\n} catch (error) {\n  if (error instanceof MspNotFoundError) {\n    // The conversation does not belong to this business.\n  } else if (error instanceof MspRateLimitError) {\n    await sleep(error.retryAfterMs ?? 1000);\n  } else if (isMspApiError(error)) {\n    error.status;   // 422\n    error.code;     // machine-readable code, when the response carries one\n    error.issues;   // [{ path, message }] on a validation_failed send\n    error.reasons;  // rich reason codes, on template and asset routes\n  }\n}\n```\n\n`MspApiError` subclasses: `MspValidationError` (400/422), `MspAuthenticationError` (401), `MspPermissionError` (403), `MspNotFoundError` (404), `MspConflictError` (409), `MspPayloadTooLargeError` (413), `MspRateLimitError` (429), `MspServerError` (5xx). Transport failures raise `MspTimeoutError` or `MspConnectionError`; bad arguments raise `MspConfigError`.\n\n## Retries\n\nGETs and idempotent writes retry on 408, 429, 5xx, and connection failures, with exponential backoff, full jitter, and `Retry-After` honoured. A retried send reuses its original `requestMessageId`, so a retry can never double-send. Non-idempotent calls are never retried. Set `retry: { maxRetries: 0 }` to opt out.\n\n## Testing against it\n\nInject `fetch` and no network is touched:\n\n```ts\nconst client = new MspClient({\n  token: 'test',\n  fetch: async (url, init) => new Response(JSON.stringify({ channels: [] }), { status: 200 }),\n});\n```\n\n## License\n\nMIT\n","readmeFilename":"README.md"}