REST API that exposes a VintaSend notification service over HTTP.
It exists so the VintaSend dashboard
no longer has to embed a notification service: the dashboard is now a pure API
client, and any implementation of this contract can serve it — including a
future one built on the Python vintasend package.
openapi.yaml is the contract. This repository is the
TypeScript reference implementation of it, published to npm so a project can
mount it rather than copy it.
Mounted in your own server — the usual case for an app that already has
one. createApp returns a Hono app, which takes a standard
Request and returns a Response, so it mounts in a Next.js route handler, in
TanStack Start, behind Express, or anywhere else that speaks fetch. You hand it
the service you already built to send notifications, and your own check of who
is calling. See Mounting it.
On its own — the vintasend-api command runs a server configured from
environment variables, behind one shared API key. See
Running it on its own.
Either way, the API ships no notification backend: database, adapters and template renderer are yours.
npm install vintasend-api vintasendvintasend is a peer dependency, so your service and the API share one copy.
Install the same release line for both: the API is released together with
vintasend, and its version matches.
The standalone server also needs @hono/node-server. It is an optional peer
dependency, so a host that mounts createApp in its own server does not install
a Node HTTP server it never starts:
npm install @hono/node-server # only to run the vintasend-api commandvintasend-api holds createApp, the authenticators, ApiError and the wire
types, and loads in a browser. vintasend-api/server holds what the standalone
command is built from: loadServerConfig and the service-module loader. It is
Node only.
┌─────────────────────┐ HTTPS + API key ┌──────────────────┐
│ Dashboard (Next) │ ───────────────────▶ │ vintasend-api │
│ server-side only │ ◀─────────────────── │ (this repo) │
└─────────────────────┘ JSON contract └────────┬─────────┘
│
┌──────────────┴──────────────┐
│ Your VintaSend service │
│ backend + adapters + │
│ template renderer │
└─────────────────────────────┘
The API owns everything that needs backend credentials — database access, template rendering, GitHub template lookups. The UI owns presentation and user authentication.
| Method | Path | Purpose |
|---|---|---|
| GET | /health |
Liveness probe (unauthenticated) |
| GET | /api/v1/capabilities |
Filter/order capabilities of the configured backend |
| GET | /api/v1/notifications |
List notifications with filters, ordering and pagination |
| GET | /api/v1/notifications/pending |
Notifications awaiting send |
| GET | /api/v1/notifications/future |
Notifications scheduled for the future |
| GET | /api/v1/notifications/one-off |
One-off notifications |
| GET | /api/v1/notifications/{id} |
One notification, including context payloads |
| GET | /api/v1/notifications/{id}/preview |
Templates rendered at the notification's commit |
| POST | /api/v1/notifications/{id}/resend |
Resend a notification |
| POST | /api/v1/notifications/{id}/cancel |
Cancel a pending notification |
Conventions worth knowing when implementing this contract elsewhere:
pageis 1-indexed in the API, in every implementation, and clients never convert. What the backend wants is a separate question: the TypeScript VintaSend backends are 0-indexed, the Python ones are 1-indexed. The offset comes from the backend'spagination.oneIndexedcapability — porting this server'spage - 1literally into a 1-indexed language is an off-by-one. The capability is backend-facing and is not published by/api/v1/capabilities.hasMoreistruewhen the next page has at least one row, so a list that exactly fills its last page never offers an empty one. Backends are not required to produce a total count: after a full page, the server reads the one row that would follow it.- List rows carry a
kindfield (userorone-off) so clients can discriminate without sniffing for the presence of fields. - Timestamps are ISO-8601 UTC strings,
nullwhen unset — neverundefined. - Errors always use the envelope
{ "error": { "code", "message", "details"? } }. Failures that come from the template source areUPSTREAM_ERROR(502), not a generic 500. - Every 400 carries
details.issues: [{ path, message }], whatever the mistake was.pathnames the field, and is empty for the body as a whole. FORBIDDEN(403) is for a caller who is authenticated and not allowed — what a host's own authentication answers. The API key alone never produces it.- Request bodies are JSON. A request declaring
application/json(or anyapplication/*+json) must carry valid JSON. A request declaring no media type, or another one, counts as an omitted body when it is empty and is a 400 otherwise —curl -dsends form encoding unless told otherwise, and reading its body as{}would resend with a regenerated context instead of the stored one.
// app/api/v1/[...path]/route.ts — a Next.js app router route handler
import {
ApiError,
asNotificationServicePort,
createApp,
createGitHubTemplateClientFromEnv,
} from 'vintasend-api';
import { notificationService } from '@/lib/notifications';
import { getSession } from '@/lib/auth';
const app = createApp({
// The service your app already sends with. VintaSend services are generic over your
// notification config, so the port takes it through a cast confined to this one call.
getService: async () => asNotificationServicePort(notificationService),
// Runs before every /api/v1 route. Throw to refuse.
authenticate: async (c) => {
const user = await getSession(c.req.raw);
if (!user) throw ApiError.unauthorized('Sign in first.');
if (!user.canManageNotifications) throw ApiError.forbidden('Not allowed.');
return {};
},
// Where previews read templates from, at the commit a notification was sent with.
getTemplateClient: () => createGitHubTemplateClientFromEnv(),
// Every error not mapped to a contract error. Defaults to one redacted log line.
onUnhandledError: (error, _c, { requestId }) => errorTracker.capture(error, { requestId }),
});
const handler = (request: Request) => app.fetch(request);
export { handler as GET, handler as POST };Behind Express, hand getRequestListener(app.fetch) from @hono/node-server to a
route that keeps the path whole — server.all(...), not server.use('/api/v1', ...),
which strips the prefix the API's routes include.
authenticate has the same shape in
vintasend-templates-management-api,
so an app mounting both passes them one function. It may throw the ApiError of
either package: both recognise an error by its name and code, not by its class.
Throw ApiError.unauthorized for a caller with no valid credential and
ApiError.forbidden for one you know and refuse: a 401 would tell a signed-in
user to sign in again. For one shared secret, pass
authenticate: apiKeyAuthenticator(key), which compares in constant time. To
check a token yourself, such as the caller's own identity-provider token, read it
with bearerToken(c.req.header('authorization')), which is null when the
request carries none.
The app uses Web APIs only — no Node built-ins — so it runs wherever fetch
does. The standalone server and the module-path service loader on ./server are
the Node-only parts, and the package entry loads neither.
An unexpected error is reported to the client as a generic 500 with an
X-Request-Id header. By default it is logged as one line — the error's name,
the request id and the route pattern — and never with its message, its stack or
the request: errors from a notification backend or provider can quote
notification content and context values, which in the applications this API
serves can be health data. onUnhandledError hands the error to your own
tracker instead; keeping health data out of it is then your call. If it throws,
the default line is logged in its place.
Every /api/v1 request must carry the shared secret:
Authorization: Bearer $VINTASEND_API_KEY
The dashboard calls this API only from its own server side, so the key never
reaches a browser. If you do need to call the API from a browser, set
VINTASEND_API_CORS_ORIGINS to the allowed origins — and put a per-user auth
layer in front of it first.
VINTASEND_API_KEY=… VINTASEND_SERVICE_MODULE=./vintasend.config.js npx vintasend-apiTo work on this repository instead:
npm install
cp .env.example .env
npm run devThe standalone server ships no backend of its own: which database, adapters and
template renderer to use is a deployment decision. Point
VINTASEND_SERVICE_MODULE at a module that default-exports a factory returning a
configured VintaSend service:
// src/vintasend.config.ts
import { VintaSendFactory } from 'vintasend';
export default async function createVintaSendService() {
const backend = /* your backend */;
const renderer = /* your template renderer */;
const adapter = /* your notification adapter */;
return new VintaSendFactory<Config>().create(backend, [adapter], contextGenerators);
}Start from src/vintasend.config.example.ts,
copying it to src/vintasend.config.ts (gitignored) so it is compiled along with
the rest of src. The factory is called once at startup, and a failure there
stops the server rather than surfacing on the first request.
VINTASEND_SERVICE_MODULE accepts a path relative to the working directory or a
bare package specifier. Use ./dist/vintasend.config.js with npm start, and
./src/vintasend.config.ts with npm run dev, which runs TypeScript directly.
| Variable | Required | Description |
|---|---|---|
VINTASEND_API_KEY |
yes | Shared secret clients must send as a bearer token. |
VINTASEND_SERVICE_MODULE |
no | Module building your VintaSend service. Defaults to ./dist/vintasend.config.js. |
VINTASEND_BACKEND_IDENTIFIER |
no | Read from a non-primary backend registered in your service. |
VINTASEND_API_CORS_ORIGINS |
no | Comma-separated browser origins allowed to call the API. |
PORT / HOST |
no | Listen address. Defaults to 3333 / 0.0.0.0. |
GITHUB_REPO |
preview only | Repository holding the templates, as owner/repo or a full URL. |
GITHUB_API_KEY |
preview only | Token with read access to that repository. |
GITHUB_API_BASE_URL |
no | Defaults to https://api.github.com. |
GITHUB_TEMPLATES_BASE_PATH |
no | Prefix added to template paths before the GitHub lookup. |
The GITHUB_* variables are only read when /preview is called, so the API
runs fine without them if you do not use template previews.
npm run dev # watch mode
npm test # vitest
npm run typecheck # tsc --noEmit
npm run lint # biome
npm run build # compile to dist/
npm start # run the compiled serverTests drive the real Hono app through app.request() with an injected fake
service, so they cover routing, auth, validation, filter negotiation and
serialization without needing a database.
- Read
openapi.yaml— it is normative, including status codes and error codes. - Mirror the
hasMorerule (another page has a row) and thekinddiscriminator exactly; the dashboard depends on both. - Take the page offset from your backend's
pagination.oneIndexedcapability, not from this implementation. The wire stays 1-indexed either way, so a 1-indexed backend passes the page straight through — no- 1. Apply it to every paginated read, and keep the capability out of the/capabilitiesresponse so no client converts on top of you. - Treat
stringLookups.caseSensitiveandstringLookups.caseInsensitiveas independent: a backend can be incapable of either one, and deriving one from the other declines the single lookup such a backend actually supports. - Negotiate string lookups and ordering against your backend's capabilities,
and report what you support from
/api/v1/capabilities. Dropping an unsupported ordering is correct; failing the request is not. - Report template-source failures as
UPSTREAM_ERROR(502) rather than a generic 500: a rate-limited or unreachable template host is not a fault of the API, and the dashboard shows the message to the operator. - Keep the error envelope identical — the dashboard branches on
error.code— includingdetails.issueson every 400 and the request-body rule.
MIT