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

@itaja/node

v0.3.0

Published

Itaja for Node.js servers: declare your catalogue in code, push it, mount one webhook handler, react to typed events.

Readme

@itaja/node

Sell subscriptions, credits and licence keys from your own code. Declare what you sell in TypeScript, push it to Itaja, mount one webhook route, react to typed events.

npm i @itaja/node        # pnpm add @itaja/node · bun add @itaja/node

Server only. This package holds your secret API key. Never ship it to a browser or a mobile app.

Quick start

0. Configure

Put your keys in .env (never commit it) and pass them to createItaja:

# .env
ITAJA_API_KEY=itjs_…              # sandbox key; an itjp_… key works on the live catalogue
ITAJA_WEBHOOK_SECRET=whsec_…      # signing secret of your webhook endpoint (dashboard)
ITAJA_API_URL=https://api.getitaja.com/api/v1   # optional
createItaja({
  apiKey: process.env.ITAJA_API_KEY,
  webhookSecret: process.env.ITAJA_WEBHOOK_SECRET,
  baseUrl: process.env.ITAJA_API_URL,
  catalog: [/* … */],
});

Each option falls back to its variable when you leave it out. Pass other values to use another account, a tenant's key or a staging API. The itaja CLI loads .env and .env.local itself: nothing to export in your shell.

1. Declare what you sell

A free plan and a paid plan, with a quota that depends on the plan.

// itaja.config.ts
import { createItaja, feature, group, plan } from "@itaja/node";

// What the customer can do
const messages = feature({ name: "Messages", type: "metered" });
const exportCsv = feature({ name: "Export", type: "boolean" });

// One customer holds one plan of a group
const app = group({ name: "My App" });

const free = plan({
  name: "Free",
  group: app,
  default: true, // everyone starts here, no payment
  includes: [messages({ limit: 100, reset: "month" })],
});

const pro = plan({
  name: "Pro",
  group: app,
  price: { amount: 5000, currency: "XOF", interval: "month" },
  trial: { days: 7 },
  includes: [messages({ limit: 2000, reset: "month" }), exportCsv()],
});

export const itaja = createItaja({
  apiKey: process.env.ITAJA_API_KEY,               // itjs_… sandbox, itjp_… live
  webhookSecret: process.env.ITAJA_WEBHOOK_SECRET, // signing secret of your webhook endpoint
  baseUrl: process.env.ITAJA_API_URL,              // optional, defaults to https://api.getitaja.com/api/v1
  catalog: [messages, exportCsv, free, pro],
});

The three options are read from these environment variables when you leave them out, so createItaja({ catalog }) works the same once they are set.

Everything is identified by its name — there is no id to choose. Names are compared trimmed, inner spaces collapsed and case-insensitively ("Pro" and " pro " are the same plan), and are typed as literals. Product names are unique across all your products, feature names across your features, plan names within their group, pack names within their credits product. Rename an item and you declare a new one; the old one is archived. Amounts are in major units (5000 XOF, 9.99 EUR), as in the API. Mistakes the API would refuse — a blank or over-long name (200 characters at most), two items of one name, an undeclared feature, two default plans, a paid plan without a price — fail as soon as the file loads.

2. Push it

npx itaja push                  # shows the diff, asks, applies

push uses the apiKey of your itaja.config.ts. Run it again: nothing changes. Change a price and a new version of the plan is created; running subscriptions keep theirs. Whatever you remove is archived, never deleted, and products made in the dashboard are never touched.

| Command | Does | |---|---| | npx itaja status | lists what differs, exits 1 if anything does | | npx itaja push --yes | applies without asking (CI, deploy pipeline) | | npx itaja catalog | prints the JSON your code declares |

The key's prefix picks the mode: itjs_ is sandbox, itjp_ is live.

3. Mount the webhook

One route receives Itaja's events. It checks the signature, ignores a delivery it already handled, ignores events of the other mode, and answers 500 when your handler throws so Itaja delivers again.

