Contents
- 1 How to build idempotent webhook handlers in PHP
- 2 What idempotency really means in webhook land
- 3 What goes wrong without idempotency
- 4 Mental model: webhooks as a write API you don’t control
- 5 Core building block: a stable event ID
- 6 Strategy 1: store processed event IDs
- 7 Async vs sync: don’t do everything in the webhook request
- 8 Data-level idempotency: unique constraints in your business tables
- 9 Security, validation, and idempotency together
- 10 Using Redis or cache for deduplication
- 11 Webhooks, PHP, and real-world failures
- 12 Laravel-style implementation (a quick sketch)
- 13 Testing idempotency like you mean it
- 14 When idempotency gets subtle
- 15 Bringing it back to us: PHP, work, and quiet reliability
How to build idempotent webhook handlers in PHP
There’s a particular kind of fear only backend developers know.
It’s 2:17 AM.
Production is “stable”.
Your coffee is cold, the office is quiet, and your monitoring dashboard shows a suspicious spike.
Then you see it: a payment processed twice.
Your code didn’t crash.
The logs don’t scream.
But the user’s card was charged two times because your webhook handler ran the same operation twice.
This is the kind of bug that doesn’t just break systems.
It breaks trust.
Friends, this is where idempotent webhook handlers stop being theory and become survival skills.
Let’s talk about how to build them in PHP — in a way you can genuinely rely on when the internet, the vendor, or your own infrastructure is having a bad day.
What idempotency really means in webhook land
In math or functional programming, idempotency is simple:
an operation is idempotent if running it multiple times has the same effect as running it once.
In webhooks, it’s a little more human, more messy.
Webhooks are at-least-once delivery by design. Providers can (and will) resend the same event when:
- Your endpoint times out
- You return a non-2xx response
- Their infrastructure hiccups
- Their retry mechanism simply over-delivers
Stripe does it. Shopify does it. GitHub does it. Everyone who takes reliability seriously does this.
That means:
- You will receive the same event multiple times
- You cannot demand “exactly-once” delivery from the provider
- You must guarantee “exactly-once processing” on your side
Idempotency, in practice, is you saying:
“Send me whatever you want, as many times as you want.
If I’ve already processed this event, I’ll smile, return 200 OK, and quietly do nothing.”
No double-charging. No duplicate orders. No re-sending “Welcome” emails on every retry. Just calm, predictable state.
What goes wrong without idempotency
Imagine this very standard PHP setup:
- You have a
/webhooks/striperoute - It reads JSON from
php://input - It decodes it, grabs
event->typeand does something in the database - On success, you return
200 OK
It works fine in staging.
It works fine in early production.
Then one day, your DB lock is slow, the webhook takes 9.5 seconds, Stripe’s timeout is 10 seconds, and Stripe goes:
“Hmm, that took too long. I’ll retry.”
Your first request finishes.
Your second request starts.
Both run the “create order” logic.
Now you have:
- Two orders
- One angry customer
- A support ticket that reads “I only clicked once”
The worst part?
Your logs look “normal”: two valid requests, two successful code paths. No exception stack trace to hold onto.
This is why idempotent webhook handlers in PHP aren’t a “nice to have”. They’re table stakes.
Mental model: webhooks as a write API you don’t control
The provider is effectively calling your API with write commands:
- “Create payment”
- “Mark invoice as paid”
- “Update subscription”
- “User joined a Zoom webinar”
- “Issue a refund”
You don’t manage the HTTP client, the retries, the timeouts, or the scheduling.
You only manage what happens after the request hits your server.
So you need a protocol between:
- The provider
- Your PHP code
- Your storage
That says:
“This event ID leads to this state transition.
This transition is applied once.
If the same event comes back, we acknowledge it — but we do not repeat the transition.”
Core building block: a stable event ID
Every serious webhook provider gives you a stable event identifier:
- Stripe: event
id - GitHub:
X-GitHub-Deliveryheader - Shopify:
X-Shopify-Webhook-Idheader - Asaas-style APIs: event
idin the payload
That ID is your anchor.
Even if the payload is slightly different, the event ID does not change across retries. You use that ID to decide:
- “Have I seen this before?”
- “Did I succeed processing it?”
- “Is this still in progress?”
If your provider doesn’t give you an explicit ID, sometimes you can build one from the payload (e.g. order_id or payment_id), but you need to understand what’s truly unique in their model. Never guess; read their docs carefully.
Strategy 1: store processed event IDs
This is the simplest and most common strategy:
- Create a table that stores event IDs you’ve processed
- Put a unique constraint on the event ID
- Before processing, insert a row with that event ID
- If the insert succeeds, you process the business logic
- If it fails due to a unique violation, you return
200 OKand skip processing
In plain language: “First time in? Come in. Second time? You’re already on the list.”
Table design example (MySQL)
CREATE TABLE webhook_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(64) NOT NULL,
event_id VARCHAR(191) NOT NULL,
status ENUM('PENDING', 'PROCESSING', 'DONE', 'FAILED') NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT NULL,
UNIQUE KEY uniq_provider_event (provider, event_id)
) ENGINE=InnoDB;
A few things to notice:
providerhelps when you integrate multiple systems (Stripe, GitHub, internal, etc.)(provider, event_id)is unique — one provider event processed oncestatusgives you visibility and recovery options- Storing the raw
payloadlets you reprocess later if needed
Basic PHP flow (framework-agnostic)
Let’s say this is a Stripe webhook:
<?php
function handleStripeWebhook(): void
{
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
// 1. Verify signature (HMAC, using Stripe's library or your own)
if (!verifyStripeSignature($rawBody, $signature)) {
http_response_code(400);
return;
}
$event = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
return;
}
$provider = 'stripe';
$eventId = $event['id'] ?? null;
$eventType = $event['type'] ?? null;
if (!$eventId || !$eventType) {
http_response_code(400);
return;
}
$pdo = getPdo(); // however you get your connection
try {
$pdo->beginTransaction();
// 2. Try to insert this event as PENDING
$stmt = $pdo->prepare(
'INSERT INTO webhook_events (provider, event_id, status, payload)
VALUES (:provider, :event_id, :status, :payload)'
);
$stmt->execute([
'provider' => $provider,
'event_id' => $eventId,
'status' => 'PENDING',
'payload' => $rawBody,
]);
// If we get here, it's the first time we see this event
$eventRowId = (int)$pdo->lastInsertId();
// 3. Mark as PROCESSING
$pdo->prepare(
'UPDATE webhook_events SET status = :status WHERE id = :id'
)->execute([
'status' => 'PROCESSING',
'id' => $eventRowId,
]);
// 4. Run business logic
processStripeEvent($eventType, $event);
// 5. Mark as DONE
$pdo->prepare(
'UPDATE webhook_events SET status = :status WHERE id = :id'
)->execute([
'status' => 'DONE',
'id' => $eventRowId,
]);
$pdo->commit();
http_response_code(200);
} catch (PDOException $e) {
// Unique constraint violation: duplicate event
if (isUniqueConstraintViolation($e)) {
$pdo->rollBack();
http_response_code(200);
return;
}
// Other DB error: rollback and signal error
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
// Log the error for later inspection
error_log('Stripe webhook error: ' . $e->getMessage());
http_response_code(500);
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log('Stripe webhook processing error: ' . $e->getMessage());
http_response_code(500);
}
}
The essence:
- If the insert fails due to uniqueness, we know this exact event has already been handled
- We still return
200 OK— because the provider only cares that you handled it, not that you ran the business logic again - Side effects (creating orders, changing balances, sending emails) only happen after the insert succeeds
Async vs sync: don’t do everything in the webhook request
One big mistake I see often in PHP codebases: doing heavy work inside the webhook request itself.
Examples of “heavy”:
- Calling another external API
- Running long database transactions
- Triggering big email campaigns
- Transforming large payloads or generating PDFs
The webhook provider usually has a short timeout (5–10 seconds is common). If you try to cram complex logic into that window, you’ll:
- Hit timeouts
- Trigger retries
- Stress your DB under load
A better pattern:
Verify → Deduplicate → Persist → ACK
Process later in a worker / queue / cron.
Even if you don’t have a full-blown queue like RabbitMQ or SQS, you can still treat webhook_events as a simple queue:
- The HTTP request writes a
PENDINGevent and returns200 OK - A worker script or cron job picks up
PENDINGevents and processes them - After processing, it marks them
DONEorFAILED
Example worker (simplified)
<?php
function runWebhookWorker(): void
{
$pdo = getPdo();
while (true) {
// 1. Fetch some PENDING events
$stmt = $pdo->query(
"SELECT * FROM webhook_events
WHERE status = 'PENDING'
ORDER BY id ASC
LIMIT 50
FOR UPDATE SKIP LOCKED"
);
$events = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($events)) {
// Sleep a bit to avoid hammering the DB
sleep(2);
continue;
}
foreach ($events as $event) {
try {
$pdo->beginTransaction();
// 2. Mark as PROCESSING
$pdo->prepare(
'UPDATE webhook_events SET status = :status WHERE id = :id'
)->execute([
'status' => 'PROCESSING',
'id' => $event['id'],
]);
$payload = json_decode($event['payload'], true);
processGenericWebhookEvent($event['provider'], $payload);
// 3. Mark as DONE
$pdo->prepare(
'UPDATE webhook_events SET status = :status WHERE id = :id'
)->execute([
'status' => 'DONE',
'id' => $event['id'],
]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log("Webhook worker error for event {$event['id']}: " . $e->getMessage());
$pdo->prepare(
'UPDATE webhook_events SET status = :status WHERE id = :id'
)->execute([
'status' => 'FAILED',
'id' => $event['id'],
]);
}
}
}
}
This is how you turn a fragile synchronous handler into a more reliable pipeline:
- HTTP request: cheap, fast, idempotent umbrella
- Worker: slow, stateful, retriable, under your control
If you’re on Laravel, Symfony, or another framework on Find PHP, you can lean on their queue systems instead of rolling your own loop.
Data-level idempotency: unique constraints in your business tables
Sometimes the webhook event is just metadata around an operation that already has its own unique identifier, like a payment_id or order_id.
In those cases, you can check idempotency closer to your domain:
- Have a
paymentstable with a uniqueprovider_payment_id - Have an
orderstable with a uniqueexternal_order_id
Your handler would:
- Insert into
paymentsorordersusing that external ID - Rely on the DB unique index to block duplicates
- Catch the unique violation and treat it as “already processed”
Example for a payments table:
CREATE TABLE payments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(64) NOT NULL,
provider_payment_id VARCHAR(191) NOT NULL,
amount_cents INT NOT NULL,
currency VARCHAR(10) NOT NULL,
status ENUM('PENDING', 'SUCCEEDED', 'FAILED') NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uniq_provider_payment (provider, provider_payment_id)
) ENGINE=InnoDB;
In the PHP handler:
try {
$stmt = $pdo->prepare(
'INSERT INTO payments (provider, provider_payment_id, amount_cents, currency, status)
VALUES (:provider, :provider_payment_id, :amount_cents, :currency, :status)'
);
$stmt->execute([
'provider' => 'stripe',
'provider_payment_id' => $paymentIdFromWebhook,
'amount_cents' => $amountInCents,
'currency' => $currency,
'status' => 'SUCCEEDED',
]);
// New payment created – continue normal flow
} catch (PDOException $e) {
if (isUniqueConstraintViolation($e)) {
// payment already exists, treat as idempotent success
http_response_code(200);
return;
}
throw $e;
}
Sometimes this approach is even simpler than a generic webhook_events table — and you can combine both.
Security, validation, and idempotency together
It’s easy to obsess about idempotency and forget something more basic: is this webhook even real?
Before you talk about event IDs, you must:
- Verify the webhook signature (HMAC, public key, whatever the provider uses)
- Make sure you validate against the raw request body (before any JSON manipulation)
- Do a constant-time compare of signatures to avoid timing leaks
- Optionally, use provider IP ranges, mTLS, or VPN if your threat model requires it
The rough order should be:
- Read raw body
- Verify signature
- Decode JSON
- Extract event ID
- Deduplicate (DB insert, cache check, etc.)
- Queue or process
Signature verification protects you from someone pretending to be Stripe or GitHub.
Idempotency protects you from the real Stripe or GitHub sending the same thing multiple times.
These are different problems, and you need both layers.
Using Redis or cache for deduplication
Sometimes you don’t want every webhook to hit your main database just to check if the event ID is new.
You might:
- Be under serious throughput (thousands of events per minute)
- Have a high-performance Redis cluster
- Want fast in-memory checks with time-limited retention
In that case:
- Use Redis to store event IDs with a TTL longer than the provider’s retry window
- On each request, do a
SETNXorSET key value NX PX <ttl> - If it returns “OK”, it’s the first time — you proceed
- If it returns nothing, it’s a duplicate — you return
200 OKand skip processing
This is especially useful for read-heavy or stateless API workers where persistent DB writes are a bottleneck.
Just remember: Redis is not a silver bullet. You must:
- Choose a TTL that covers the worst-case retry duration
- Accept that when TTL expires, a very late retry may be treated as new
- Decide whether that’s acceptable for your business logic
Webhooks, PHP, and real-world failures
At some point, you will have this sequence of events:
- Your queue worker is down
- The webhook endpoint is still accepting requests
- You log
PENDINGevents into the DB - Hours pass
- You restart workers
- They have to process a massive backlog of
PENDINGrows
Without idempotency, that restart could be catastrophic: thousands of duplicated operations, emails, or payments.
With idempotency, the worst case is mostly delay — which is frustrating, but fixable.
This is one of the reasons I like the explicit status approach in the DB:
- I can query all
FAILEDevents and inspect payloads - I can implement manual replay routes for admins
- I can add metrics to track how many webhooks are stuck
- I can build dashboards for ops and support
Suddenly, webhooks aren’t mysterious “black-box HTTP calls”.
They’re first-class citizens in your architecture, visible and understandable.
Laravel-style implementation (a quick sketch)
Many readers on Find PHP live inside Laravel, so let’s briefly translate the concepts.
You might create:
-
A
webhook_eventsmigration exactly like above -
A
WebhookEventEloquent model -
A controller that:
- Validates signature
- Upserts an event as
PENDING - Dispatches a job to process it
- Returns
200 OK
-
A
ProcessWebhookEventjob that:- Updates status to
PROCESSING - Runs application-specific logic
- Marks status as
DONEorFAILED
- Updates status to
A simplified controller method:
public function handleStripe(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
if (!$this->verifyStripeSignature($payload, $signature)) {
return response('Invalid signature', 400);
}
$data = json_decode($payload, true);
$eventId = $data['id'] ?? null;
if (!$eventId) {
return response('Missing event id', 400);
}
try {
$event = WebhookEvent::create([
'provider' => 'stripe',
'event_id' => $eventId,
'status' => 'PENDING',
'payload' => $payload,
]);
ProcessWebhookEvent::dispatch($event);
return response('OK', 200);
} catch (\Illuminate\Database\QueryException $e) {
if ($this->isUniqueConstraintViolation($e)) {
// Already processed or queued, treat as idempotent success
return response('OK', 200);
}
throw $e;
}
}
When you’re reading someone’s CV or portfolio on Find PHP, this is the kind of detail that quietly indicates: “I know how real systems break.”
Testing idempotency like you mean it
A surprisingly small number of teams actually test idempotency.
Here’s a practical checklist for your webhook tests:
- Send the same payload twice in a row
- Assert that domain state only changes once
- Assert that both responses are
2xx
- Simulate a unique constraint violation
- Make sure your handler returns
200 OKinstead of500
- Make sure your handler returns
- Simulate long processing times
- Force a sleep inside processing
- Hit the endpoint from another process while the first is running
- Ensure your logic stays consistent
- Restart workers mid-processing
- Confirm that replays don’t double-apply effects
Those tests are not glamorous, but they are deeply reassuring.
You begin to trust your own code in the same way you trust a seatbelt: not because it’s fancy, but because you know exactly how it behaves when things go wrong.
When idempotency gets subtle
Not every webhook is a simple “create X” or “mark Y as paid”.
Sometimes you get:
- A “status changed” event where the exact same event could have different effects if applied multiple times depending on current state
- Sequence-sensitive events:
created, thenupdated, thendeleted - Out-of-order delivery, where “updated” arrives before “created”
Some strategies that help:
- Always use resource IDs (like user ID, order ID) along with event IDs
- Treat webhooks as notifications, not as your only source of truth
- Periodically reconcile with the provider’s API (e.g., fetch all recent payments and reconcile statuses)
- Design your state transitions so that applying the same transition twice is harmless
An example:
- If you receive “subscription canceled”, and your subscription is already canceled, do nothing
- If you receive “invoice paid”, and invoice is already marked paid, do nothing
The rule of thumb:
Every webhook handler should be able to run multiple times against the same current state without causing damage.
If you can’t achieve that, your system will forever feel fragile around failure and retries.
Bringing it back to us: PHP, work, and quiet reliability
Picture this.
It’s a weeknight.
You’re in your home office or on your couch with the laptop balanced in that slightly uncomfortable way we pretend is ergonomic.
There’s a bug report in your tracker:
“Payments occasionally duplicated when internet connection is unstable.”
You scroll through logs, trace IDs, Stripe dashboard, GitHub issues, random StackOverflow tabs.
It’s not glamorous detective work. It’s the small, gritty kind.
And then it clicks: retries + non-idempotent handlers + a lucky/unlucky timing window.
You fix it.
You add an event_id.
You add a unique constraint.
You add a worker.
You write the tests.
The external behavior?
Nothing flashy. No new features. No UI.
But from that point on, a whole class of subtle, user-hurting bugs simply cannot happen anymore.
That’s the kind of engineering that almost nobody notices when it’s done well — yet it’s exactly what separates “it usually works” from “you can trust this system with real money, real people, real consequences”.
And in a strange way, it’s also what people are implicitly looking for on platforms like Find PHP:
- Teams looking to hire PHP developers are not just buying syntax fluency
- Developers looking for work are not just selling “I know Laravel 11 and Docker”
They’re buying and selling judgment.
The judgment to know that a simple POST handler isn’t enough.
The patience to design for retries and duplicates.
The humility to accept “the network will fail, the provider will retry, and my code must be ready”.
Idempotent webhook handlers are one very concrete expression of that judgment.
They say:
- “I know webhooks are at-least-once.”
- “I know how to make them effectively once.”
- “I’m not scared of failure scenarios; I design for them.”
Underneath the SQL, the Redis checks, the 200 OK responses, that’s what this is about.
It’s about making your systems, and quietly, yourself, a little more trustworthy than they were yesterday.