Skip to content

imarinoff/LinkVault

Repository files navigation

LinkVault

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.

Use Case

LinkVault is for the common "landing page plus payment link" sales flow:

  1. You publish a product landing page in your CMS, website builder, or static site.
  2. You create the product and Payment Link in Stripe.
  3. You set the Payment Link confirmation behavior to redirect back to LinkVault with the Checkout Session ID.
  4. You sync Stripe products into LinkVault.
  5. 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:

  1. The customer reads your landing page and clicks the Stripe Payment Link CTA.

  2. Stripe hosts checkout and collects payment.

  3. Stripe redirects the customer to LinkVault at a URL like:

    https://your-linkvault-domain.com/product-slug?chsi={CHECKOUT_SESSION_ID}
  4. LinkVault verifies the Checkout Session and purchased line items with Stripe.

  5. 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.

Setup

  1. Install dependencies:

    bun install
  2. 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'))"
  3. Copy .env.example to .env and 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_TO is optional but recommended: buyer replies to delivery emails go there.

    ADMIN_PASSWORD_HASH_BASE64 is 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, set ADMIN_PASSWORD_HASH instead 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.

  4. Build and start:

    bun run build
    bun run start

Configuration reference

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.

Docker Compose

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 --build

LinkVault will listen on port 3000:

http://localhost:3000/admin

The 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 down

To remove all stored products, orders, tokens, and uploaded files too:

docker compose --env-file docker.env down -v

Easypanel

compose.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_here

Easypanel 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.

Stripe Payment Links

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.

Webhook

Configure a Stripe webhook endpoint:

https://your-linkvault-domain.com/stripe/webhook

Listen for:

  • checkout.session.completed
  • checkout.session.async_payment_succeeded
  • checkout.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.

Download links

  • 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.

Admin

Visit /admin to:

  • Sync products from Stripe.
  • Enable products.
  • Edit slugs.
  • Upload digital files.
  • Copy each product redirect URL.

Development

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

About

LinkVault is a Bun + React app for secure digital downloads after Stripe Payment Link checkout. Sync Stripe products, upload files, verify Checkout Sessions, generate expiring download links, and email customers via Resend.

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages