Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Webhooks: Design and Security

By Kokil Thapa | Last reviewed: September 2026

Webhooks: Design and Security is where many integrations quietly break. A payment gateway fires a callback, your app updates an order twice, or a forged POST marks an invoice paid. On production Laravel and eCommerce systems I maintain, webhooks sit on the critical path for API development and third-party integrations. They push events instead of polling. That speed is useful. It also shifts failure modes to your server, your queue, and your database. This guide covers webhook design patterns, verification, retries, and the security controls that keep callbacks trustworthy under real traffic.

What Webhooks Design and Security Problems Do Production Teams Hit First?

A webhook is an HTTP POST from a provider to your URL when something changes. Stripe sends payment_intent.succeeded. Shopify sends orders/create. Khalti or eSewa send payment status updates on Nepal checkout flows. Your endpoint must accept the payload, verify authenticity, and update state exactly once.

The first failure is usually architectural. Teams treat webhooks like internal API calls. They run heavy logic synchronously, return HTTP 500 on a slow query, and trigger aggressive provider retries. The second failure is security. Endpoints are public URLs. Anyone who discovers them can POST fake events unless you verify signatures. The third is idempotency. Providers retry on timeouts. Without deduplication, you ship twice or credit a wallet twice.

On a legal-tech portal with document payments, I have seen duplicate webhook processing create two receipt records for one bank transfer. The fix was not more logging. It was a unique constraint on the provider event ID plus a queue worker that processed events asynchronously. Good webhook design patterns for reliability start with that mindset: verify first, dedupe second, process third.

Webhooks vs PollingPolling APIYour cron asks repeatedlyHigher load, simpler opsWebhooksProvider pushes eventsLower latency, harder opsSecurity Must-HavesHMAC signatures, HTTPS only, idempotency keysFast 2xx response, async queue processing
Webhooks: Design and Security starts by accepting that push events trade polling simplicity for verification and deduplication work on your side.

How Should You Design a Webhook Receiver Endpoint?

Design webhook receivers around three responsibilities: authenticate the sender, acknowledge quickly, and process reliably offline. Never mix all three in one slow request cycle if you can avoid it.

Step 1: Use a dedicated route with no session middleware

Webhook routes should skip CSRF protection and cookie sessions. In Laravel 13 on PHP 8.3+, exclude the URI in bootstrap/app.php or middleware configuration. Use a route like POST /webhooks/stripe that maps to a thin controller.

// routes/web.php
Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle'])
    ->middleware('webhook.stripe');

Step 2: Read the raw body before parsing JSON

Signature verification requires the exact byte sequence the provider signed. If Laravel parses JSON first and you re-encode it, the signature check fails intermittently. Read request()->getContent() and pass that string to verification logic.

Step 3: Return 2xx only after persistence, not after full business logic

The safe pattern stores the raw event in a webhook_events table, enqueues a job, and returns HTTP 200. Business rules run in the queue worker. If processing fails, you retry from your queue without forcing the provider to redeliver.

  1. Verify signature and timestamp.
  2. Insert event row with a unique provider event ID.
  3. Dispatch a job to process the event.
  4. Return HTTP 200 with a minimal JSON body.
  5. Mark the row processed after successful side effects.

This mirrors how I structure payment callbacks on Laravel eCommerce projects with local payment gateways. The HTTP layer stays fast. The order state machine runs in a worker you control.

Webhook Receiver PipelineHTTP POSTRaw bodyVerifyHMAC + timePersistUnique IDQueue JobAsync work200Worker Side EffectsUpdate order, send email, sync inventoryMark event processed with timestamp
Production webhook design separates HTTP acknowledgment from business processing via a persisted event and queue worker.

How Do You Secure Webhook Endpoints Against Forgery and Replay?

Webhook security is not optional. Public URLs get scanned. Competitors probe endpoints. Attackers replay captured payloads. Your job is to make every request prove it came from the provider and is still timely.

Verify HMAC signatures on the raw payload

