Stripe end-to-end test runbook
A hand check that Hardcount’s Stripe integration works against a real Stripe test account. You connect the account, save a card, charge it, receive Stripe’s webhooks, retry a declined card, refund, and disconnect.
Run it:
- before each design-partner cutover, which brings an early pilot customer live on Hardcount;
- after any change touching
internal/payment/,internal/tenantstripe/orinternal/dunning/.
Audience: Hardcount maintainer, design-partner technical contact during sandbox cutover.
Never use live keys for this runbook.
Before you start
Section titled “Before you start”- A Stripe test-mode account with keys minted. At least a Restricted Key
(a Stripe key limited to chosen permissions), or a Standard secret key and
publishable key.
- The secret key authenticates server-side API calls.
- The publishable key is its browser-safe counterpart.
- Once entered, the keys never leave the dashboard’s connection form. Hardcount encrypts them at rest with the same AES-256-GCM key used for customer email.
- The Stripe CLI (
brew install stripe/stripe-cli/stripe), and$STRIPE_SKset to your Stripe test secret key. Step 1b uses them. jq, to read the JSON responses.- The operator dashboard on
http://localhost:5173, for step 2b. Step 0 starts only the API; start the dashboard as in the README Quick start.
To test a published release instead, start the Compose stack from
deploy/compose with HARDCOUNT_IMAGE and
HARDCOUNT_DASHBOARD_IMAGE set to the release tag. Use its address in place of
http://localhost:8080 and http://localhost:5173.
A secret API key (Authorization: Bearer hc_secret_test_…) works in place
of the cookie in every call below.
Step 0 — Stand up Hardcount locally
Section titled “Step 0 — Stand up Hardcount locally”-
Start the services, bootstrap on first boot (it creates the owner and the tenant), and start the API:
Terminal window docker compose up -d postgres redis mailpitDATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" \RUN_MIGRATIONS_ON_BOOT=true \go run ./cmd/hardcount-bootstrap # only on first boot — creates owner + tenantDATABASE_URL="postgres://hardcount:hardcount@localhost:5432/hardcount?sslmode=disable" \RUN_MIGRATIONS_ON_BOOT=true \go run ./cmd/hardcount &Then run
curl -sf http://localhost:8080/health. You see:{"status":"ok"}. -
Log in as the owner the bootstrap step printed, and keep the cookie for the calls below:
Terminal window curl -s -X POST http://localhost:8080/v1/auth/login \-H 'Content-Type: application/json' \-c /tmp/hardcount-cookies.txt \-d '{"email":"<OWNER_EMAIL>","password":"<OWNER_PASSWORD>"}'You see:
/tmp/hardcount-cookies.txtholds ahardcount_sessioncookie.
Step 1 — Connect the Stripe test account
Section titled “Step 1 — Connect the Stripe test account”1a. Connect
Section titled “1a. Connect”The connection form encrypts secret keys at rest (AES-256-GCM, same key as
customer email). Do not paste keys into shell history or commit them. Write
them to a umask 077 tmp file and delete it afterwards:
umask 077 && cat > /tmp/stripe-connect.json <<'EOF'{ "livemode": false, "secret_key": "sk_test_...", "publishable_key": "pk_test_..."}EOFcurl -s -X POST http://localhost:8080/v1/settings/stripe \ -b /tmp/hardcount-cookies.txt \ -H 'Content-Type: application/json' \ -d @/tmp/stripe-connect.jsonrm -f /tmp/stripe-connect.jsonYou see (200):
{ "id": "hc_spc_...", "tenant_id": "hc_ten_...", "livemode": false, "stripe_account_id": "acct_...", "stripe_account_name": "<your account display name>", "secret_key_prefix": "sk_test_...", "secret_key_last4": "...", "verified_at": "2026-04-27T..."}What this proves: Hardcount called GET /v1/account with the supplied secret
key, got an answer, and stored the encrypted keys. A verified_at timestamp
means the key shape and scope are valid and Stripe acknowledged the request.
If the response carries last_verified_error, the key is stored but Stripe
rejected the verify call. Common causes:
- a live key in test mode;
- a restricted key without
read_onlyscope on Account.
Code note: the verify call is
internal/tenantstripe/service.goConnect()→sc.V1Accounts.Retrieve.
1b. Forward Stripe events to Hardcount
Section titled “1b. Forward Stripe events to Hardcount”Do this before you save a card in step 2b. A saved card reaches Hardcount through
the setup_intent.succeeded webhook. Without the webhook secret in place,
Stripe’s page says the card is saved and the customer still has no payment
method in Hardcount.
Hardcount verifies Stripe’s webhook signatures with a per-tenant secret.
There is no operator-level STRIPE_WEBHOOK_SECRET env var and no
platform-level webhook URL. Each tenant’s endpoint is
/v1/webhooks/stripe/{endpoint_id}, where <endpoint_id> is the id
from step 1a (hc_spc_…, the tenant’s stripe_provider_credentials.id).
-
Forward events to your local API, without exposing port 8080 to the public internet:
Terminal window stripe listen --api-key $STRIPE_SK \--forward-to http://localhost:8080/v1/webhooks/stripe/<endpoint_id>You see: the CLI prints a temporary webhook secret (
whsec_…). -
Paste the secret into the Hardcount dashboard under Settings → Payments, step 2 “Deliver Stripe events to Hardcount” (or send it with
PATCH /v1/settings/stripe/test/webhook). You see: all later test-mode events flow through the CLI tunnel.
Step 2 — Create a customer with a saved payment method
Section titled “Step 2 — Create a customer with a saved payment method”A PaymentIntent is Stripe’s object tracking the collection of one payment. PaymentIntent flows require a saved payment method. The Hardcount dashboard saves one through Stripe’s Setup Intent flow, which saves a card for later charges without charging it now.
Customer creation (2a) needs no browser. Attaching the card (2b) goes through the dashboard and Stripe’s hosted page.
2a. Create the Hardcount customer
Section titled “2a. Create the Hardcount customer”curl -s -X POST http://localhost:8080/v1/customers \ -b /tmp/hardcount-cookies.txt \ -H 'Content-Type: application/json' \ -d '{ "display_name": "Stripe E2E Smoke", "email": "e2e-smoke@hardcount.test", "external_id": "stripe-e2e-smoke-1" }'You see: the new customer, with its hc_cus_... ID. Keep the ID for
the next steps.
2b. Attach a Stripe test card through the dashboard
Section titled “2b. Attach a Stripe test card through the dashboard”- Open
http://localhost:5173/customers/<hc_cus_...>. In the Payment methods panel, click Add payment method, then Copy link. - Open the link in a browser. You see: Stripe’s hosted Checkout page in setup mode.
- Enter Stripe’s standard test card, and save it. Choose “Pay without Link”
if offered, and give a ZIP for a US address, or Save does nothing.
- Card:
4242 4242 4242 4242 - Expiry: any future date
- CVC: any 3 digits
- Postal code: any 5 digits
- Card:
- Reload the customer page. You see: the Payment methods panel lists Visa ····4242 with its expiry date.
Hardcount stores the resulting pm_* payment method ID as a row in the
payment_methods table for that customer. That table is the canonical store
for a customer’s multiple payment methods.
Code note: Copy link calls
POST /v1/customers/{id}/payment-methods/setup-session, andpaymentmethods.Service.CreateSetupSession(internal/paymentmethods/service.go) creates the Checkout session. The oldcustomer_payment_setups.stripe_payment_method_idcolumn was dropped in migration 0097.
2c. Failed-card variant (run after the happy path)
Section titled “2c. Failed-card variant (run after the happy path)”- Repeat 2a with a new
external_idand email, for examplestripe-e2e-smoke-2ande2e-decline@hardcount.test. A customer’sexternal_idis unique per account and mode. - Repeat 2b for that customer with
4000 0000 0000 0341. You see: the card is listed, like4242was.
On this Stripe test card the attach succeeds but charges decline. Do not use
4000 0000 0000 0002: it declines at SetupIntent time too, so it never
produces this state. The decline in step 6 starts the
dunning flow: automated retry-and-notify handling of a
failed payment.
Step 3 — Subscribe the customer to a flat plan
Section titled “Step 3 — Subscribe the customer to a flat plan”Bootstrap seeds no plans, so create one first. Use the dashboard’s Pricing page, or instantiate a recipe: a pre-built pricing template shipped with Hardcount.
-
Create the plan at 2900 cents/month, because the amounts in steps 4 and 7 assume it. Its base fee bills in advance (
base_bill_timing: in_advance), so step 4 gets an invoice the moment the subscription starts. The default, in arrears, bills at the end of the period.Terminal window curl -s -X POST http://localhost:8080/v1/plans \-b /tmp/hardcount-cookies.txt \-H 'Content-Type: application/json' \-d '{"code":"flat-29","name":"Flat 29","currency":"USD","billing_interval":"monthly","base_amount_cents":2900,"base_bill_timing":"in_advance"}' -
Subscribe the customer from 2a to it:
Terminal window PLAN_ID=$(curl -s http://localhost:8080/v1/plans -b /tmp/hardcount-cookies.txt \| jq -r '.data[0].id')curl -s -X POST http://localhost:8080/v1/subscriptions \-b /tmp/hardcount-cookies.txt \-H 'Content-Type: application/json' \-d "{\"code\": \"stripe-e2e-smoke\",\"display_name\": \"Stripe E2E Smoke\",\"customer_id\": \"<hc_cus_...>\",\"items\": [{\"plan_id\": \"$PLAN_ID\", \"quantity\": 1}],\"billing_time\": \"anniversary\"}"You see: an
hc_sub_...withstatus=active, and current period bounds set to monthly (anchor: now).
Step 4 — Check the immediate invoice and PaymentIntent
Section titled “Step 4 — Check the immediate invoice and PaymentIntent”The flat-fee charge for the first cycle fires the moment the subscription activates.
curl -s "http://localhost:8080/v1/invoices?customer_id=<hc_cus_...>" \ -b /tmp/hardcount-cookies.txt | jq '.data[0]'You see: an invoice with total_amount_cents=2900 and
stripe_payment_intent_id populated. With the 4242 card, the PaymentIntent
auto-confirms, and within about 5 seconds the invoice is status=paid,
payment_status=succeeded and amount_due_cents=0. The PaymentIntent ID
also shows on the Stripe dashboard under Test data → Payments.
Step 5 — Check webhook delivery from Stripe to Hardcount
Section titled “Step 5 — Check webhook delivery from Stripe to Hardcount”Verified events are stored in stripe_webhook_events, keyed on
(tenant_id, livemode, stripe_event_id) for idempotency. The key is a UNIQUE
constraint, so a replayed event cannot be stored twice. There is no
processed column. The secret is stored encrypted, in
stripe_provider_credentials.webhook_secret_encrypted.
Code note: the per-request lookup is
LookupEndpointininternal/payment/handler.go.
You see: every Stripe event for steps 2–4 (payment_method.attached,
payment_intent.created, payment_intent.succeeded, charge.succeeded)
answered 200 in the stripe listen window, and stored as a row in
stripe_webhook_events (one per stripe_event_id per mode).
The dashboard’s /webhook_events page does not show this inbound
Stripe-ingestion table. It shows outbound webhook deliveries: events
Hardcount sends to your configured endpoints.
Step 6 — Failed payment and dunning
Section titled “Step 6 — Failed payment and dunning”-
Repeat step 3, item 2, for the customer from step 2c (declined card). Use a new subscription
code, such asstripe-e2e-decline: a code is unique per account and mode. You see: the first invoice’s PaymentIntent fails. -
List the dunning runs for that invoice:
Terminal window curl -s "http://localhost:8080/v1/dunning/runs?invoice_id=<hc_inv_...>" \-b /tmp/hardcount-cookies.txt | jqYou see: a dunning run, on the “Default retries” policy that bootstrap creates in each mode (ADR-123). Its first retry is due three days after the failure.
Retries follow the default policy’s retry_days, [3, 4, 7] for “Default
retries”: day 3, 7 and 14. Each retry is a fresh PaymentIntent: on the
Stripe dashboard’s Payments page, the PI (PaymentIntent) ID changes per
attempt.
An account created before ADR-123 has no policy. The failed payment is then
skipped with a “dunning not configured” WARN, and the runs list stays empty.
Create a policy first (dashboard → Dunning policies, or
POST /v1/dunning/policies).
To recover the customer, swap the card via step 2b with 4242, then wait for
the next scheduled dunning retry. Once the payment succeeds, the run resolves
automatically.
POST /v1/dunning/runs/{id}/resolve is the operator action to close a run
manually. There is no per-attempt force-retry endpoint.
Step 7 — Refund via credit note
Section titled “Step 7 — Refund via credit note”Issue a credit note against the paid invoice from step 4. A credit note is the document that records money credited or refunded against an invoice.
curl -s -X POST http://localhost:8080/v1/credit-notes \ -b /tmp/hardcount-cookies.txt \ -H 'Content-Type: application/json' \ -d '{ "invoice_id": "<hc_inv_...>", "reason": "duplicate", "lines": [ {"description": "Refund - full invoice amount", "quantity": 1, "unit_amount_cents": 2900} ], "refund_amount_cents": 2900, "auto_issue": true }'You see:
- The credit note, with
status=issued. - Because
refund_amount_centsis set, Hardcount callsrefunds.Createagainst Stripe, and the refund lands on the same charge. - A
charge.refundedwebhook flows back. The credit note’srefund_statusflips tosucceeded, andstripe_refund_idis populated.
A credit note has three allocation fields: refund_amount_cents,
credit_amount_cents and out_of_band_amount_cents. If you leave all three
at zero on a paid invoice, Hardcount defaults to credit_amount = total. That is
a customer-balance credit with no Stripe refund.
Step 8 — Disconnect
Section titled “Step 8 — Disconnect”curl -s -X DELETE http://localhost:8080/v1/settings/stripe/test \ -b /tmp/hardcount-cookies.txtYou see: 204. The credentials row is deleted, and the encrypted blobs are
purged. Asking for a card-setup session afterwards answers 409 with code
stripe_not_connected.
What to capture in your test report
Section titled “What to capture in your test report”A passing run reports:
- Stripe account name + ID returned from step 1 verify (proof the key works and is scoped to the intended account)
- Webhook event count from step 5 (≥4 events expected for the happy path)
- Final invoice status from step 4 (
paidfor happy, andpaidafter recovery in the dunning path) - Refund webhook landed within 30 seconds (step 7)
Anything that does not match a You see: line above is a bug. File it
under the Hardcount repo with the request ID Hardcount returns in the error envelope:
{"error": {"type", "code", "message", "request_id"}}. The envelope is
defined in internal/api/respond/respond.go. The request ID is also echoed
in the Hardcount-Request-Id response header.
What this runbook does NOT cover
Section titled “What this runbook does NOT cover”- Stripe Connect / Express accounts: Hardcount is a self-host product, not a marketplace. Connect onboarding is out of scope.
- Live mode: explicitly forbidden in this runbook. Live testing belongs in
the design-partner cutover playbook, maintained in the internal
velox-opsrepo. - Tax: Stripe Tax integration is exercised separately. See Tax calculation.
- Payouts / balance: Hardcount does not surface Stripe payouts. The operator’s Stripe dashboard is canonical for that.
Last verified
Section titled “Last verified”| Date | Hardcount SHA | Stripe API version | Verified by | Result |
|---|---|---|---|---|
| 2026-04-27 | f1b2301 |
2025-08-27.basil (default) |
maintainer | Step 1 (connect + verify) ✅; steps 2–8 documented as runbook, full flow validated during sandbox cutover for first design partner |
| 2026-10-13 | v0.6.1 (published images, Compose) |
2026-03-25.dahlia (Stripe CLI) |
maintainer | Steps 1 to 5, 7 and 8 passed: account verified; cards 4242 and 0341 attached through Checkout; a 2,900 cent charge succeeded and a decline opened a dunning run, both matching Stripe’s own PaymentIntent records; full refund through a credit note matched Stripe’s refund; 23 webhook events, all answered 200. Not run: the dunning retry and recovery in step 6 (the first retry is three days out). Found and fixed in the same change: card setup after disconnect answered 500. |
Re-run and append a row after every change touching internal/payment/,
internal/tenantstripe/, or internal/dunning/.