amirxd / ultra-webhook
A lightweight, secure and extensible PHP webhook receiver and sender.
Requires
- php: >=8.0
- ext-hash: *
- ext-json: *
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-02 17:21:14 UTC
README
A lightweight PHP library for receiving and sending webhooks. UltraWebHook is designed with the same practical style as PHP Ultra Request: small focused classes, mutable fluent configuration, no clone-based APIs, clear namespaces, and no mandatory third-party runtime dependencies.
Features
- Register webhook endpoints with
GET,POST,PUT,PATCH,DELETE, custom methods, orANY. - Route parameters such as
/webhooks/orders/{orderId}. - Build requests from PHP globals or construct requests directly for tests and framework integration.
- Return JSON, text, empty, or custom HTTP responses.
- Verify timestamped HMAC-SHA256 signatures with constant-time comparison and replay-window validation.
- Reject duplicate delivery IDs with in-memory or file-backed idempotency stores.
- Global middleware with request, response, and exception lifecycle hooks.
- Optional JSON content-type / syntax validation and request body size limits.
- Send signed webhook events with delivery IDs and timestamps.
- Retry network failures and selected HTTP status codes with exponential backoff, jitter, and
Retry-Aftersupport. - Optional delivery metadata logging to memory or JSON Lines files.
- Replaceable HTTP transport for testing or custom network integrations.
- PHP 8.0+, Composer PSR-4 autoloading, no cURL requirement.
Requirements
- PHP 8.0 or newer
ext-jsonext-hash
The default outbound transport uses PHP Streams. TLS peer and hostname verification are enabled, and redirects are disabled by default so signed webhook headers are not forwarded to another host.
Installation
Install through Composer after publishing the package:
composer require amirxd/ultra-webhook
For local development:
composer dump-autoload
Receive a webhook
<?php declare(strict_types=1); use Amirxd\UltraWebHook\Http\WebhookRequest; use Amirxd\UltraWebHook\Http\WebhookResponse; use Amirxd\UltraWebHook\Routing\WebhookContext; use Amirxd\UltraWebHook\UltraWebHook; require __DIR__ . '/vendor/autoload.php'; $app = new UltraWebHook(); $app->post('/webhooks/orders/{orderId}', function ( WebhookRequest $request, WebhookContext $context ): WebhookResponse { $payload = $request->getJson(); // Validate the payload against your business rules and enqueue heavy work. return WebhookResponse::json([ 'received' => true, 'order_id' => $context->getRouteParameter('orderId'), 'event' => $payload['event'] ?? null, ], 202); }); $app->run();
run() reads the current HTTP request, dispatches it, and emits the response. It does not call exit(), so the host application can still execute shutdown handlers.
Route methods
$app->get('/health', $handler); $app->post('/webhooks', $handler, $signatureConfig); $app->put('/webhooks/{id}', $handler); $app->patch('/webhooks/{id}', $handler); $app->delete('/webhooks/{id}', $handler); $app->any('/custom-endpoint', $handler); $app->route('OPTIONS', '/webhooks', $handler);
Handlers receive WebhookRequest and WebhookContext. The handler may return:
WebhookResponse: use the response as-is.arrayorobject: encoded as a JSON response with status 200.string: returned as plain text with status 200.null: returned as 204 No Content.bool,int, orfloat: returned as JSON under theresultkey.
Example request helpers:
$request->getMethod(); $request->getPath(); $request->getHeader('X-Event-Id'); $request->getRawBody(); $request->getJson(); $request->getQueryParam('source'); $request->getRemoteAddress();
getJson() uses JSON_THROW_ON_ERROR and throws JsonException on invalid JSON.
Verify webhook signatures
UltraWebHook uses an HMAC-SHA256 signature over this exact string:
{unix_timestamp}.{raw_request_body}
The signature header format is:
X-UltraWebHook-Signature: t=UNIX_TIMESTAMP,v1=LOWERCASE_HEX_HMAC
Configure a protected endpoint:
use Amirxd\UltraWebHook\Security\SignatureConfig; $signature = SignatureConfig::hmacSha256( secret: $_ENV['WEBHOOK_SECRET'], headerName: 'X-UltraWebHook-Signature', toleranceSeconds: 300 ); $app->post('/webhooks/payment', $handler, $signature);
The signature is checked against the raw body, not decoded and re-encoded JSON. The timestamp tolerance rejects stale and excessively future-dated signatures. Comparison uses hash_equals().
The sender built into UltraWebHook uses the same format and default header, so the sender and receiver interoperate directly.
Prevent duplicate delivery processing
Many webhook providers retry delivery when they do not receive a successful response. Enable idempotency to prevent the same delivery ID from being handled repeatedly:
use Amirxd\UltraWebHook\Storage\FileIdempotencyStore; $app->post('/webhooks/payment', $handler, $signature) ->withIdempotencyStore( new FileIdempotencyStore(__DIR__ . '/var/webhook-ids.json'), headerName: 'X-Webhook-Id', ttlSeconds: 86400 );
When idempotency is enabled, the configured delivery ID header is required. The first delivery is processed; later deliveries with the same route and ID receive a 200 response with duplicate: true.
Stores included:
InMemoryIdempotencyStore: suitable for unit tests and single-process temporary state. It is not shared across PHP workers.FileIdempotencyStore: usesflock()on the data file and restrictive file permissions. It is suitable for a single server with a local filesystem.
For multiple application servers or containers, implement IdempotencyStoreInterface with a shared database or Redis and use an atomic insert / SET NX operation. The included stores are not distributed locks.
The key is claimed before the handler runs to avoid concurrent duplicate execution. Consequently, if the process crashes after claiming a delivery but before finishing business processing, that ID remains blocked until its TTL expires. For critical workflows, use a durable queue or a custom store with explicit in_progress / completed states and recovery logic.
Middleware
Middleware is executed in registration order before the handler and in reverse order after the handler:
use Amirxd\UltraWebHook\Http\WebhookRequest; use Amirxd\UltraWebHook\Http\WebhookResponse; use Amirxd\UltraWebHook\Middleware\AbstractWebhookMiddleware; use Amirxd\UltraWebHook\Routing\WebhookContext; $logger = new class extends AbstractWebhookMiddleware { protected function before(WebhookRequest $request, WebhookContext $context): void { error_log('Webhook received: ' . $context->getRequestId()); } protected function after( WebhookResponse $response, WebhookRequest $request, WebhookContext $context ): WebhookResponse { return $response->withHeader('X-Processed-By', 'UltraWebHook'); } }; $app->addMiddleware($logger);
AbstractWebhookMiddleware provides before(), after(), and onError() hooks. Implement WebhookMiddlewareInterface directly when you need complete control or want to short-circuit the route by returning a response without calling $next.
Middleware methods:
$app->addMiddleware($middleware); $app->prependMiddleware($middleware); $app->removeMiddleware($middleware); $app->clearMiddlewares();
Built-in optional middleware:
use Amirxd\UltraWebHook\Middleware\JsonPayloadMiddleware; use Amirxd\UltraWebHook\Middleware\RequestSizeLimitMiddleware; $app->addMiddleware(new RequestSizeLimitMiddleware(1024 * 1024)); $app->addMiddleware(new JsonPayloadMiddleware());
RequestSizeLimitMiddleware measures the actual body bytes and returns 413 when the limit is exceeded. JsonPayloadMiddleware requires a JSON content type and valid JSON, returning 415 or 400 otherwise. Neither middleware is enabled automatically; add them only to routes that require JSON. For different policies per route, register separate router instances or implement route-aware middleware.
Send a webhook
use Amirxd\UltraWebHook\Delivery\RetryPolicy; use Amirxd\UltraWebHook\UltraWebHook; $app = new UltraWebHook(); $sender = $app->createSender( secret: $_ENV['WEBHOOK_SECRET'], retryPolicy: new RetryPolicy( maxAttempts: 4, baseDelayMs: 250, maxDelayMs: 8000, multiplier: 2.0, jitter: true ) ); $result = $sender->send( 'https://consumer.example.com/webhooks', 'order.created', [ 'order_id' => 123, 'total' => 49.90, 'currency' => 'EUR', ] ); if (!$result->isSuccessful()) { error_log('Webhook delivery failed: ' . ($result->getError() ?? 'HTTP ' . $result->getStatusCode())); }
The sender adds:
X-Webhook-Event: event name.X-Webhook-Id: unique delivery ID, reused for all attempts of one delivery.X-Webhook-Timestamp: Unix timestamp.X-UltraWebHook-Signature: timestamped HMAC-SHA256 signature.Content-TypeandUser-Agentdefaults.
Reserved headers (Host, Content-Length, Transfer-Encoding, Connection, and the UltraWebHook event/signature headers) cannot be overridden. Custom application headers are supported.
String payloads are sent as raw bytes. Arrays and objects are JSON encoded with exceptions enabled. The signature is calculated over the exact bytes sent.
Retry policy
RetryPolicy retries network/transport exceptions and these HTTP response codes by default: 408, 425, 429, 500, 502, 503, and 504.
$policy = new RetryPolicy( maxAttempts: 5, baseDelayMs: 300, maxDelayMs: 10000, multiplier: 2.0, jitter: true, retryableStatusCodes: [408, 429, 500, 502, 503, 504], maxElapsedMs: 45000 );
maxAttempts includes the first attempt. Exponential backoff is capped by maxDelayMs; jitter randomizes the delay to reduce retry synchronization. A valid Retry-After value (delta seconds or HTTP date) is respected up to the configured delay cap.
The sender keeps the same delivery ID, timestamp, and signature across attempts. This lets a receiver deduplicate retries reliably. The sender does not automatically follow redirects. A 3xx response is returned as a failed delivery; configure the final destination explicitly rather than forwarding signed requests.
maxElapsedMs prevents starting another attempt after the elapsed-time budget is exhausted. It cannot interrupt a network operation already in progress, so set setTimeout() to a suitable value as well.
Delivery results and logs
$result->isSuccessful(); $result->getDeliveryId(); $result->getEvent(); $result->getStatusCode(); $result->getResponseBody(); $result->getAttempts(); $result->getAttemptCount(); $result->getDurationMs(); $result->getError(); $result->getStorageError();
Optional delivery stores:
use Amirxd\UltraWebHook\Delivery\InMemoryDeliveryStore; use Amirxd\UltraWebHook\Delivery\JsonFileDeliveryStore; $memoryStore = new InMemoryDeliveryStore(); $fileStore = new JsonFileDeliveryStore(__DIR__ . '/var/deliveries.jsonl'); $sender->setDeliveryStore($fileStore); $sender->onDelivery(function ($result): void { // Metrics, alerting, or application-specific handling. });
If delivery logging fails after the HTTP delivery, the sender still returns the delivery result and exposes the logging failure through getStorageError(); it does not ask the caller to resend an event that may already have arrived. The JSON Lines store records delivery metadata only. It intentionally omits request payloads, response bodies, URL paths/query strings, and arbitrary transport error messages because they may contain personal or secret data. Protect the log directory and apply retention limits in production.
Custom HTTP transport
Implement HttpTransportInterface to use a different transport or to test deliveries without network access. Throw HttpTransportException for retryable network/transport failures; other exceptions are treated as application or configuration errors and are not retried:
use Amirxd\UltraWebHook\Http\HttpTransportInterface; use Amirxd\UltraWebHook\Http\OutboundRequest; use Amirxd\UltraWebHook\Http\OutboundResponse; class MyTransport implements HttpTransportInterface { public function send(OutboundRequest $request): OutboundResponse { // Perform the HTTP request using your preferred transport. return new OutboundResponse(202, [], 'accepted'); } }
Error handling
Unhandled route and middleware exceptions are converted to a generic 500 response. Internal exception messages are not returned to the remote caller.
A custom exception handler may log the exception or return a response:
$app->setExceptionHandler(function (Throwable $exception): WebhookResponse { error_log((string) $exception); return WebhookResponse::json(['error' => 'processing_failed'], 500); });
Signature failures return 401, missing idempotency IDs return 400, duplicate deliveries return 200, missing routes return 404, and method mismatches return 405 with an Allow header.
Security and deployment notes
- Keep webhook secrets in environment variables or a secret manager. Never commit them to source control.
- Always verify signatures before trusting payload data. Configure a short timestamp tolerance that matches provider behavior.
- Use idempotency for providers that retry deliveries. Use a shared atomic store in multi-server deployments.
- Enforce request body limits at the reverse proxy/web server too. PHP must read
php://inputbefore application-level middleware can measure it. - Keep webhook handlers fast. Validate the event, persist/enqueue work, and return 2xx; process long jobs asynchronously.
- Do not build outbound webhook URLs from untrusted input. The sender allows HTTP and HTTPS URLs; application policy should restrict allowed hosts and private network access where appropriate to prevent SSRF.
- Outbound TLS verification is enabled. Do not disable TLS verification in a custom transport.
- The default sender does not follow redirects. The receiver does not trust
X-Forwarded-For; only use proxy headers when your trusted proxy configuration explicitly validates them. - The file stores use local filesystem locking. They are not substitutes for a distributed database or queue.
- Add application-level authorization and payload/schema validation after signature verification.
Tests
Run the built-in test suite:
php tests/run.php
The suite uses a fake outbound transport and does not require cURL or internet access. A real deployment should additionally run an integration test against the actual hosting SAPI, proxy, TLS termination, and provider signature format.
License
MIT