Stripe, Shopify, GitHub, and most mature providers sign payloads with HMAC-SHA256 using a shared secret. Compare signatures with hash_equals() to prevent timing attacks. Never roll your own crypto primitives.

$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$event = \Stripe\Webhook::constructEvent(
    $payload,
    $signature,
    config('services.stripe.webhook_secret')
);

The official Stripe webhook signature documentation shows the expected header format and tolerance window. Read provider docs literally. Header names and algorithms differ.

Reject stale timestamps to block replay attacks

Many providers embed a timestamp in the signed payload. Reject events older than five minutes unless the provider documents a different window. Store processed event IDs in Redis or MySQL with a TTL to block duplicate delivery within that window.

Enforce HTTPS and restrict methods

Webhook URLs must use TLS 1.2 or higher. Terminate SSL at your load balancer or web server. Accept only POST on webhook routes. Return 405 for GET probes. Rate-limit by IP at the edge if the provider publishes IP ranges.

These controls align with broader API security checklist practices. Webhooks are inbound APIs with no user login. Signature verification is your authentication layer.

ControlWhat it stopsTypical implementation
HMAC signatureForged payloadshash_equals() on raw body
Timestamp toleranceReplay of old eventsReject if skew > 300 seconds
Unique event IDDuplicate processingDB unique index on provider ID
HTTPS onlyMITM tamperingRedirect HTTP to HTTPS, HSTS
Secret rotationLeaked signing keysDual secrets during rollover
IP allowlistRandom internet POSTsEdge firewall or middleware

How Should Webhook Retries and Idempotency Be Designed?

Providers retry when they see non-2xx responses or timeouts. AWS and Stripe document exponential backoff over hours. Your design must assume the same event arrives more than once.

Store a provider-scoped idempotency key

Every major provider includes a unique event ID. Persist it before side effects. Use a database unique constraint so concurrent workers cannot both pass the insert check.

Schema::create('webhook_events', function (Blueprint $table) {
    $table->id();
    $table->string('provider', 32);
    $table->string('event_id', 128);
    $table->string('event_type', 128);
    $table->json('payload');
    $table->timestamp('processed_at')->nullable();
    $table->timestamps();
    $table->unique(['provider', 'event_id']);
});

On duplicate insert, return 200 anyway. The provider already got a failure or timeout once. Arguing with retries causes retry storms.

Make business handlers idempotent too

Event-level deduplication is not enough if your handler partially succeeds. Use idempotent updates: UPDATE orders SET status = 'paid' WHERE id = ? AND status != 'paid'. For financial actions, store a ledger entry keyed by event ID. Never rely on "we probably won't get this twice."

Compare this with background jobs versus cron design. Webhooks are event triggers. Cron polling is a fallback when callbacks fail silently. Many production systems use both.

Provider Retry vs Idempotent ReceiverAttempt 1: timeout — provider schedules retryAttempt 2: 200 OK — event stored, job queuedAttempt 3: duplicate event_id — skip, return 200Outcome: one order update, no double chargeUnique constraint + processed_at flag
Idempotent webhook design returns success on duplicate deliveries so providers stop retrying without creating duplicate side effects.

How Do You Send Outbound Webhooks Safely From Your Own API?

If your platform exposes webhooks to customers, you inherit the provider role. Design outbound delivery with the same rigor you expect inbound.

Sign every payload and version your schema

Generate a per-endpoint secret on subscription creation. Sign timestamp + '.' + raw_json with HMAC-SHA256. Document header names, event types, and sample payloads in your API docs. Follow patterns from REST API design best practices for versioning and changelogs.

Implement delivery logs and manual replay

Store each delivery attempt: URL, response code, latency, payload hash. Expose a dashboard or API for customers to replay failed events. On client portals with payment notifications, replay tooling reduces support tickets when a partner firewall blocked one callback.

Use exponential backoff with a dead-letter state

