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

@sendtoapp/server

v0.1.0

Published

Create, revoke and read SendToApp links from your own server. Typed, retry-safe, no dependencies.

Readme

@sendtoapp/server

Create, revoke and read SendToApp links from your own server.

The HTTP API is the product and is not going anywhere — anything that speaks HTTP can keep calling it exactly as it does today. This package is for Node, and takes out the parts every integration was writing by hand: the bearer header, the app host, an idempotency key on a retry, a cursor loop, and the difference between a 409 and a 410.

No dependencies beyond @sendtoapp/protocol. Runs on Node 18+, Workers, Deno and Bun.

npm install @sendtoapp/server

A link

import { SendToApp } from '@sendtoapp/server';

const sendtoapp = new SendToApp({
  apiKey: process.env.SENDTOAPP_API_KEY,
  appHost: 'acme.sendtoapp.com'
});

const link = await sendtoapp.links.create({
  route: `/products/${product.id}`,
  preview: { title: product.name, description: product.blurb, imageURL: product.image },
  expiresIn: '7d',
  idempotencyKey: `product-${product.id}`
});

link.url; // https://acme.sendtoapp.com/l/8a3c429bda114b7091eb56b146403dbb

route is what your app opens and is never visible in the link. preview is what a chat app draws when the link is pasted, merged over the app's own preview field by field. expiresIn takes '30m', '12h', '7d', '2w' or milliseconds; expiresAt takes a Date.

The key is a server-side secret. It authorises creating and revoking every link on the app, so it never belongs in a mobile binary — this package declares no React Native entry point, and the mobile SDK cannot create links at all.

The rest

await sendtoapp.links.revoke(code);            // 410 everywhere, immediately
await sendtoapp.links.list({ cursor });        // one page, newest first
for await (const link of sendtoapp.links.all()) { … }   // every page

await sendtoapp.links.stats(code, { days: 30 });
await sendtoapp.stats({ days: 30 });

await sendtoapp.app.get();
await sendtoapp.app.update({ ...config, webFallback: 'web' });

await sendtoapp.resolve(code);                 // what the app would open

links.list returns at most 50 links and a nextCursor. That first page looks like the whole list until an app has more than fifty links, which is why links.all() exists.

TypeScript

Types ship with the package. Nothing to install, nothing to declare.

import { SendToApp, isSendToAppError } from '@sendtoapp/server';
import type { Link, LinkPreview, AppConfig, AppUpdate, Stats, ErrorCode } from '@sendtoapp/server';

const link: Link = await sendtoapp.links.create({ route: '/products/42' });

try {
  await sendtoapp.links.create({ route, idempotencyKey });
} catch (error) {
  // A catch gives TypeScript an `unknown`. This narrows it.
  if (isSendToAppError(error) && error.code === 'CONFLICT') return existing(order);
  throw error;
}

AppConfig is what a read returns; AppUpdate is what a write takes. PUT /v1/app replaces the configuration rather than patching it, so webHosts and webURL are required and an omitted android turns Android off. Spread a read:

await sendtoapp.app.update({ ...(await sendtoapp.app.get()), webFallback: 'web' });

The declarations compile under strict with skipLibCheck: false on every commit, against a fixture that also asserts what must not type.

Failures have names

Every failure is a SendToAppError with a code, so an application branches on the thing that happened rather than on a number it has to look up.

| code | What it means | | --- | --- | | INVALID | The request was refused. message names the field. | | UNAUTHORIZED | The key is wrong, or it is an account key rather than an app key. | | SUSPENDED | The account cannot create anything new until the subscription is settled. | | NOT_FOUND | No such link, or no such app for this key. | | CONFLICT | An idempotency key was reused for a different route. | | GONE | The link is revoked or expired. | | RATE_LIMITED | Too many requests. Retried once by default, honouring Retry-After. | | SERVER | The platform failed. Retried once by default. | | NETWORK / TIMEOUT | It could not be reached, or did not answer in time. |

try {
  await sendtoapp.links.create({ route, idempotencyKey });
} catch (error) {
  if (error.code === 'CONFLICT') return existingLinkFor(order);
  if (error.code === 'SUSPENDED') return payTheBill();
  throw error;
}

Retries

A read and a revoke are safe to repeat, so they are — once, by default.

A create is not, unless it carries an idempotencyKey: with one, the platform recognises the repeat and returns the first attempt's link instead of minting a second. Without one, this client sends a create exactly once and hands you the failure rather than risking two links for one intention.

new SendToApp({ apiKey, appHost, timeoutMs: 10000, retries: 1 });

Options

| Option | Default | | | --- | --- | --- | | apiKey | — | The app's API key. | | appHost | — | The app's link host, for example acme.sendtoapp.com. | | baseURL | https://<appHost> | Where requests go. A local deployment sets this. | | timeoutMs | 10000 | | | retries | 1 | Extra attempts for a request that is safe to repeat. | | fetchImpl | globalThis.fetch | |

Still just HTTP

Nothing here is required, and nothing here can do what the API cannot. If you are not on Node, or you would rather see the requests, the HTTP API is the same six endpoints this package calls.

MIT