@mukkle/sdk
v1.0.0
Published
Official SDK for integrating with Mukkle's AI content generation platform
Downloads
271
Maintainers
Readme
@mukkle/sdk
Official JavaScript/TypeScript SDK for receiving webhooks from Mukkle's AI content generation platform.
Using a different language? Mukkle webhooks use standard HMAC-SHA256 signatures. See Webhook Integration Without SDK for details on integrating with any language.
Features
- 🚀 Framework Agnostic - Works with Next.js, Express, Cloudflare Workers, Deno, Bun, and more
- 🔐 Secure by Default - HMAC-SHA256 signature verification, timestamp validation
- 📦 Modular Design - Use only what you need via subpath imports
- 🧪 Testing Utilities - Mocks, fixtures, and helpers for testing your integration
- 🗄️ Bring Your Own Storage - No opinionated storage layer; use Prisma, Drizzle, MongoDB, or anything else
- 📝 TypeScript First - Full type safety with excellent IntelliSense support
Installation
npm install @mukkle/sdk
# or
yarn add @mukkle/sdk
# or
pnpm add @mukkle/sdkQuick Start
Basic Webhook Handler
import { createHandler, createAdapter } from '@mukkle/sdk'
const handler = createHandler({
// Required: Provide your credentials via functions (supports async)
getApiKey: () => process.env.MUKKLE_API_KEY!,
getSigningSecret: () => process.env.MUKKLE_SIGNING_SECRET!,
// Required: Handle received posts
onReceive: async (post, ctx) => {
// Save to your database
const saved = await db.posts.create({
title: post.title,
slug: post.slug,
content: post.content,
excerpt: post.excerpt,
authorId: post.authorId,
publishedAt: post.publishedAt,
tags: post.tags,
draft: post.draft,
})
// Return the URL where the post is accessible
return {
url: `/blog/${saved.slug}`,
postId: saved.id,
action: 'created',
}
},
})
// Create a Web Standard adapter (works with Cloudflare Workers, Next.js App Router, etc.)
export default createAdapter(handler)Next.js App Router
// app/api/mukkle/route.ts
import { createHandler, createRouteHandlers } from '@mukkle/sdk'
const handler = createHandler({
getApiKey: () => process.env.MUKKLE_API_KEY!,
getSigningSecret: () => process.env.MUKKLE_SIGNING_SECRET!,
onReceive: async (post) => {
await db.posts.create(post)
return { url: `/blog/${post.slug}` }
},
})
// Export both GET (health check) and POST (webhook) handlers
export const { GET, POST } = createRouteHandlers(handler)Express.js (Manual Integration)
import express from 'express'
import { createHandler } from '@mukkle/sdk'
const app = express()
app.use(express.text({ type: 'application/json' }))
const handler = createHandler({
getApiKey: () => process.env.MUKKLE_API_KEY!,
getSigningSecret: () => process.env.MUKKLE_SIGNING_SECRET!,
onReceive: async (post) => {
await db.posts.create(post)
return { url: `/blog/${post.slug}` }
},
})
app.all('/api/mukkle', async (req, res) => {
const response = await handler.handle({
method: req.method,
headers: req.headers as Record<string, string>,
body: req.body,
})
res.status(response.status)
res.set(response.headers)
res.json(response.body)
})Subpath Imports
The SDK is organized into focused modules:
// Core types and utilities (zero dependencies)
import { MukklePost, MukkleError, verifySignature } from '@mukkle/sdk/core'
// Webhook receiver
import { createHandler } from '@mukkle/sdk/receiver'
// Testing utilities
import { createTestPost, simulateWebhook, createMemoryStorage } from '@mukkle/sdk/testing'
// HTTP adapters
import { createAdapter } from '@mukkle/sdk/adapters/http/generic'Configuration
Handler Options
const handler = createHandler({
// Required
getApiKey: () => string | Promise<string>,
getSigningSecret: () => string | Promise<string>,
onReceive: (post, ctx) => Promise<ReceiveResult>,
// Optional hooks
onHealthCheck: () => Promise<HealthResult>,
onError: (error, ctx) => Promise<void>,
validateRequest: (request) => Promise<ValidationResult>,
transformPost: (post) => MukklePost | Promise<MukklePost>,
// Optional configuration
config: {
timestampTolerance: 300, // seconds (default: 5 minutes)
requireSignature: true, // default: true
requireApiKey: true, // default: true
maxBodySize: 10 * 1024 * 1024, // default: 10MB
allowedContentTypes: ['application/json'],
allowedMethods: ['POST'],
},
})Receive Context
Your onReceive handler receives context about the request:
onReceive: async (post, ctx) => {
console.log(ctx.requestId) // Unique request ID
console.log(ctx.timestamp) // When received (Date)
console.log(ctx.isRetry) // Whether this is a retry
console.log(ctx.retryCount) // Number of previous attempts
console.log(ctx.headers) // All request headers
return { url: '/blog/' + post.slug }
}Testing Your Integration
The SDK includes comprehensive testing utilities:
import { describe, it, expect } from 'vitest'
import {
createTestPost,
createTestRequest,
simulateWebhook,
createMemoryStorage,
createMockDependencies,
} from '@mukkle/sdk/testing'
import { createHandler } from '@mukkle/sdk'
describe('My webhook handler', () => {
it('saves posts correctly', async () => {
const storage = createMemoryStorage()
const handler = createHandler({
getApiKey: () => 'test-key',
getSigningSecret: () => 'test-secret',
onReceive: async (post) => {
const saved = await storage.save(post)
return { url: `/blog/${saved.slug}`, postId: saved.id }
},
})
// simulateWebhook creates properly signed requests
const result = await simulateWebhook(
handler,
createTestPost({ title: 'Test Post' }),
{ secret: 'test-secret', apiKey: 'test-key' }
)
expect(result.status).toBe(200)
expect(storage.count()).toBe(1)
expect(storage.last()?.title).toBe('Test Post')
})
it('rejects invalid signatures', async () => {
const handler = createHandler({
getApiKey: () => 'test-key',
getSigningSecret: () => 'test-secret',
onReceive: async (post) => ({ url: `/blog/${post.slug}` }),
})
const result = await simulateWebhook(
handler,
createTestPost(),
{ secret: 'wrong-secret', apiKey: 'test-key' } // Wrong secret!
)
expect(result.status).toBe(401)
})
})Testing Utilities
| Utility | Description |
|---------|-------------|
| createTestPost(overrides?) | Create a valid MukklePost with test data |
| createTestPosts(count, overrides?) | Create multiple test posts |
| createTestRequest(post, options) | Create a signed HandlerRequest |
| simulateWebhook(handler, post, options) | Send a test webhook to your handler |
| createMemoryStorage() | In-memory storage for test assertions |
| createMockDependencies(options) | Create mock handler dependencies |
Error Handling
The SDK uses typed errors with error codes:
import { MukkleError, isMukkleError } from '@mukkle/sdk'
try {
// ... handler code
} catch (error) {
if (isMukkleError(error)) {
console.log(error.code) // e.g., 'SIGNATURE_INVALID'
console.log(error.statusCode) // e.g., 401
console.log(error.retryable) // e.g., false
console.log(error.message) // Human-readable message
}
}Error Codes
| Code | Status | Retryable | Description |
|------|--------|-----------|-------------|
| SIGNATURE_INVALID | 401 | No | Signature verification failed |
| SIGNATURE_EXPIRED | 401 | No | Timestamp outside tolerance |
| API_KEY_INVALID | 401 | No | API key doesn't match |
| API_KEY_MISSING | 401 | No | No API key provided |
| PAYLOAD_INVALID | 400 | No | Invalid post data |
| PAYLOAD_TOO_LARGE | 413 | No | Body exceeds max size |
| CONTENT_TYPE_INVALID | 415 | No | Wrong content type |
| METHOD_NOT_ALLOWED | 405 | No | Wrong HTTP method |
| STORAGE_ERROR | 500 | Yes | Database/storage error |
| HANDLER_ERROR | 500 | Yes | Your handler threw |
| TIMEOUT | 504 | Yes | Operation timed out |
| RATE_LIMITED | 429 | Yes | Too many requests |
Security
The SDK implements several security measures:
- HMAC-SHA256 Signatures - Every webhook is signed
- Timestamp Validation - Prevents replay attacks (default: 5 minute window)
- Constant-Time Comparison - Prevents timing attacks
- API Key Verification - Additional authentication layer
Verify Signatures Manually
import { verifySignature, createSignature } from '@mukkle/sdk/core'
// Verify an incoming signature
const result = verifySignature({
payload: body,
signature: headers['x-mukkle-signature'],
secret: signingSecret,
timestamp: parseInt(headers['x-mukkle-timestamp']),
tolerance: 300, // 5 minutes
})
if (!result.valid) {
console.error(result.error.code) // 'SIGNATURE_INVALID' or 'SIGNATURE_EXPIRED'
}
// Create a signature (for testing)
const signature = createSignature(body, secret)Storage (Your Responsibility)
Storage is intentionally not part of this SDK. The SDK focuses on webhook verification and parsing - how you store posts is entirely up to you.
This design is intentional:
- You know your stack - Prisma, Drizzle, raw SQL, MongoDB, file system, whatever works for your project
- You control the schema - Add any fields you need beyond the core
MukklePosttype - You own the data - No opinionated abstractions between you and your database
Example: Storing Posts
import { createHandler } from '@mukkle/sdk'
import { prisma } from './db' // Your database client
const handler = createHandler({
getApiKey: () => process.env.MUKKLE_API_KEY!,
getSigningSecret: () => process.env.MUKKLE_SIGNING_SECRET!,
onReceive: async (post, ctx) => {
// Use YOUR database, YOUR way
const saved = await prisma.post.create({
data: {
title: post.title,
slug: post.slug,
content: post.content,
excerpt: post.excerpt,
authorId: post.authorId,
publishedAt: new Date(post.publishedAt),
tags: post.tags,
draft: post.draft,
// Add any custom fields you need
sourceId: post.meta.sourceId,
contentHash: post.meta.contentHash,
receivedAt: ctx.timestamp,
},
})
return {
url: `/blog/${saved.slug}`,
postId: saved.id,
}
},
})Idempotency
Use post.meta.contentHash to implement idempotent saves:
onReceive: async (post) => {
// Check if we already have this exact content
const existing = await db.posts.findFirst({
where: { contentHash: post.meta.contentHash }
})
if (existing) {
return { url: `/blog/${existing.slug}`, postId: existing.id, action: 'skipped' }
}
const saved = await db.posts.create({ data: post })
return { url: `/blog/${saved.slug}`, postId: saved.id, action: 'created' }
}Testing with In-Memory Storage
For testing, the SDK provides a simple in-memory storage utility:
import { createMemoryStorage, createTestPost, simulateWebhook } from '@mukkle/sdk/testing'
const storage = createMemoryStorage()
const handler = createHandler({
getApiKey: () => 'test-key',
getSigningSecret: () => 'test-secret',
onReceive: async (post) => {
const saved = await storage.save(post)
return { url: `/blog/${saved.slug}`, postId: saved.id }
},
})
// After your test
expect(storage.count()).toBe(1)
expect(storage.last()?.title).toBe('My Post')Note:
createMemoryStorage()is for testing only. In production, use your actual database.
Webhook Integration Without SDK
If you're using Python, Go, Ruby, PHP, or another language, you can integrate directly with Mukkle webhooks:
Webhook Headers
| Header | Description |
|--------|-------------|
| X-Mukkle-Api-Key | Your API key for verification |
| X-Mukkle-Timestamp | Unix timestamp (seconds) |
| X-Mukkle-Signature | HMAC-SHA256 signature |
| X-Mukkle-Request-Id | Unique request ID for tracing |
Signature Verification
# Python example
import hmac
import hashlib
import time
def verify_signature(body: str, signature: str, timestamp: str, secret: str, tolerance: int = 300) -> bool:
# Check timestamp is recent (prevent replay attacks)
now = int(time.time())
if abs(now - int(timestamp)) > tolerance:
return False
# Compute expected signature
message = f"{timestamp}.{body}"
expected = hmac.new(
secret.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(expected, signature)Payload Format
{
"title": "Post Title",
"slug": "post-title",
"content": "# Markdown content...",
"excerpt": "Short excerpt...",
"author_id": "user-123",
"author_name": "Author Name",
"published_at": "2024-01-31T12:00:00Z",
"target_post_id": null,
"tags": ["tag1", "tag2"],
"draft": false,
"meta": {
"description": "SEO description",
"word_count": 500,
"source_id": "content-123",
"content_hash": "sha256-hash-for-deduplication"
}
}Expected Response
Return a JSON response with:
{
"success": true,
"data": {
"url": "/blog/post-title",
"postId": "your-internal-id"
}
}Requirements
- Node.js >= 18.0.0
- TypeScript >= 5.0 (recommended)
License
MIT