Retry 5xx and network errors. Do not retry 4xx except 429 with Retry-After. Cap attempts at 24–72 hours depending on SLA. Move permanently failing endpoints to a disabled state and email the account owner.

// Pseudocode for outbound delivery job
$attempt = $delivery->attempts + 1;
$delay = min(3600, pow(2, $attempt) * 30); // cap at 1 hour
WebhookDeliveryJob::dispatch($delivery)
    ->delay(now()->addSeconds($delay));

When building outbound hooks for a multi-tenant SaaS, see multi-tenant database design for scoping webhook subscriptions per tenant with row-level isolation.

Outbound Delivery DecisionsPOST event2xx responseMark delivered5xx / timeoutBackoff retry410 / bad URLDisable endpointMax attemptsDead letter queue
Outbound Webhooks: Design and Security requires explicit retry, disable, and dead-letter rules instead of infinite redelivery loops.

What Laravel and PHP Patterns Work Best for Webhook Production?

Laravel 13 on PHP 8.3+ gives you queues, middleware, and Form Requests. None of them replace webhook-specific design. They accelerate it when applied correctly.

Use middleware for verification only

Keep middleware focused on signature and timestamp checks. Throw a dedicated exception that renders HTTP 400 for bad signatures. Log verification failures with source IP and user agent. Do not log full secrets or signing keys.

Queue on Redis 8.10 with explicit connection names

Isolate webhook traffic on a webhooks queue. Scale workers independently from default queue workers. Failed jobs land in failed_jobs for inspection. Horizon or supervisor configs should list this queue explicitly on production Ubuntu servers.

Test with provider CLI tools and fixture payloads

Stripe CLI forwards events to localhost. Shopify offers similar tooling. Write PHPUnit tests that POST signed fixtures using known secrets. Validate both happy path and tampered signature cases. Pair this with guidance from Laravel webhooks send and receive reliably for end-to-end coverage.

For JSON payload inspection during development, the JSON formatter tool helps compare provider samples against your normalized storage shape. Use regex tester when parsing non-standard header formats from regional payment gateways.

Composer 2.10 manages dependencies like stripe/stripe-php cleanly. Pin major versions in composer.json. Run composer audit in CI before deploy. On shared Deployer 7 pipelines I maintain, webhook code deploys with the same zero-downtime symlink swap as the rest of the app. Remember to reload PHP-FPM so opcache picks up middleware changes.

WordPress and WooCommerce 11.1 sites receive webhooks through REST plugins or custom endpoints. Magento 2.4.x uses async consumers for some integrations. Shopify Admin API 2026-07 webhooks require HMAC validation identical in principle to Laravel middleware. Platform syntax differs. Security rules do not.

Monitor and alert on webhook health

Track metrics: verification failure rate, queue latency, duplicate event count, unprocessed events older than ten minutes. Alert when verification failures spike. That often means a rotated secret was not updated in production .env. Cross-check OAuth security practices if webhooks complement token-based API auth in the same integration.

Server hardening matters too. Webhook endpoints still run on your stack. Follow Ubuntu server security best practices and keep TLS certificates current via Let's Encrypt. For high-value payment flows, pair webhook processing with manual reconciliation reports until you trust automated matching.

Need implementation help on a new integration? See custom software development services or eCommerce development for checkout and gateway work. Existing systems benefit from testing and optimization once webhook logs reveal latency bottlenecks.

Key Takeaways

  • Verify every inbound webhook with HMAC on the raw body before any business logic runs.
  • Persist provider event IDs with a unique constraint and return HTTP 200 on duplicates.
  • Acknowledge fast, process async via queues, and keep webhook routes outside CSRF middleware.
  • Sign outbound payloads, log delivery attempts, and disable endpoints that return persistent 4xx errors.
  • Monitor verification failures and queue backlog — they usually mean secrets, timeouts, or worker capacity issues.
  • Test with signed fixtures and provider CLI tools; never rely on manual POST from Postman alone.

People Also Ask