// Next.js — app/itaja/webhook/route.ts
import { itaja } from "@/itaja.config";
export const POST = itaja.handler;

// Hono, Bun, Elysia, Workers
app.post("/itaja/webhook", (c) => itaja.handler(c.req.raw));

// Express — raw body on this path
app.post("/itaja/webhook", express.raw({ type: "*/*" }), itaja.nodeHandler);

The route checks deliveries with webhookSecret, the signing secret of the endpoint in the dashboard.

4. React to events

export const itaja = createItaja({
  apiKey: process.env.ITAJA_API_KEY,
  webhookSecret: process.env.ITAJA_WEBHOOK_SECRET,
  catalog: [messages, exportCsv, free, pro],
  on: {
    "checkout.paid": ({ data }) => fulfil(data.checkoutId),
    "subscription.past_due": ({ data }) => warnCustomer(data),
    "subscription.canceled": ({ data }) => downgrade(data),
  },
});

data is typed per event. The "*" handler receives every delivery, including types this version does not know yet. Test a handler locally with a signed sample:

npx itaja trigger checkout.paid --url http://localhost:3000/itaja/webhook   # signs with ITAJA_WEBHOOK_SECRET from .env

What you can sell

| Mode | Declare with | Good for | |---|---|---| | Subscriptions | feature · group · plan | SaaS, memberships | | Credits | credits · creditPack | usage, AI tokens, coins | | Licence keys | license | desktop or mobile software | | One-off payments | itaja.checkout.create | a physical or digital order |

Each one can live in the same catalog array.

Subscriptions

The example above is the whole declaration. Around it:

const team = plan({
  name: "Team",
  group: app,
  price: { amount: 15000, currency: "XOF", interval: "month" },
  includes: [messages({ limit: 20000, reset: "month" }), exportCsv()],
});
  • interval: week · month · quarter · year. reset on a quota is day · week · month · year.
  • A plan without a price must be default: true; a group has at most one default.
  • trial: { days } gives a free period before the first charge.
await itaja.subscriptions.list({ customerId });
await itaja.subscriptions.cancel(subscriptionId); // at period end

Events: invoice.paid · invoice.payment_failed · subscription.past_due · subscription.canceled.

