I have used webhooks in a lot of different situations over the years, from payment alerts to form submissions to background system events that needed to trigger something on my server right away to home automation. They are one of those things that look simple at first, but once you start relying on them in production, you find out pretty quickly that there is a big difference between receiving a request and handling it well. I have had to build webhook endpoints that were clean, testable, secure, and easy to troubleshoot when the sending system was outside my control.
At the basic level, a webhook is just an HTTP request that one system sends to another when something happens. Instead of your server constantly checking for updates, the remote service sends the event to you as soon as it occurs. That makes webhooks useful for things like order notifications, payment status changes, contact form integrations, IoT triggers, and internal system messaging between apps.
In PHP, webhooks are easy to work with because everything comes down to reading the request, validating it, and deciding what to do with the payload. The hard part is not the syntax. The hard part is building the handler in a way that is safe enough for real traffic and simple enough that you can still debug it later when something goes wrong.
What Webhooks Actually Do
A lot of people first run into webhooks when connecting a payment processor or a form builder to their own server. A customer submits a payment, a system sends a POST request to your webhook URL, and your server receives the payload. That payload might contain a transaction ID, customer email, amount, status, and a timestamp, and from there your code can update a database, send an email, or trigger some other background action.
That same pattern shows up all over the place. A contact form can send an event to your application when a new message is submitted. A home automation controller can call a PHP endpoint when a door opens, a light turns on, or a sensor trips. A background job system can notify another internal service the moment a task completes. In every case, the idea is the same. Something happens somewhere else, and your server gets told about it immediately.
This is why I like webhooks for event driven work. They remove the need for constant polling and they let systems stay loosely connected. One service does not need full access to your server or database. It only needs a URL and an agreed upon payload format.
A Simple PHP Webhook Receiver
When I am explaining webhooks to someone, I usually start with the smallest possible receiver. All it needs to do is accept a POST request, read the raw body, decode JSON, and return a response. That is enough to show how one system can send structured data into another one.
Here is a simple PHP webhook receiver that accepts JSON and writes the payload to a log file. I would not use this exact version for sensitive production events, but it is a good starting point because it makes the request flow easy to see.
<?php
declare( strict_types = 1 );
header( 'Content-Type: application/json' );
if ( $_SERVER['REQUEST_METHOD'] !== 'POST' ) {
http_response_code( 405 );
echo json_encode( [ 'error' => 'Method not allowed' ] );
exit;
}
$rawBody = file_get_contents( 'php://input' );
$data = json_decode( $rawBody, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
http_response_code( 400 );
echo json_encode( [ 'error' => 'Invalid JSON payload' ] );
exit;
}
$logFile = __DIR__ . '/webhook.log';
$entry = [
'time' => date( 'Y-m-d H:i:s' ),
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'headers' => getallheaders(),
'payload' => $data,
];
file_put_contents(
$logFile,
json_encode( $entry, JSON_PRETTY_PRINT ) . PHP_EOL . str_repeat( '=', 80 ) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
echo json_encode( [ 'status' => 'received' ] );If I am testing locally, I usually save that as receiver.php and run it with PHP’s built in server. That lets me post test payloads to it immediately without needing Apache or Nginx in the way. For quick webhook testing, that is often the fastest route.
php -S 127.0.0.1:8000
Sending a Webhook With PHP
Once the receiver is in place, I like to pair it with a sender script so the whole flow is visible. A sender script makes the concept click much faster because you can watch one file send the event and the other file receive it. It also gives you something reusable later when you need to test production endpoints.
The simplest way to send a webhook in PHP is with cURL. I usually build the payload as an array, convert it to JSON, and send it as an HTTP POST request with the appropriate content type header. That is enough to simulate many real services.
<?php
declare( strict_types = 1 );
$url = 'http://127.0.0.1:8000/receiver.php';
$payload = [
'event' => 'form.submitted',
'form_id' => 25,
'name' => 'John Doe',
'email' => '[email protected]',
'submitted_at' => date( 'c' ),
];
$jsonPayload = json_encode( $payload );
$ch = curl_init( $url );
curl_setopt_array( $ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $jsonPayload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Content-Length: ' . strlen( $jsonPayload ),
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
] );
$response = curl_exec( $ch );
$statusCode = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$error = curl_error( $ch );
curl_close( $ch );
if ( $error ) {
echo 'cURL error: ' . $error . PHP_EOL;
exit( 1 );
}
echo 'HTTP Status: ' . $statusCode . PHP_EOL;
echo 'Response: ' . $response . PHP_EOL;Why Webhook Security Matters
One mistake I see people make is assuming that if a request reaches the right URL, it must be legitimate. That is not a safe assumption. If a webhook endpoint is public, anyone who knows or guesses the URL can try to send requests to it. If your code blindly trusts those requests, you can end up processing fake payments, bogus alerts, or malicious event data.
In real deployments, I want to know at least 2 things before I trust a webhook. I want to know that the request actually came from the expected sender, and I want to know that the body was not changed in transit. That is where webhook signatures come in. Many services use an HMAC signature built from the raw payload and a shared secret.
I also pay attention to replay attacks. Even if a request is valid, I may not want the same signed payload accepted forever. That is why some systems include a timestamp and reject requests that are too old. It is a small detail, but it matters once your webhook endpoint starts doing anything important.
Receiving a Signed Webhook Securely
This is the kind of receiver script I prefer once I move past basic testing. It validates the request method, reads the raw body, checks for a signature header, calculates its own HMAC hash, and compares the two safely. If the signature does not match, it stops right there.
<?php
declare( strict_types = 1 );
header( 'Content-Type: application/json' );
$sharedSecret = 'replace_this_with_a_long_random_secret';
if ( $_SERVER['REQUEST_METHOD'] !== 'POST' ) {
http_response_code( 405 );
echo json_encode( [ 'error' => 'Method not allowed' ] );
exit;
}
$rawBody = file_get_contents( 'php://input' );
$headers = getallheaders();
$receivedSignature = $headers['X-Webhook-Signature'] ?? '';
if ( $receivedSignature === '' ) {
http_response_code( 400 );
echo json_encode( [ 'error' => 'Missing signature header' ] );
exit;
}
$calculatedSignature = hash_hmac( 'sha256', $rawBody, $sharedSecret );
if ( ! hash_equals( $calculatedSignature, $receivedSignature ) ) {
http_response_code( 401 );
echo json_encode( [ 'error' => 'Invalid signature' ] );
exit;
}
$data = json_decode( $rawBody, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
http_response_code( 400 );
echo json_encode( [ 'error' => 'Invalid JSON payload' ] );
exit;
}
$logFile = __DIR__ . '/secure-webhook.log';
$entry = [
'time' => date( 'Y-m-d H:i:s' ),
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'event' => $data['event'] ?? 'unknown',
'payload' => $data,
];
file_put_contents(
$logFile,
json_encode( $entry, JSON_PRETTY_PRINT ) . PHP_EOL . str_repeat( '=', 80 ) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
echo json_encode( [ 'status' => 'verified and received' ] );Sending a Signed Webhook
Once the receiver expects a signature, the sender has to generate one the same way. I usually create the JSON payload first, then hash that raw JSON string with the shared secret, and finally send the result in a custom header. That mirrors how many third party webhook systems work.
<?php
declare( strict_types = 1 );
$url = 'http://127.0.0.1:8000/secure-receiver.php';
$sharedSecret = 'replace_this_with_a_long_random_secret';
$payload = [
'event' => 'payment.completed',
'payment_id' => 'pay_123456',
'amount' => 49.99,
'currency' => 'USD',
'status' => 'completed',
'created_at' => date( 'c' ),
];
$jsonPayload = json_encode( $payload );
$signature = hash_hmac( 'sha256', $jsonPayload, $sharedSecret );
$ch = curl_init( $url );
curl_setopt_array( $ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $jsonPayload,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Content-Length: ' . strlen( $jsonPayload ),
'X-Webhook-Signature: ' . $signature,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
] );
$response = curl_exec( $ch );
$statusCode = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
$error = curl_error( $ch );
curl_close( $ch );
if ( $error ) {
echo 'cURL error: ' . $error . PHP_EOL;
exit( 1 );
}
echo 'HTTP Status: ' . $statusCode . PHP_EOL;
echo 'Response: ' . $response . PHP_EOL;Building a Catch All Webhook Logger
One of the most useful webhook tools I keep around is a catch all logging script. I use it when I need to see exactly what a remote service is sending before I commit to writing a final parser. It is also useful when troubleshooting a service that claims it is sending webhooks but I am not seeing what I expect.
This script accepts any request method, captures the request details, logs headers, query string, body, JSON decoding attempts, and server information, then responds with a simple success message. It is not meant to process production actions. It is meant to help me inspect webhook traffic safely during testing.
<?php
declare( strict_types = 1 );
header( 'Content-Type: application/json' );
$rawBody = file_get_contents( 'php://input' );
$decodedJson = json_decode( $rawBody, true );
$entry = [
'time' => date( 'Y-m-d H:i:s' ),
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'uri' => $_SERVER['REQUEST_URI'] ?? '',
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'query_string' => $_SERVER['QUERY_STRING'] ?? '',
'headers' => getallheaders(),
'raw_body' => $rawBody,
'json_body' => json_last_error() === JSON_ERROR_NONE ? $decodedJson : null,
'post_data' => $_POST,
];
$logFile = __DIR__ . '/catch-all-webhooks.log';
file_put_contents(
$logFile,
json_encode( $entry, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . PHP_EOL . str_repeat( '=', 80 ) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
echo json_encode( [
'status' => 'logged',
'message' => 'Webhook request captured successfully',
] );Testing Webhooks Without Guessing
When I test webhooks, I try to keep the process boring and repeatable. I start with the catch all logger so I can see the request exactly as it arrives. After that, I tighten the receiver to validate JSON, check signatures, and process only the fields I actually need. That workflow saves time because I am not trying to debug structure and security at the same time.
Here is the sort of output I expect from the sender script after a successful request. This is simple, but seeing it confirms that the HTTP request completed and the receiver answered the way I expected.
HTTP Status: 200
Response: {"status":"verified and received"}One other thing I pay attention to is response codes. A 200 means success, a 400 usually means malformed data, a 401 means authentication or signature failure, and a 405 means the wrong request method was used. Good webhook debugging gets a lot easier once the receiver responds clearly and logs enough detail to explain what happened.
A Few Practical Things I Always Watch
The biggest real world webhook problems I run into are duplicate events, bad assumptions about payload structure, and missing logs. Some services retry failed requests, and some retry slow requests too, so I never assume an event will arrive only once. If the action matters, I usually store an event ID and make sure I can detect duplicates before processing them twice.
I also avoid relying on fields that I have not verified. Just because one webhook payload included a value last week does not mean it will always be there. I try to code defensively, check that expected fields exist, and fail in a controlled way when they do not. That matters even more when the webhook is coming from a third party system I do not control.
Logging matters more than most people think. When a webhook fails, it often fails outside the application flow you are staring at. There may be no visible page error and no direct user interaction to clue you in. A clean log entry with the raw request, headers, and timestamp can save a lot of time when you need to find out whether the problem was on your side or theirs.
Closing Thoughts
What I like about webhooks is that they let separate systems react to each other instantly without needing a heavy connection between them. What I do not like is how often people trust them too quickly. A webhook endpoint should not just accept input. It should validate what it received, log enough to troubleshoot it, and reject anything that does not pass the checks you expect.
PHP is a good fit for webhook work because the request handling is straightforward and the tooling is simple. Once you understand how to read the raw body, decode JSON, verify a signature, and log requests cleanly, you have everything you need to build reliable webhook handlers. From there, it becomes less about syntax and more about discipline.
This is one of those topics where a few small habits go a long way. A basic sender script helps you test. A secure receiver protects the endpoint. A catch all logger helps you see what is really happening. Put those together and webhooks stop feeling mysterious pretty quickly.





