What is the difference between a webhook and a REST API callback?

A webhook is an HTTP POST initiated by the provider when an event occurs. Your server passively receives it. A REST API callback usually means your app calls the provider first and registers a URL. The transport is similar. Ownership of the HTTP request direction differs. Webhooks push. Polling APIs pull.

Should webhook endpoints require authentication headers?

Provider signatures are the primary authentication mechanism for inbound webhooks. Additional Bearer tokens are rare and often break provider retries. Use HMAC verification, HTTPS, timestamp checks, and optional IP allowlists instead of session cookies or CSRF tokens.

What HTTP status code should you return for invalid webhook signatures?

Return HTTP 400 for invalid signatures so the provider knows the payload was rejected intentionally. Return HTTP 500 only when your server failed after accepting a valid event. Many teams return 200 on duplicates after the first successful persist to stop retry storms.

How long do payment providers retry failed webhooks?

Stripe retries with exponential backoff for up to three days. Other gateways vary from hours to a week. Design for at-least-once delivery. Assume the same event ID may arrive many times across that window.

Ship Webhook Integrations You Can Trust

Webhooks: Design and Security is not a one-time config task. It is ongoing operations: rotated secrets, queue capacity, idempotent handlers, and delivery logs you can audit. Start with verify-store-queue-respond. Add monitoring before launch day, not after duplicate charges appear. If you are wiring payment gateways, SaaS notifications, or partner event feeds into a Laravel or PHP stack, the patterns above are the baseline I use on production systems.

Ready to harden an existing integration or design webhooks for a new product? Contact us to review your callback flow, or explore the portfolio for examples of payment and booking systems that depend on reliable event delivery. For related reading, see JWT security vulnerabilities when webhooks complement token auth, and visit kokil.com.np for more engineering guides aimed at builders in Nepal and worldwide.

Frequently Asked Questions

A webhook is an HTTP POST from a provider to your URL when something changes, such as Stripe sending payment_intent.succeeded or Shopify sending orders/create.

Both use HTTP, but the direction differs. A webhook is provider-initiated: the provider pushes an event to your server when something happens. A REST API callback usually means your app registers a URL with the provider first, then receives posts after your own API call. Webhooks replace polling. Your server passively receives push events instead of repeatedly asking whether state changed. The transport looks similar. Ownership of who opens the HTTP request does not.

Three failures show up early. Architectural: teams treat webhooks like internal API calls, run heavy logic synchronously, return HTTP 500 on slow queries, and trigger aggressive provider retries. Security: endpoints are public URLs, so anyone who discovers them can POST fake events unless signatures are verified. Idempotency: providers retry on timeouts, and without deduplication you ship twice or credit a wallet twice. On a legal-tech portal with document payments, duplicate webhook processing once created two receipt records for one bank transfer until a unique constraint on the provider event ID fixed it.

Design around three jobs: authenticate the sender, acknowledge quickly, and process reliably offline. Use a dedicated POST route that skips CSRF and session middleware, such as POST /webhooks/stripe with provider-specific middleware. Read the raw body with request()->getContent() before JSON parsing so signature checks use the exact signed bytes. Persist the event to a webhook_events table, dispatch a queue job, and return HTTP 200 before business rules run. Mark the row processed only after side effects succeed. This keeps the HTTP layer fast while order state machines run in workers you control.

HMAC signature verification requires the exact byte sequence the provider signed. If Laravel or your framework parses JSON first and you re-encode the payload, whitespace, key order, or encoding can change. The recomputed signature then fails intermittently even for legitimate events. Always pass request()->getContent() as a string to verification logic. Parse JSON only after the signature check passes. This applies to Stripe, Shopify, GitHub, and regional gateways like Khalti or eSewa that follow the same signing principle with different header names.

