Build Payhip
KINDAback to the verdict
A storefront for your own downloadable products: hosted checkout so card details never reach you, location evidence collected at purchase, VAT charged at the customer's rate where required, secure time-limited delivery of the file, and records an accountant can file from.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the storefront.
Get it python.org or your package manager, then a virtual environment.
Verify
python3 --version prints 3.12 or higher - installfree
Why Orders and tax records deserve a real database.
Get it Install locally or run it in Docker. open ↗
Verify
psql connects and reports 16 or higher - API keyfree in test mode; live sales cost per transaction
Why Hosted checkout keeps card data out of your scope entirely, which is the only reasonable starting position for a solo build.
Get it Sign up, take the keys from Developers then API keys, and the webhook signing secret from the endpoint or the listen command. open ↗
Verify
A triggered test event reaches your endpoint and is verified - decidean hour of an accountant's time, and it is the cheapest part of getting this wrong
Why This is the prerequisite that actually blocks launch. Where you are established determines whether a threshold applies, and selling into the European Union from outside it means owing from the first sale.
Get it Speak to an accountant familiar with cross-border digital sales, or read your own revenue authority's guidance on digital services and the One Stop Shop scheme. Record what you concluded and when. open ↗
Verify
A written note states your registration position, the scheme and the date confirmed - API keystorage is cheap; egress scales with sales
Why Products are files and they must not be publicly readable, since a public URL is the product being given away.
Get it A private bucket with credentials scoped to it, from any provider supporting signed URLs with short expiry.
Verify
An unsigned request is refused and a signed one succeeds
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- products(id, name, slug, description, price_minor, currency, kind, active) · kind is download or bundle - files(id, product_id, storage_key, filename, bytes, version, uploaded_at) - orders(id, email, stripe_session_id, subtotal_minor, tax_minor, total_minor, currency, state, placed_at) - order_items(order_id, product_id, price_minor, tax_rate_bp, tax_minor) - location_evidence(order_id, kind, value_hash, collected_at) · at least two, non-contradictory, kept for the record - tax_rates(country, rate_bp, effective_from, source_url, checked_on) · dated, sourced, never hard coded - downloads(id, order_id, file_id, token, expires_at, used_count, max_uses) - The evidence rows are collected during checkout and kept, because you cannot go back and ask where a customer was.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://user:pass@localhost:5432/shop | Orders and tax records. |
STRIPE_SECRET_KEYsecret | required | sk_test_... | Stay in test mode until the restore drill and the tax position are both done. |
STRIPE_WEBHOOK_SECRETsecret | required | whsec_... | From the registered endpoint or the listen command. |
FILES_BUCKET | required | product-files | Private bucket holding the products themselves. |
DOWNLOAD_TTL_SECONDS | required | 86400 | How long a download link lasts. Short enough that it does not circulate. |
DOWNLOAD_MAX_USES | required | 5 | Uses before a link must be re-issued to the purchase email. |
TAX_RATES_PATH | required | ./data/vat_rates.json | Dated rates with sources. No rate appears in code. |
FILING_CURRENCY | required | GBP | Currency your returns are filed in. Conversions are recorded on the order. |
The build, in order
Products and a checkout that takes no card data
Someone can buy, and no card detail ever touches your server.
Minor units, one currency to start. Formatting only for display.
Files
app/models.pyterminalpip install django stripe psycopg[binary]
Price looked up from your database. Never trust an amount from the client.
Files
app/checkout.pySignature verified, event ids recorded for idempotency.
Files
app/webhooks.py
done when · tick each as it passeswatch out- Marking an order paid on the success redirect is the classic mistake and here it gives away a file. The redirect can be replayed and the customer can close the tab; the webhook is the truth and the redirect is a thank-you page that may arrive first.
Location evidence, collected at the moment you can
Every order carries at least two non-contradictory pieces of evidence about where the customer was.
Billing address, network address country and card issuing country. Two agreeing is the requirement.
Files
app/evidence.pyDo not pick. A disagreement is a decision with liability attached and belongs to a person.
Files
app/evidence.pyEnough to substantiate a return, hashed where the raw value is not needed.
Files
app/evidence.py
done when · tick each as it passeswatch out- Evidence cannot be collected retrospectively: once the sale is done you cannot ask where the buyer was, and a return that cannot be substantiated is a problem years later. Capture at checkout, store it, and treat a contradiction as something a human decides rather than something the code resolves.
Tax as dated data
The right rate is charged and you can show why, months later.
Country, rate, effective date, source, check date. Enter only the countries you sell to.
Files
data/vat_rates.jsonNever by today. A refund or a correction months later needs the rate that applied.
Files
app/tax.pyAn unknown country stops the sale for review. Charging zero silently is the expensive failure.
Files
app/tax.py
done when · tick each as it passeswatch out- A rate written into code is a rate nobody will re-check, and these change. Keep them dated with a source, resolve by order date rather than by today, and record on each line which rate was applied so a return is reconstructable rather than recomputed with today's numbers.
Delivery that is not a public link
The buyer gets the file and the link does not circulate.
Test an unsigned request and confirm refusal before relying on signing.
With a use limit, re-issuable to the purchase email rather than unlimited.
Files
app/delivery.pyTimestamp and count. It is how you notice a link circulating.
Files
app/delivery.py
done when · tick each as it passeswatch out- A permanent download link is the product itself, and it will be posted somewhere within weeks of your first successful launch. Short expiry with a small use limit, re-issuable to the purchase email, is the balance that stays usable without becoming a distribution channel.
Refunds, and the records behind them
A refund reverses the sale correctly, including its tax.
Reverse the order, reverse the tax, revoke links. Trigger both in test mode.
Files
app/webhooks.pyterminalstripe trigger charge.refunded
What a return needs. In your filing currency, with the conversion recorded.
Files
app/reports.pyYour totals against theirs for the same window. A gap is a bug rather than a rounding difference.
done when · tick each as it passeswatch out- A refund that reverses the payment but not the tax record leaves your return overstating what you owe, and a refund that leaves download links live gives the product away. Handle both in the webhook handler and test with a real refund in test mode before launch.
Before you take real moneyproduct builder
Your registration position is settled and the launch checks have been done.
Which scheme, which member state if applicable, and from what date. Recorded, with the advice you took.
Files
docs/tax-position.mdThe fields your jurisdiction requires on a receipt or invoice for a digital sale.
Files
app/receipts.pyDatabase and files. A restore where orders exist and files are gone is not a restore.
Files
scripts/backup.shREADME.md
done when · tick each as it passeswatch out- Launching before settling registration means either charging tax you are not registered to collect or collecting nothing and owing it anyway, and both are discovered later with interest attached. Settle it first; the code is the easy part of this build.
That is the whole plan for Payhip. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Tax advice or a determination of your registration position.
- Acting as merchant of record and carrying the tax liability.
- Marketplace discovery.
- Affiliate programmes and bundled course delivery.
- payment integration, EU and UK VAT handling, affiliates, courses, and hosted delivery
- marketplace discovery
- tax remittance
- fraud operations
- advanced course and community tooling
- A second currency, once the conversion and filing questions are settled
- Licence keys for software products, which need their own validation endpoint
- Customer-facing order history so re-download does not need an email round trip
Need the files? The project pack on the verdict page hands your agent the whole brief · more creator commerce.