Signup Credit¶
New IndoxHub accounts can claim a one-time $1 free credit after verifying a payment method. There is no charge — Stripe captures the card via a SetupIntent (zero-amount authorization) so we know the card is real before granting funds.
Eligibility¶
A user is eligible for the signup credit if all of the following are true:
| Gate | Source |
|---|---|
SIGNUP_CREDIT_ENABLED=true on the backend |
.env setting (default true) |
| User's email has been verified via the email-confirmation link | users.email_verified = TRUE (set on /auth/confirm) |
| User has not previously received a signup credit | No row in billing_transactions with payment_method='signup_credit' for that user |
OAuth signups (Google / GitHub / Apple) bypass the email-confirmation step and currently do not auto-flip email_verified. See the OAuth note below.
Endpoints¶
GET /api/v1/payments/signup-credit/status¶
Authenticated. Returns whether the current user can / has claimed the signup credit. Use this to show or hide the "Claim $1" CTA in the frontend.
Response — eligible user, no grant yet:
Response — already granted:
{
"eligible": false,
"granted": true,
"email_verified": true,
"amount_usd": 1.0,
"reason": "already_granted"
}
The reason field is one of null, "disabled", "already_granted", or "email_not_verified".
POST /api/v1/payments/signup-credit/setup-intent¶
Authenticated. Creates a Stripe Customer (if not already created), persists users.stripe_customer_id, and returns a SetupIntent client_secret the frontend can confirm via Stripe.js.
Response (200):
{
"client_secret": "seti_…_secret_…",
"setup_intent_id": "seti_…",
"customer_id": "cus_…",
"publishable_key": "pk_live_…"
}
Errors:
| Code | Reason |
|---|---|
| 401 | No valid auth cookie / token |
| 403 | SIGNUP_CREDIT_ENABLED=false or user's email is not verified |
| 409 | User has already received a signup credit |
| 503 | STRIPE_API_KEY not configured on the backend |
End-to-end flow¶
1. User registers POST /auth/register → account created, email_verified=FALSE
2. Email confirmation POST /auth/confirm → email_verified=TRUE
3. Frontend "Claim $1" POST /payments/signup-credit/setup-intent
4. Stripe.js confirms card → Stripe fires `setup_intent.succeeded` webhook
5. Webhook handler → grant_signup_credit(user_id, setup_intent_id, $1)
6. User's account credits 0 → 1.00, account_tier free → standard
The webhook handler is idempotent:
- Same
setup_intent_idfired twice → only one grant - Different
setup_intent_idfor the same user → still only one grant (one-per-user check)
Configuration¶
| Env var | Default | Purpose |
|---|---|---|
SIGNUP_CREDIT_ENABLED |
true |
Master kill-switch. Set to false to disable the entire flow. |
SIGNUP_CREDIT_AMOUNT_USD |
1.0 |
The dollar amount granted. |
STRIPE_API_KEY |
required | Stripe secret key (test or live). |
STRIPE_PUBLISHABLE_KEY |
empty | Returned to the frontend so Stripe.js can initialize. |
STRIPE_WEBHOOK_SECRET |
required | Validates incoming setup_intent.succeeded webhooks. |
The Stripe Dashboard webhook endpoint must be subscribed to the setup_intent.succeeded event. The handler ignores SetupIntents whose metadata purpose != "signup_credit", so other SetupIntent flows in the account are unaffected.
OAuth signups (current behavior)¶
OAuth users (Google / GitHub / Apple) skip the email-confirmation step, which means email_verified stays FALSE and they cannot currently claim the signup credit. The next iteration will mark these users as email_verified=TRUE automatically — the OAuth provider has already verified the email.
Database changes¶
Migration 015_add_stripe_customer_and_email_verified.sql adds:
users.stripe_customer_id VARCHAR(255) UNIQUE— links a user to their Stripe Customer recordusers.email_verified BOOLEAN NOT NULL DEFAULT FALSE— the eligibility gateusers.email_verified_at TIMESTAMP WITH TIME ZONE— when the user confirmed- Index
idx_users_stripe_customer_idonusers(stripe_customer_id)
Backfilling email_verified=TRUE for existing active users is not done automatically. Run a migration script if you want existing users to be eligible.