Official SDK for integrating with Mukkle's AI content generation platform
npm install @mukkle/sdkOfficial 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.
- ๐ 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
``bash`
npm install @mukkle/sdkor
yarn add @mukkle/sdkor
pnpm add @mukkle/sdk
`typescript
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)
`
`typescript
// 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)
`
`typescript
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
body: req.body,
})
res.status(response.status)
res.set(response.headers)
res.json(response.body)
})
`
The SDK is organized into focused modules:
`typescript
// 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'
`
`typescript
const handler = createHandler({
// Required
getApiKey: () => string | Promise
getSigningSecret: () => string | Promise
onReceive: (post, ctx) => Promise
// Optional hooks
onHealthCheck: () => Promise
onError: (error, ctx) => Promise
validateRequest: (request) => Promise
transformPost: (post) => MukklePost | Promise
// 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'],
},
})
`
Your onReceive handler receives context about the request:
`typescript
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 }
}
`
The SDK includes comprehensive testing utilities:
`typescript
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)
})
})
`
| 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 |
The SDK uses typed errors with error codes:
`typescript
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
}
}
`
| 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 |
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
`typescript
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 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 MukklePost type
- You own the data - No opinionated abstractions between you and your database
`typescript
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,
}
},
})
Use post.meta.contentHash to implement idempotent saves:
`typescript
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' }`
}
For testing, the SDK provides a simple in-memory storage utility:
`typescript
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.
If you're using Python, Go, Ruby, PHP, or another language, you can integrate directly with Mukkle webhooks:
| 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 |
`pythonPython 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)
`
`json`
{
"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"
}
}
Return a JSON response with:
`json``
{
"success": true,
"data": {
"url": "/blog/post-title",
"postId": "your-internal-id"
}
}
- Node.js >= 18.0.0
- TypeScript >= 5.0 (recommended)
MIT