Provider HMAC signatures are the primary authentication for inbound webhooks. Additional Bearer tokens are rare and often break provider retries because the sender cannot attach custom auth on every redelivery. Skip session cookies and CSRF tokens on webhook routes. Rely on HMAC verification on the raw body, HTTPS, timestamp tolerance to block replay, and optional IP allowlists if the provider publishes ranges. Webhooks are inbound APIs with no user login. The signature is your authentication layer.

Verify HMAC-SHA256 signatures on the raw payload using the provider shared secret and hash_equals() to prevent timing attacks. Reject stale timestamps, typically events older than five minutes unless the provider documents a different window. Store processed event IDs in Redis or MySQL with a TTL to block duplicate delivery within that window. Enforce HTTPS with TLS 1.2 or higher, accept only POST and return 405 for GET probes, and rate-limit by IP at the edge when provider IP ranges are known. Rotate signing secrets with dual-secret rollover during key changes.

Return HTTP 400 for invalid signatures so the provider knows the payload was rejected intentionally. Return HTTP 500 only when your server failed after accepting a valid event.

Assume every event arrives more than once because providers retry on non-2xx responses or timeouts. Stripe and AWS document exponential backoff over hours. Persist a provider-scoped idempotency key with a database unique constraint on provider plus event_id before any side effects so concurrent workers cannot both pass the insert. On duplicate insert, return HTTP 200 anyway to stop retry storms. Make business handlers idempotent too, such as UPDATE orders SET status = paid WHERE id = ? AND status != paid. For financial actions, store ledger entries keyed by event ID.

Return HTTP 200 after confirming the event ID already exists in your webhook_events table. The provider already saw a failure or timeout once and is retrying. Arguing with retries causes retry storms and can duplicate side effects if you process again. Persist first, dedupe second, process third. A unique index on provider and event_id makes duplicate detection atomic. Business logic should still use idempotent updates so partial handler success cannot create duplicate charges or shipments even if deduplication is bypassed.

Stripe retries with exponential backoff for up to three days. Other gateways vary from hours to several days.

When your platform exposes webhooks to customers, you inherit the provider role. Generate a per-endpoint secret on subscription and sign timestamp plus raw JSON with HMAC-SHA256. Version your schema and document header names, event types, and sample payloads. Store each delivery attempt with URL, response code, latency, and payload hash. Expose replay tooling for failed events. Retry 5xx and network errors with exponential backoff capped around one hour per attempt, over 24 to 72 hours depending on SLA. Do not retry most 4xx except 429 with Retry-After. Disable endpoints that persistently fail and notify the account owner.

On Laravel 13 with PHP 8.3 or higher, use middleware focused only on signature and timestamp checks, throwing a dedicated exception that renders HTTP 400 for bad signatures. Queue webhook jobs on Redis 8.10 with an isolated webhooks connection so workers scale separately from default queues. Exclude webhook URIs from CSRF in bootstrap/app.php or middleware config. Test with Stripe CLI and PHPUnit fixtures posting signed payloads. Pin stripe/stripe-php major versions via Composer 2.10 and run composer audit in CI. After Deployer 7 symlink swaps, reload PHP-FPM so opcache picks up middleware changes.

Process asynchronously via a queue worker whenever possible. The safe pattern verifies the signature, inserts the raw event, dispatches a job, and returns HTTP 200 within the provider timeout window. Business rules such as order updates, wallet credits, or document receipt generation run in the worker. If processing fails, retry from your queue without forcing the provider to redeliver. Synchronous processing on the HTTP thread causes timeouts, triggers provider retries, and mixes acknowledgment with heavy database work. I use this split on Laravel eCommerce projects with local payment gateways.

Track verification failure rate, queue latency, duplicate event count, and unprocessed events older than ten minutes. Alert when verification failures spike, which often means a rotated webhook secret was not updated in production .env. Inspect failed_jobs for webhook queue workers. Log verification failures with source IP and user agent but never log full secrets. For high-value payment flows, pair automated webhook processing with manual reconciliation reports until automated matching is trusted. Keep TLS certificates current via Let's Encrypt since webhook endpoints remain public attack surface on your stack.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: