@sendtoapp/server
v0.1.0
Published
Create, revoke and read SendToApp links from your own server. Typed, retry-safe, no dependencies.
Maintainers
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/serverA 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/8a3c429bda114b7091eb56b146403dbbroute 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 openlinks.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
