- Knowledge
- technology
- OOP
- Tips
- Programming
- Tips
- Tutorial
- SEO
- Ranking
- Knowledge
- Special Day
- Seo
- Bug
- Data science
- Seo
- artificial intelligence
- Machine Learning
- Robotics
- happyNewYear2021
- newYearEve
- 2021
- Automation
- Smart Home
- Career
- Best Practices
- Git
- Logging
- Web Fundamentals
- DNS
- HTTPS
- Performance
- AI Tools
- ChatGPT
- Claude
- Gemini
- Laravel
- Eloquent
- MySQL
- HTTPS
- TLS
- Web Security
- Certificates
- Developer Life
- Debugging
- Docker
- DevOps
- Transactions
- Queues
- LLMs
- AI
- AI Coding
- Developer Tools
- React Native
- Expo
- Kate PMS
- Mobile Apps
- Laravel
- Authentication
- Sanctum
- Cookies
- API Design
- Payments
- Idempotency
- DeepSeek
- Open Source AI
- LLMs
- AI News
- Git
- Version Control
- AI Coding
- Prompting
- PHP
- Checklist
- MCP
- AI Agents
- OpenAI
- Architecture
- Microservices
- Modular Monolith
- Estimation
- Developer Life
- Project Planning
- Humour
- OAuth
- OpenID Connect
- Authentication
- Embeddings
- Vector Search
- RAG
- pgvector
- OpenAI
- GPT-4.1
- Codex CLI
- Events
- Testing
- Clean Code
- Maintainability
- Code Review
- Webhooks
- API
- Security
- Claude Code
- Workflow
- AI
- LLM
- Prompt Injection
- Mobile
- React
- Networking
- TCP
- UDP
- HTTP/3
- CLAUDE.md
- AWS
- Cloud Security
- Backups
- PHPUnit
- Software Engineering
- Leadership
- Communication
- RAG
- Embeddings
- AI Engineering
- IT Infrastructure
- Networking
- Access Control
- CI/CD
- GitHub Actions
- Gemini CLI
- Claude Code
- JavaScript
- Async/Await
- Node.js
- Promises
- Security
- Cryptography
- Passwords
- MySQL
- Database
- Vibe Coding
- Software Quality
- DNS
- Code Reading
- Onboarding
- Productivity
- Background Jobs
- Developer Humour
- Estimates
- Dev Life
- JWT
- o3-mini
- DeepSeek R1
- Rate Limiting
- Kate PMS
- E-Signing
- Audit Trail
- REST
- GraphQL
- API Design
- Laravel 12
- Upgrade Guide
- Open Source
- Self-Hosting
- Task Scheduling
- Cron
- Secrets
- CORS
- PHP
- PHP-FPM
- OPcache
- GitHub Copilot
- Software Architecture
- Engineering
- TypeScript
- JavaScript
- Type Safety
- AI Security
- React Native
- Product Design
- AI Agents
- Kiro
- Queues
- Redis
- RabbitMQ
- AWS SQS
- Nginx
- Apache
- GPT-5
- gpt-oss
- Clean Code
- Architecture
- Naming
- Documentation
- Career
- ADR
- Teamwork
- Supply Chain
- Kate HRM
- HR Software
- Permissions
- System Design
- Pagination
- SSH
- Linux
- Big O
- Databases
- Laravel Boost
- MCP
- Developer Skills
- Validation
- Databases
- Indexes
- Code Quality
- Deployment
- Developer Humour
- Feature Flags
- Code Review
- Pull Requests
- Docker
- Cursor
- Authorization
- RBAC
- Gemini
- Long Context
- PHP 8.4
- Caching
- Dependency Injection
- Web Performance
- Browser
- CSS
- Database
- Migrations
- ChatGPT
- AI for Developers
- Monitoring
- On-Call
- REST
- Backend
- SQL
- NoSQL
- Database Design
- Coding Agents
- Claude 4
- API Resources
- REST API
- Load Balancing
- Scaling
- AWS
- AI Tools
- Claude
- Sora 2
- CTE
- 2FA
- TOTP
- Programming Languages
- Prompts
- Developer Workflow
- API Gateway
- APIs
- Passport
- API Auth
- Learning
- Burnout
- Developer Growth
- Web Development
- SEO
- Kate Mall
- ChatGPT Atlas
- Agent Skills
- Middleware
- Laravel 12
- Collections
- Context Window
- Monitoring
- Commit Messages
- Self Review
- Growth
- Regex
- Programming Basics
- Text Processing
- Database Design
- Normalization
- Linux
- Server Security
- Linux Foundation
- Open Standards
- Legacy Code
- Documentation
- AI Workflow
- File Uploads
- Test Data
- Hashing
- Performance
- Caching
- Enums
- Scope Creep
- Estimation
- Codex
- Gemini CLI
- Timezones
- Carbon
- Bugs
- PHP 8.5
- Gemini 3
- GPT-5.1
- Data Integrity
- Event Loop
- Async
- Opus 4.5
- AI Models
- React
- Forms
- Frontend
- Backups
- AI Images
- DALL-E
- Midjourney
- Race Conditions
- Concurrency
- Legacy Code
- Refactoring
- Senior Engineer
- Scope
- LLM
- CDN
- Web
- Sub-Agents
- Soft Deletes
- Audit Log
- Concurrency
- AI Learning
- NestJS
- AI Evals
- Policies
- SPF DKIM DMARC
- Unicode
- UTF-8
- Knowledge Graph
- Value Objects
- Technical Debt
- Feature Flags
- Laravel Pennant
- Deployment
- Copilot
- Composer
- Dependencies
- Artisan
- Automation
- AWS S3
- Object Storage
- Cloud
- Small Language Models
- Ollama
- Production
- Sessions
- HTTP
- Mentoring
- SQL
- Virtual Machines
- Web Development
- HTTP/2
- QUIC
- Web Performance
- AI Integration
- LLM API
- SOLID
- OOP
- Hosting
- Serverless
- Merge Conflicts
- Temperature
- AI Development
- Reverse Proxy
- Nginx
- Infrastructure
- Verification
- Passkeys
- WebAuthn
- Teams
- Communication
- Stakeholders
- Monorepo
- CI/CD
- Versioning
- JSON Schema
- Livewire
- Inertia
- Meetings
- Distributed Systems
- Privacy
- Full-Stack
- T-Shaped Skills
- Money
- Notifications
- Web Security
- HTTP Headers
- CSP
- Function Calling
- Load Testing
- k6
- Data Extraction
- Debugging
- WebSockets
- SSE
- Real-Time
- Laravel Reverb
- Infrastructure as Code
- Terraform
- Side Projects
- Laravel Pint
- OpenAPI
- Swagger
- UX
- Multimodal
- Jest
- Pair Programming
- APIs
- Rate Limiting
- Resilience
- Dev Humour
- Design Tokens
- JWT
- API Keys
- Sessions
- PHPStan
- Rector
- Incidents
- Reporting
- Dashboards
- Zero Trust
- IAM
- Search
- Laravel Scout
- Junior Developers
- Mentoring
- Images
- WebP
- AVIF
- Bug Reports
- Let's Encrypt
- Design Docs
- Software Design
- Observers
- Replication
- Accountability
- Data Structures
- Reliability
- LLM Memory
- Error Handling
- Payments
- Payment Gateway
- Webhooks
- PCI DSS
- Observability
- OpenTelemetry
- Personal Brand
- Writing
- Conventions
- Dates
- Scheduling
- Disaster Recovery
- Compression
- Brotli
- Deadlines
- Developer Habits
- State Machines
- Tech Roles
- UUID
- ULID
- Horizon
- Planning
- Engineering Culture
- Ownership
- Soft Skills
- Socialite
- Cost Control
- Collations
- Unicode
- Octane
- PostgreSQL
Writing Webhooks You Can Trust: A Laravel Guide to Receiving Them Safely
About Post
Here's an uncomfortable way to look at a webhook endpoint: it's a public URL, with no login, that marks invoices as paid.
Your payment gateway calls it. So could anyone else who finds or guesses it. And even the genuine calls arrive twice, out of order, or while your database is busy, because that's how webhooks work.
The good news is that receiving webhooks safely is a solved problem with a clear recipe. Here it is, in Laravel 12, in the order a request flows through it.
The recipe at a glance
- Verify the signature, on the raw body.
- Reject replays with a timestamp check.
- Store the event, using its ID to ignore duplicates.
- Respond with a 2xx immediately.
- Process on a queue, idempotently, trusting the provider's API over the payload's order.
Step 1: verify the signature
Most providers sign each webhook with a shared secret using HMAC-SHA256. They compute a hash of the request body with the secret, and send it in a header. You compute the same hash on your side. If they match, the request came from someone who knows the secret, and the body wasn't changed on the way.
Header formats differ between providers, so check their docs. A common style puts a timestamp and signature together, like t=1790000000,v1=5f2b..., and signs timestamp.body. Here's a middleware for that style:
public function handle(Request $request, Closure $next): Response
{
parse_str(str_replace(',', '&', (string) $request->header('X-Signature')), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$signature = (string) ($parts['v1'] ?? '');
if (abs(time() - $timestamp) > 300) {
abort(401, 'Stale or missing timestamp.');
}
$expected = hash_hmac(
'sha256',
$timestamp.'.'.$request->getContent(),
config('services.payments.webhook_secret'),
);
if (! hash_equals($expected, $signature)) {
abort(401, 'Invalid signature.');
}
return $next($request);
}
Three details in there matter more than they look:
- Use the raw body.
$request->getContent()gives the exact bytes the provider signed. Re-encoding the parsed JSON changes spacing and key order, and the signature will never match. - Use
hash_equals(), not===. It compares in constant time, so an attacker can't learn the correct signature one character at a time by measuring how quickly you reject them. - Keep the secret in config, loaded from the environment, never in code. And plan for rotation: many providers let you have two active secrets during a changeover, so accept either for a short window.
Step 2: reject replays
A valid signed request stays valid forever. If someone captures one (from a log, a proxy, a misconfigured debugging tool), they could send it again next month.
That's what the timestamp check above is for. Because the timestamp is part of the signed content, it can't be changed without breaking the signature, and anything older than a few minutes is rejected. The duplicate check in step 3 covers replays inside that window.
Step 3: route it without CSRF, and store it first
Webhooks can't send a CSRF token, so put the route in routes/api.php (run php artisan install:api if your app doesn't have one yet), where CSRF doesn't apply. If it must live in web.php, exclude it in bootstrap/app.php with $middleware->validateCsrfTokens(except: ['webhooks/*']).
Route::post('/webhooks/payments', PaymentWebhookController::class)
->middleware(VerifyWebhookSignature::class);
The controller does almost nothing, on purpose. It saves the event and hands it to a queue:
public function __invoke(Request $request): Response
{
$payload = $request->json()->all();
// unique index on (provider, event_id)
$event = WebhookEvent::createOrFirst(
['provider' => 'payments', 'event_id' => $payload['id']],
['type' => $payload['type'], 'payload' => $payload],
);
if ($event->wasRecentlyCreated) {
ProcessPaymentWebhook::dispatch($event);
}
return response()->noContent();
}
createOrFirst() tries the insert and, if the unique index rejects it, returns the existing row. That makes duplicate deliveries harmless even when two copies arrive at the same moment, which a "check if it exists, then insert" approach can't promise.
Step 4: respond fast
Providers wait only a limited time for your response, and treat a timeout or an error as a failure. Then they retry, often with increasing delays, and some will eventually disable an endpoint that keeps failing.
So don't send emails, generate PDFs or call other APIs inside the request. Store, dispatch, return 204. The slow work happens in the job, where it can be retried on your terms. And when you do want a retry, a non-2xx response is your way of asking for one: return an error only if you genuinely failed to store the event.
Step 5: process idempotently, and don't trust the order
The job is where your business logic lives, and it has to assume the worst:
- It might run twice. Queue jobs can be retried. Check a
processed_atcolumn at the start and set it, inside a transaction, at the end. - Events can arrive out of order. "Payment refunded" can land before "payment succeeded". Don't let an older event overwrite newer state. For important objects, it's often safest to treat the webhook as a nudge and fetch the current state from the provider's API before acting.
- Unknown event types should be stored and ignored, not crash the job. Providers add new events over time.
The mindset: a webhook is a notification that something may have changed, delivered at least once, in no guaranteed order. Verify who sent it, store it once, and let a queued job work out what's actually true.
A few extras worth having
- Keep the stored events. The
webhook_eventstable doubles as an audit log and lets you replay processing after fixing a bug. - Monitor failures. Alert on failed jobs and on signature failures. A sudden burst of invalid signatures means either a rotated secret or someone probing.
- Don't log secrets, and think twice before logging full payloads that contain personal data.
- IP allowlists are a fine extra layer if your provider publishes its ranges, but never a replacement for signatures.
- If you'd rather not build it yourself, the
spatie/laravel-webhook-clientpackage implements this store-then-process pattern with signature validation.
The code here is simplified (no secret rotation, minimal error handling), but the structure is the real thing: thin endpoint, verified input, stored once, processed safely. For the queued side (retries, backoff, failed jobs), see the Laravel queues docs.
What's the strangest thing a webhook has done to you: duplicates, wrong order, or a provider that changed its payload without warning?

Be first to comment it...