Coming with the API: subscribe({ customerId, plan: "Pro", product?: "My App" }) (new, upgrade, downgrade, cancel-to-free in one call; product picks between products that sell a plan of that name), check() and report() for entitlements are typed and throw NOT_YET_AVAILABLE until the API serves them (itaja-services #465, #466).

Credits

A currency your app spends as the customer uses it, sold in packs.

import { creditPack, credits } from "@itaja/node";

const tokens = credits({ name: "AI tokens", coinName: "Tokens" });

const small = creditPack({ name: "100 tokens", of: tokens, credits: 100, price: { amount: 1000, currency: "XOF" } });
const large = creditPack({ name: "1000 tokens", of: tokens, credits: 1000, price: { amount: 8000, currency: "XOF" } });
const { checkoutUrl } = await itaja.credits.buy({
  product: "AI tokens",
  pack: "100 tokens", // the pack's name, as the buyer reads it
  customerId,
  homeUrl,
  successUrl: "https://shop.example/credits/thanks", // optional, as on checkout.create
});
await itaja.credits.consume({ product: "AI tokens", customerId, amount: 3 });
await itaja.credits.balance({ product: "AI tokens", customerId });

Events: credits.granted · credits.consumed · credits.expired · credits.low · credits.clawed_back.

Licence keys

Software that unlocks on a number of devices.

import { license } from "@itaja/node";

const desktop = license({
  name: "Desktop app",
  devices: 3, // null = unlimited
  duration: "days-365", // "lifetime" · "days-N" · "from-first-activation-days-N"
  price: { amount: 25000, currency: "XOF" },
});
await itaja.licenses.issue({ product: "Desktop app", customerId });
await itaja.licenses.revoke({ product: "Desktop app", keyId, reason: "chargeback" });

Events: license.issued · activated · deactivated · revoked · expired · rotated · suspended · resumed · updated.

One-off payments

Nothing to declare: create a checkout for an amount, send the customer to checkoutUrl, and fulfil on checkout.paid.

const { checkoutUrl } = await itaja.checkout.create({
  homeUrl: "https://shop.example/thanks",
  successUrl: "https://shop.example/order/paid", // optional
  cancelUrl: "https://shop.example/cart",        // optional
  currency: "XOF",
  lineItems: [{ name: "Sandals", quantity: 1, unitPrice: 5000 }],
});

The payer comes back to homeUrl by default. Pass successUrl, failureUrl and/or cancelUrl (optional, absolute https, on a live key a domain verified for your account) to send each outcome to its own page; one left out keeps Itaja's return page. Itaja adds itaja_checkout_id and itaja_status (success | failure | cancel) to the address. The redirect is payer-visible: confirm payment from the webhook, never from it.

Payment links are a dashboard feature: no builder or endpoint here creates one.

Also in the SDK

await itaja.customers.get("user_42"); // your own customer id works too
await itaja.request("GET", "/me");    // any endpoint the helpers do not cover

Products and packs are referenced by their name at runtime too: the SDK resolves product: "AI tokens" to Itaja's product once, by comparing the normalised name with what your last push created. PlanNameOf, ProductNameOf, CreditsNameOf and LicenseNameOf give you those names as types:

import type { CreditsNameOf } from "@itaja/node";
const catalog = [messages, exportCsv, free, pro, small, large] as const;
type CreditProduct = CreditsNameOf<typeof catalog>; // "AI tokens"

Verify a delivery yourself, without the handler:

import { constructEvent } from "@itaja/node";
const event = constructEvent({ payload: rawBody, headers, secret: process.env.ITAJA_WEBHOOK_SECRET! });
// the same secret you pass to createItaja

Behind several instances, share the processed-delivery store (it is in memory by default):

createItaja({
  …,
  dedupe: {
    has: (id) => redis.exists(`itaja:${id}`).then(Boolean),
    add: (id) => redis.set(`itaja:${id}`, 1, "EX", 86400),
  },
});

Errors and retries

Every failure is an ItajaError with a stable code, the HTTP status and the API's details: ItajaValidationError (400/422), ItajaAuthenticationError (401), ItajaPermissionError (403), ItajaNotFoundError (404), ItajaConflictError (409), ItajaRateLimitError (429), ItajaServerError (5xx), ItajaConnectionError, ItajaSignatureError, ItajaConfigError. No error ever contains your key.

Every write carries an Idempotency-Key, one per call and reused on its retries, so a retried write never runs twice. Network errors, 5xx, 429 and 409 IDEMPOTENCY_KEY_IN_PROGRESS are retried twice, honouring Retry-After. Pass { idempotencyKey } (an order id, say) to make your own retries safe too.

Configuration

Every option of createItaja, and the variable it falls back to:

| Option | Environment | Default | |---|---|---| | apiKey | ITAJA_API_KEY | — | | webhookSecret | ITAJA_WEBHOOK_SECRET | — (only the handler needs it) | | baseUrl | ITAJA_API_URL | https://api.getitaja.com/api/v1 | | timeoutMs / maxRetries | — | 30000 / 2 |

There is no environment option: the key's prefix decides sandbox or live.

Development

pnpm install
pnpm test        # unit + contract suites, no network
pnpm test:e2e    # against a real developer API: ITAJA_E2E_API_KEY=itjs_… ITAJA_E2E_API_URL=…
pnpm generate    # regenerate src/generated/openapi.ts from spec/openapi.json

spec/openapi.json and spec/catalog.schema.json (repository root) are copies of the developer API's exported documents. spec/signature-vectors.json was signed by the API's own signOutboundWebhook. The e2e suite writes tests/e2e/__fixtures__/catalog-push-walk.json and compares every later run with it.

License

MIT