Crypto faucets look trivial from the outside — a button, a timer, a payout — but the moment you wire up real money, the problems stack up fast: double-spend race conditions, bot farms draining your balance, API errors that silently swallow payouts, and users complaining they never got paid. I’ve built and shipped exactly this system, and this guide walks through a production-grade crypto faucet using Laravel 13, Inertia.js, and the FaucetPay API.
By the end you’ll have a working faucet with automatic micro-payouts, a cooldown timer, anti-bot protection, and a reactive UI — the foundation for a real passive income crypto project.
What We’re Building
The app lets a visitor claim a small amount of cryptocurrency (satoshis, LTC, DOGE, etc.) on a fixed schedule — for example once every 60 minutes. When they claim, Laravel validates the request, checks the cooldown, verifies a CAPTCHA, sends the payout through FaucetPay, and records the transaction.
Here’s the stack:
- Backend: Laravel 13 (PHP 8.3+)
- Frontend: Inertia.js (Vue 3) + Tailwind CSS — this is the inertiajs vue combo that keeps everything in one codebase
- Payout API: FaucetPay Faucet API
- Database: MySQL or PostgreSQL
Prerequisites & Environment Setup
You need PHP 8.3+, Composer, and Node.js 20+ installed. On the FaucetPay side, register an account, deposit a small test balance, and generate an API key under Merchant → Faucet API. Keep that key private — it authorizes payouts from your balance.
Scaffold a fresh Laravel 13 project with the Inertia + Vue starter kit:
composer create-project laravel/laravel crypto-faucet
cd crypto-faucet
composer require laravel/breeze --dev
php artisan breeze:install vue
npm install && npm run dev
This laravel 13 tutorial assumes you keep authentication in place — you’ll tie claims to registered users, which makes rate limiting and abuse tracking far easier than an anonymous faucet.
Database Architecture
Two schema changes drive the whole laravel crypto app: extend the users table and add a claims table.
Extending the users table
Schema::table('users', function (Blueprint $table) {
$table->string('faucetpay_address')->nullable();
$table->timestamp('last_claim_at')->nullable();
$table->unsignedBigInteger('balance')->default(0); // in satoshis
});
Store balances as integers (satoshis or the smallest unit of your coin). Floating-point math on currency is a classic bug — 0.1 + 0.2 !== 0.3 will eventually corrupt your accounting.
The claims table
Schema::create('claims', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('amount');
$table->string('currency', 10);
$table->string('ip_address', 45);
$table->string('payout_id')->nullable();
$table->enum('status', ['pending', 'success', 'failed'])->default('pending');
$table->timestamps();
});
The ip_address column uses length 45 to accommodate IPv6. The payout_id stores FaucetPay’s transaction reference so you can reconcile disputes later.
Building the FaucetPay API Service
Wrap all API interaction in a dedicated service class rather than calling HTTP from your controller. This keeps the controller thin and makes the faucetpay api logic testable.
First, register credentials in config/services.php:
'faucetpay' => [
'key' => env('FAUCETPAY_API_KEY'),
'currency' => env('FAUCETPAY_CURRENCY', 'DOGE'),
'base_url' => 'https://faucetpay.io/api/v1',
],
And in .env:
FAUCETPAY_API_KEY=your_secret_key
FAUCETPAY_CURRENCY=DOGE
Now the service class:
namespace App\Services;
use Illuminate\Support\Facades\Http;
class FaucetPayService
{
protected string $key;
protected string $baseUrl;
public function __construct()
{
$this->key = config('services.faucetpay.key');
$this->baseUrl = config('services.faucetpay.base_url');
}
public function sendPayout(string $to, int $amount, string $currency, string $ip): array
{
$response = Http::asForm()->post("{$this->baseUrl}/send", [
'api_key' => $this->key,
'amount' => $amount,
'to' => $to,
'currency' => $currency,
'ip_address'=> $ip,
]);
return $response->json();
}
public function checkBalance(string $currency): array
{
return Http::asForm()->post("{$this->baseUrl}/balance", [
'api_key' => $this->key,
'currency' => $currency,
])->json();
}
}
Gotcha: FaucetPay expects application/x-www-form-urlencoded, not JSON. If you use Http::post() without asForm(), you’ll get a status 0 error with a cryptic message. The ip_address field is mandatory for the Faucet API — passing it lets FaucetPay filter proxies on their end.
Creating the Business Logic & Controller
The controller is where security lives. The single most important lesson from running a live faucet: never trust the cooldown check alone to prevent double claims. Concurrent requests can both pass the timer check before either writes last_claim_at, letting a scripted attacker claim twice.
Use an atomic lock to close that race condition:
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
public function claim(Request $request, FaucetPayService $faucetPay)
{
$user = $request->user();
$lock = Cache::lock("claim:{$user->id}", 10);
if (! $lock->get()) {
return back()->withErrors(['claim' => 'Please wait, request in progress.']);
}
try {
// 1. Cooldown check
$cooldown = 60; // minutes
if ($user->last_claim_at && $user->last_claim_at->diffInMinutes(now()) < $cooldown) {
return back()->withErrors(['claim' => 'Cooldown active.']);
}
// 2. Verify CAPTCHA (see frontend section)
$this->verifyCaptcha($request->input('cf-turnstile-response'), $request->ip());
$amount = 10; // satoshis / smallest unit
$currency = config('services.faucetpay.currency');
// 3. Payout inside a DB transaction
$result = DB::transaction(function () use ($user, $faucetPay, $amount, $currency, $request) {
$claim = $user->claims()->create([
'amount' => $amount,
'currency' => $currency,
'ip_address' => $request->ip(),
'status' => 'pending',
]);
$response = $faucetPay->sendPayout(
$user->faucetpay_address, $amount, $currency, $request->ip()
);
if (($response['status'] ?? null) !== 200) {
$claim->update(['status' => 'failed']);
throw new \RuntimeException($response['message'] ?? 'Payout failed');
}
$claim->update([
'status' => 'success',
'payout_id' => $response['payout_id'] ?? null,
]);
$user->update(['last_claim_at' => now()]);
$user->increment('balance', $amount);
return $claim;
});
return back()->with('success', 'Payout sent! Amount: ' . $result->amount);
} finally {
$lock->release();
}
}
The FaucetPay Faucet API returns HTTP-style codes inside the JSON body: 200 is success, 456 means the user is on a blacklist, and 500-range codes indicate insufficient faucet balance. Always branch on $response['status'] and log the raw response for failed claims.
Building the Inertia Frontend Component
Pass the claim state as props from the controller when rendering the page:
return Inertia::render('Faucet', [
'canClaim' => $canClaim,
'nextClaimAt' => $user->last_claim_at?->addMinutes(60)->timestamp,
'rewardAmount'=> 10,
'balance' => $user->balance,
]);
The Vue component handles the countdown client-side so the timer feels live without polling the server:
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useForm, usePage } from '@inertiajs/vue3'
const props = defineProps(['canClaim', 'nextClaimAt', 'rewardAmount', 'balance'])
const remaining = ref(0)
const form = useForm({ 'cf-turnstile-response': '' })
const isReady = computed(() => remaining.value <= 0)
function tick() {
remaining.value = Math.max(0, props.nextClaimAt - Math.floor(Date.now() / 1000))
if (!isReady.value) setTimeout(tick, 1000)
}
onMounted(tick)
function submit() {
form.post('/claim', { preserveScroll: true })
}
</script>
<template>
<div class="max-w-md mx-auto p-6 bg-slate-800 rounded-xl text-white">
<p class="text-2xl font-bold">Balance: {{ balance }} sats</p>
<div v-if="$page.props.flash.success" class="text-green-400">
{{ $page.props.flash.success }}
</div>
<div v-if="form.errors.claim" class="text-red-400">
{{ form.errors.claim }}
</div>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY"></div>
<button
:disabled="!isReady || form.processing"
@click="submit"
class="mt-4 w-full py-3 rounded bg-emerald-500 disabled:opacity-40">
<span v-if="isReady">Claim {{ rewardAmount }} sats</span>
<span v-else>Next claim in {{ remaining }}s</span>
</button>
</div>
</template>
I recommend Cloudflare Turnstile over classic reCAPTCHA — it’s privacy-friendly, free at any volume, and less intrusive for real users while still blocking basic bots. Verify the token server-side in your verifyCaptcha() helper by POSTing to https://challenges.cloudflare.com/turnstile/v0/siteverify with your secret and the visitor’s IP.
Anti-Bot Protection & Security
Bots are the reason most faucets die. Layer your defenses:
- Pass the real IP to FaucetPay. Their API cross-references IPs against known proxy/VPN databases and rejects flagged addresses — free filtering you get by simply sending
ip_address. - Rate limit the route at the framework level so even a valid session can’t hammer the endpoint:
Route::post('/claim', [FaucetController::class, 'claim'])
->middleware(['auth', 'throttle:5,1']);
Behind a reverse proxy (Nginx, Cloudflare), make sure TrustProxies is configured so $request->ip() returns the client’s real address rather than your load balancer’s. Getting this wrong means every user shares one IP and rate limiting collapses.
For extra hardening, run suspicious IPs through a proxy-detection service (IPQualityScore, proxycheck.io) and store the risk score on the claim record. Block above a threshold rather than deleting — you’ll want the audit trail.
Testing & Deployment Tips
Test with the smallest payout amount your currency allows so a mistake costs fractions of a cent. Mock FaucetPayService in your feature tests to assert the cooldown, lock, and transaction behavior without spending real coins:
$this->mock(FaucetPayService::class)
->shouldReceive('sendPayout')
->andReturn(['status' => 200, 'payout_id' => 'test123']);
If you want automatic housekeeping — resetting daily bonus counters, retrying failed payouts, or reconciling balances — use the Laravel scheduler:
Schedule::command('faucet:reconcile')->hourly();
In production, store FAUCETPAY_API_KEY only in server environment variables or a secrets manager — never commit .env. Run php artisan config:cache on deploy, and rotate the key immediately if it ever appears in logs or a stack trace.
Conclusion & Next Steps
You now have a functional faucet: atomic payouts through the FaucetPay API, race-condition-safe claim logic, CAPTCHA and rate limiting, and a reactive Inertia + Vue interface. This is the core loop every faucet in web development is built on.
To grow it into a sustainable passive income crypto product, consider adding:
- Referral system — pay users a percentage of their invitees’ claims to drive organic traffic.
- Shortlinks — integrate URL shorteners so users unlock bonus claims, offsetting your payout costs with ad revenue.
- Daily bonuses and VIP tiers — reward retention with streak multipliers and higher payout rates for loyal users.
Frequently Asked Questions
What is a crypto faucet?
A crypto faucet is a website that gives visitors tiny amounts of cryptocurrency at set intervals, usually funded by advertising and shortlink revenue. Faucets attract crypto traffic and are a common entry point for new users learning to receive digital currency.
Is the FaucetPay API free to use?
Yes. FaucetPay provides its Faucet API at no cost. You only fund the balance you pay out to users, and FaucetPay handles the micro-transaction settlement, which avoids expensive on-chain fees for satoshi-sized payments.
How do I prevent bots from draining my faucet?
Combine several layers: a CAPTCHA like Cloudflare Turnstile, Laravel route rate limiting (throttle middleware), passing the real user IP to FaucetPay for proxy filtering, and atomic locks to block concurrent double claims.
Why use Laravel 13 with Inertia.js and Vue for this?
Laravel 13 handles secure server-side payout logic and database transactions, while Inertia.js with Vue 3 delivers a single-page reactive UI — like a live countdown timer — without maintaining a separate API. It’s the fastest path to a modern laravel crypto app.
How are payouts calculated to avoid rounding errors?
Store all balances and amounts as integers in the smallest unit (satoshis), never as floats. Floating-point arithmetic causes precision errors that corrupt financial records over time.
