@ryterr/client
v0.1.0
Published
TypeScript client for the Ryterr Content API and webhooks. Auto-publish AI-generated blog posts to any JavaScript or TypeScript site.
Maintainers
Readme
@ryterr/client
TypeScript client for the Ryterr Content API. Auto-publish AI-generated blog posts to any JavaScript or TypeScript site.
Install
pnpm add @ryterr/client
# or npm install @ryterr/client
# or yarn add @ryterr/clientThe package is published on npm. Full integration guides and examples live at https://ryterr.com/integrations.
Requires Node 18.17+, Bun, or any runtime with fetch and crypto.subtle (Cloudflare Workers, Deno Deploy, modern browsers).
Quickstart
1. Get an API key
In your Ryterr dashboard, go to Settings > API keys and create a new key. For
webhooks, choose the Webhook type and set your site's receiver URL. Ryterr mints
the key and shows it once, so copy it before closing the dialog and store it as
RYTERR_API_KEY in your site. The same key works for both webhook signature
verification and Content API reads (Bearer auth).
If your site only needs to pull posts on demand and does not use webhooks, create a Content API key instead.
2. Fetch posts on your site
// app/blog/page.tsx (Next.js App Router)
import { Ryterr } from "@ryterr/client"
const ryterr = new Ryterr({ apiKey: process.env.RYTERR_API_KEY! })
export default async function BlogIndex() {
const { data: posts } = await ryterr.posts.list({ limit: 20 })
return (
<ul>
{posts.map((p) => (
<li key={p.id}>
<a href={`/blog/${p.slug}`}>{p.title}</a>
<p>{p.excerpt}</p>
</li>
))}
</ul>
)
}// app/blog/[slug]/page.tsx
import { Ryterr } from "@ryterr/client"
import { notFound } from "next/navigation"
const ryterr = new Ryterr({ apiKey: process.env.RYTERR_API_KEY! })
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await ryterr.posts.get(slug)
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
{post.featuredImageUrl ? (
<img
src={post.featuredImageUrl}
alt={post.featuredImageAlt ?? post.title}
/>
) : null}
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
)
}3. Get notified when posts publish
Create a webhook receiver that validates the signature and revalidates your blog routes.
// app/api/ryterr-webhook/route.ts (Next.js App Router)
import { verifyWebhook, InvalidSignatureError } from "@ryterr/client"
import { revalidatePath } from "next/cache"
export async function POST(req: Request) {
try {
const event = await verifyWebhook(req, process.env.RYTERR_API_KEY!)
if (event.type === "post.published") {
revalidatePath("/blog")
revalidatePath(`/blog/${event.data.post.slug}`)
}
if (event.type === "post.updated") {
revalidatePath("/blog")
revalidatePath(`/blog/${event.data.post.slug}`)
if (event.data.previousSlug) {
revalidatePath(`/blog/${event.data.previousSlug}`)
}
}
if (event.type === "post.deleted") {
revalidatePath("/blog")
revalidatePath(`/blog/${event.data.slug}`)
}
return Response.json({ ok: true })
} catch (err) {
if (err instanceof InvalidSignatureError) {
return new Response("bad signature", { status: 401 })
}
throw err
}
}In Ryterr, make sure the webhook connection points to this exact URL: https://your-site.com/api/ryterr-webhook.
Test the connection from the Ryterr dashboard: open Settings > Connections, expand your webhook connection, and click "Send test event". You will see an immediate result (HTTP status) and a new row will appear in the Recent deliveries table. Check your server logs for the signed POST. Production publish events use the exact same signature and payload format.
That's it. Your blog stays in sync automatically.
Ryterr returns only posts published to the authenticated connection. If you unpublish a post from that connection, it disappears from list and detail API responses. Republish makes it visible again with a fresh updatedAt.
API reference
new Ryterr(options)
const ryterr = new Ryterr({
apiKey: string, // required
baseUrl?: string, // default: "https://ryterr.com"
timeout?: number, // ms, default: 15000
fetch?: typeof fetch, // override fetch impl (testing, Workers)
})ryterr.posts.list(params?)
List published posts, newest first.
const { data, next_cursor, has_more } = await ryterr.posts.list({
limit: 20, // 1..100, default 20
cursor: "<opaque>", // pagination cursor
since: "2026-01-01T00:00:00Z", // ISO timestamp filter
category: "Tutorials", // exact-match category filter
})Returns { data: PostSummary[], next_cursor: string | null, has_more: boolean }. Iterate with cursor for full enumeration:
let cursor: string | undefined
do {
const page = await ryterr.posts.list({ limit: 100, cursor })
for (const post of page.data) {
// ...
}
cursor = page.next_cursor ?? undefined
} while (cursor)ryterr.posts.get(slug)
Fetch one post by slug. Returns Post | null.
const post = await ryterr.posts.get("my-post-slug")
if (!post) return notFound()
console.log(post.body) // markdown
console.log(post.html) // sanitized HTMLryterr.posts.getById(id)
Fetch one post by Ryterr's internal ID. Use when you only have the ID from a webhook event (event.data.post.id).
const post = await ryterr.posts.getById(event.data.post.id)verifyWebhook(request, signingSecret, options?)
Validate a webhook signature and return the typed event.
import { verifyWebhook } from "@ryterr/client"
const event = await verifyWebhook(req, process.env.RYTERR_API_KEY!, {
toleranceSeconds: 300, // default 5 min
})
// event is one of: PostPublishedEvent | PostUpdatedEvent | PostDeletedEventImportant: do not consume the request body before calling this. The function reads request.text() to compute the HMAC. If you need the body twice, clone the request first:
const event = await verifyWebhook(req.clone(), secret)
const body = await req.text() // body still availableErrors
All errors thrown by the SDK extend RyterrError. Catch the base class for generic handling, or branch by instanceof for fine-grained recovery.
import {
RyterrAuthError,
RyterrNotFoundError,
RyterrRateLimitError,
RyterrServerError,
RyterrTimeoutError,
RyterrValidationError,
InvalidSignatureError,
ExpiredTimestampError,
MalformedPayloadError,
} from "@ryterr/client"
try {
await ryterr.posts.list()
} catch (err) {
if (err instanceof RyterrRateLimitError) {
await sleep((err.retryAfterSeconds ?? 60) * 1000)
// retry...
} else if (err instanceof RyterrAuthError) {
// bad API key - refresh and re-try
} else if (err instanceof RyterrServerError) {
// 5xx - retry with exponential backoff
}
}Every error carries:
.code- machine-readable string (e.g."rate_limited","post_not_found").message- human-readable.requestId- server-supplied request ID; quote this in support requests.statusCode- HTTP status (where applicable)
Webhook signature scheme
Ryterr signs webhook payloads with a timestamped HMAC scheme:
X-Ryterr-Signature: t=<unix_seconds>,v1=<hex_hmac_sha256>The HMAC is computed over ${timestamp}.${rawBody} with your API key as the secret. The SDK's verifyWebhook validates this for you. If you're verifying manually (other languages, etc.):
- Parse the
tandv1values from the header - Reject if
Math.abs(now - t) > 300seconds (replay protection) - Recompute
HMAC-SHA256(${t}.${rawBody}, apiKey) - Compare to
v1using a constant-time comparison
Webhook event shape
post.published sends the full post payload:
{
id: "evt_<base64url>", // shared across retries
type: "post.published",
apiVersion: "2026-05-01",
createdAt: "2026-05-17T10:00:00.000Z",
data: {
post: { /* full Post object */ }
}
}post.updated sends the full post payload plus the previous slug:
{
id: "evt_<base64url>",
type: "post.updated",
apiVersion: "2026-05-01",
createdAt: "2026-05-17T10:00:00.000Z",
data: {
post: { /* full Post object */ },
previousSlug: "old-slug"
}
}post.deleted is sent when a post is unpublished from the connection:
{
id: "evt_<base64url>",
type: "post.deleted",
apiVersion: "2026-05-01",
createdAt: "2026-05-17T10:00:00.000Z",
data: {
postId: "post_...",
slug: "removed-post",
unpublishedAt: "2026-05-17T10:00:00.000Z"
}
}Headers sent alongside:
| Header | Purpose |
|--------|---------|
| X-Ryterr-Event-Id | Idempotency key. Receivers should de-dupe on this. |
| X-Ryterr-Event-Type | Same as event.type |
| X-Ryterr-Api-Version | Same as event.apiVersion |
| X-Ryterr-Signature | HMAC signature (see above) |
| X-Ryterr-Delivery-Id | Delivery row id for support and replay debugging |
| X-Ryterr-Delivery-Attempt | 1-indexed retry counter |
Delivery guarantees
- At-least-once: Ryterr retries failed deliveries with exponential backoff (1m, 5m, 30m, 2h, 12h). After 6 attempts the delivery is marked failed for manual review.
- De-dupe at the receiver: same
X-Ryterr-Event-Idmay arrive multiple times. Store seen ids and treat duplicates as no-ops. - 10s timeout per attempt. Respond with any 2xx as fast as possible; do slow work in a background task.
Sample receivers
Cloudflare Worker
import { verifyWebhook } from "@ryterr/client"
export default {
async fetch(req: Request, env: { RYTERR_API_KEY: string }) {
if (req.method !== "POST") return new Response("not allowed", { status: 405 })
try {
const event = await verifyWebhook(req, env.RYTERR_API_KEY)
// ... do work
return Response.json({ ok: true })
} catch {
return new Response("bad signature", { status: 401 })
}
},
}Express (Node)
import express from "express"
import { verifyWebhook } from "@ryterr/client"
const app = express()
// IMPORTANT: do NOT use express.json() here - we need the raw body for HMAC.
// Use a route-scoped raw parser, or read the body manually.
app.post("/api/ryterr-webhook", async (req, res) => {
// Reconstruct the body. With express, easiest path is `express.raw({type:"application/json"})`.
const fetchRequest = new Request("http://local/", {
method: "POST",
headers: req.headers as HeadersInit,
body: req.body as Buffer,
})
try {
const event = await verifyWebhook(fetchRequest, process.env.RYTERR_API_KEY!)
// ...
res.json({ ok: true })
} catch {
res.status(401).send("bad signature")
}
})License
MIT
