npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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/client

The 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 HTML

ryterr.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 | PostDeletedEvent

Important: 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 available

Errors

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.):

  1. Parse the t and v1 values from the header
  2. Reject if Math.abs(now - t) > 300 seconds (replay protection)
  3. Recompute HMAC-SHA256(${t}.${rawBody}, apiKey)
  4. Compare to v1 using 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-Id may 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