Digital downloads for Stripe Payment Links.
LinkVault is a small Bun + TypeScript + React app for merchants who already sell through Stripe Payment Links and need a simple way to deliver paid digital files.
It is independent software for merchants using Stripe Payment Links. It is not affiliated with, endorsed by, or sponsored by Stripe, Inc. Stripe is a trademark of Stripe, Inc.
LinkVault is for the common "landing page plus payment link" sales flow:
- You publish a product landing page in your CMS, website builder, or static site.
- You create the product and Payment Link in Stripe.
- You set the Payment Link confirmation behavior to redirect back to LinkVault with the Checkout Session ID.
- You sync Stripe products into LinkVault.
- You enable the products you want to fulfill and upload the digital goods for each product, such as PDFs, images, templates, ZIP files, or other downloads.
Customer flow:
-
The customer reads your landing page and clicks the Stripe Payment Link CTA.
-
Stripe hosts checkout and collects payment.
-
Stripe redirects the customer to LinkVault at a URL like:
https://your-linkvault-domain.com/product-slug?chsi={CHECKOUT_SESSION_ID} -
LinkVault verifies the Checkout Session and purchased line items with Stripe.
-
LinkVault shows a branded download page with the product image, name, and description, creates a unique expiring download link, and emails the same link through Resend — telling the buyer exactly until when the link works and how many downloads it allows.
For delayed payment methods (bank debits and similar), the buyer sees a friendly "payment processing" page instead, and the download link is emailed automatically once Stripe confirms the payment via webhook.
The goal is deliberately narrow: keep Stripe responsible for checkout, taxes, receipts, and payment methods, while LinkVault handles secure digital-file fulfilment after payment.
-
Install dependencies:
bun install
-
Create an admin password hash.
LinkVault does not ship with a default admin password. Pick your own password and generate its base64-encoded Argon2 hash:
bun -e "console.log(Buffer.from(await Bun.password.hash('your-password-here')).toString('base64'))" -
Copy
.env.exampleto.envand fill in:BASE_URL=https://your-linkvault-domain.com STRIPE_SECRET_KEY=sk_live_or_test STRIPE_WEBHOOK_SECRET=whsec_... RESEND_API_KEY=re_... RESEND_FROM="Your Shop <downloads@example.com>" RESEND_REPLY_TO=support@example.com ADMIN_PASSWORD_HASH_BASE64=paste_the_generated_value_here
The display name in
RESEND_FROM(the part before<...>) doubles as the buyer-facing brand: it appears on the download page and in delivery emails. Always include one — a bare address makes inboxes show the raw email as the sender, which looks anonymous and hurts deliverability.RESEND_REPLY_TOis optional but recommended: buyer replies to delivery emails go there.ADMIN_PASSWORD_HASH_BASE64is the recommended way to store the hash because raw Argon2 hashes are full of$characters, and every env-file parser treats$differently. Base64 output never contains$, so it works unmodified everywhere — Bun's.env, Docker Compose env files, and hosting dashboards. If you prefer the raw hash, setADMIN_PASSWORD_HASHinstead and escape every$as\$(Bun expands$...in dotenv files; an unescaped hash gets mangled and login fails). If both are set, the base64 variant wins. -
Build and start:
bun run build bun run start
| Variable | Required | Default | Purpose |
|---|---|---|---|
BASE_URL |
yes | — | Public URL of this LinkVault instance; used to build download links and secure cookies. |
STRIPE_SECRET_KEY |
yes | — | Stripe API key (test or live). |
STRIPE_WEBHOOK_SECRET |
yes | — | Signing secret of the Stripe webhook endpoint. |
RESEND_API_KEY |
yes | — | Resend API key for delivery emails. |
RESEND_FROM |
yes | — | From address; its display name is the buyer-facing brand. |
RESEND_REPLY_TO |
no | — | Reply-To for delivery emails (e.g. a support inbox). |
ADMIN_PASSWORD_HASH_BASE64 |
one of the two | — | Base64-encoded Argon2 hash of the admin password (recommended). |
ADMIN_PASSWORD_HASH |
one of the two | — | Raw Argon2 hash; needs $ escaping per parser. |
DATABASE_PATH |
no | ./data/shop.sqlite |
SQLite database location. |
UPLOADS_DIR |
no | ./data/uploads |
Where uploaded digital goods are stored (outside public paths). |
DOWNLOAD_TOKEN_TTL_HOURS |
no | 168 |
How long download links stay valid (7 days). |
DOWNLOAD_MAX_USES |
no | 5 |
Downloads allowed per order. |
PORT |
no | 3000 |
HTTP port. |
For a simple single-server deployment, copy docker.env.example to docker.env, fill in the values, then run:
docker compose --env-file docker.env up -d --buildLinkVault will listen on port 3000:
http://localhost:3000/adminThe Compose setup stores SQLite data and uploaded digital goods in the named Docker volume linkvault-data, mounted at /app/data inside the container.
Use docker.env for Docker Compose and .env for local Bun development. They are separate because Bun and Docker Compose handle $ in environment files differently — which is a non-issue if you use ADMIN_PASSWORD_HASH_BASE64 (recommended, no $ characters). If you use the raw ADMIN_PASSWORD_HASH instead, keep it wrapped in single quotes in docker.env:
ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$paste_the_rest_of_the_generated_hash_here'Useful commands:
docker compose --env-file docker.env logs -f
docker compose --env-file docker.env restart
docker compose --env-file docker.env downTo remove all stored products, orders, tokens, and uploaded files too:
docker compose --env-file docker.env down -vcompose.easypanel.yml deploys LinkVault onto an Easypanel host behind its Traefik reverse proxy, instead of publishing port 3000 directly.
Easypanel deploys by cloning this Git repository directly, so there is no docker.env file on the server (it is gitignored on purpose, since it holds secrets). Do not create one manually. Instead, create an Easypanel "App" from this repository in Compose mode pointing at compose.easypanel.yml, and set the environment variables in Easypanel's Environment tab in its dashboard:
BASE_URL=https://your-linkvault-domain.com
STRIPE_SECRET_KEY=sk_live_or_test
STRIPE_WEBHOOK_SECRET=whsec_...
RESEND_API_KEY=re_...
RESEND_FROM=Your Shop <downloads@example.com>
RESEND_REPLY_TO=support@example.com
ADMIN_PASSWORD_HASH_BASE64=paste_the_base64_value_hereEasypanel writes whatever you save there verbatim into a plain .env file inside the app's code directory on the server (env_file in compose.easypanel.yml loads it — it does not go through Easypanel's docker-compose.override.yml, which only touches networking). Docker Compose applies its own variable interpolation to env_file contents, the same as it does to values written directly in a compose YAML file, expanding $word as a variable reference.
Use ADMIN_PASSWORD_HASH_BASE64, not ADMIN_PASSWORD_HASH, in Easypanel's dashboard. Generate it with:
bun -e "console.log(Buffer.from(await Bun.password.hash('your-password-here')).toString('base64'))"Base64 output contains no $ characters, so there is nothing for Compose's interpolation to mangle — no escaping needed at all. If you use plain ADMIN_PASSWORD_HASH instead, every $ in the hash must be escaped as $$ (Compose's own escape sequence for a literal $ — this is different from the \$ escaping used for local Bun .env files, and backslash escaping does not work here: Compose treats \ as a plain character, so a \$-escaped hash gets each $word segment silently replaced with an empty string, leaving stray backslashes behind, which fails validation with no obvious cause).
If the app was never configured with any environment variables in Easypanel's dashboard, .env will not exist yet on the server; compose.easypanel.yml marks it required: false so the container still starts (and reports LinkVault's own "missing required variables" error) instead of Compose itself refusing to start the whole stack.
Before deploying, also edit the traefik.http.routers.linkvault.rule label in compose.easypanel.yml to your real domain (it ships with the placeholder linkvault.example.com), matching the BASE_URL you set above.
Easypanel builds and starts the container itself once the App is created; there is no manual docker compose up step. It joins the container to the external easypanel Docker network instead of publishing a host port, and the Traefik labels tell Easypanel's proxy to route HTTPS traffic to the container on port 3000. Data persists in the named volume linkvault-data.
Create Payment Links in Stripe. For each product, set After the payment to redirect to:
https://your-linkvault-domain.com/product-slug?chsi={CHECKOUT_SESSION_ID}The app verifies the Checkout Session and line items with Stripe before creating a download link.
Configure a Stripe webhook endpoint:
https://your-linkvault-domain.com/stripe/webhookListen for:
checkout.session.completedcheckout.session.async_payment_succeededcheckout.session.async_payment_failed
Webhook fulfilment matches enabled local products by purchased Stripe product/price. Events that are not actionable — sessions whose payment is still processing, or checkouts for products LinkVault does not manage (e.g. other things you sell through the same Stripe account) — are acknowledged with 200 so Stripe does not retry them; delayed payments fulfil automatically when async_payment_succeeded arrives. Invalid signatures are rejected with 400.
- Links expire after 7 days and allow 5 downloads by default (
DOWNLOAD_TOKEN_TTL_HOURS,DOWNLOAD_MAX_USES). - All links issued for the same order share one budget, anchored to the first fulfilment: revisiting the success page issues a fresh link but never resets the clock or the download count, so a shared redirect URL cannot be milked for unlimited downloads.
- Only the SHA-256 hash of each token is stored; raw links appear only in the buyer's browser and email.
- Products with one file download directly; products with several files download as a ZIP streamed on the fly.
Visit /admin to:
- Sync products from Stripe.
- Enable products.
- Edit slugs.
- Upload digital files.
- Copy each product redirect URL.
bun run dev # backend with watch mode on :3000
bun run dev:client # Vite dev server on :5173, proxying /api, /stripe, /d to :3000
bun run test
bun run typecheck
bun run build