Enterprise edition
Webhook — Deep Reference
At a glance
Section titled “At a glance”The NextPDF\Enterprise\Webhook namespace ships tenant-scoped webhook delivery for job events. The public surface is six symbols: WebhookManager, WebhookRegistration, WebhookPayload, WebhookDelivery, WebhookRetryPolicy, and DeadLetterEntry. The manager registers endpoints per tenant and dispatches job events to subscribing registrations. The delivery engine POSTs an HMAC-SHA256-signed JSON payload, validates every destination against the Core SSRF egress gate, retries with exponential backoff, and records permanent failures in an in-memory dead-letter queue. As of 3.1.0 the signature binds the X-NextPDF-Timestamp header into the MAC base string, so receivers verify freshness and integrity together. For the workflow-level guide, see Webhook.
Availability & licensing
Section titled “Availability & licensing”This capability ships in NextPDF Enterprise (nextpdf/enterprise) and activates with an Enterprise-tier license envelope. A deployment without that entitlement does not load the capability’s classes. Compare editions and get a license.
The webhook surface is a base Enterprise capability, available once the Enterprise package is installed; there is no separate per-feature flag. NextPDF Core (Apache-2.0) and NextPDF Pro have no webhook registration or delivery surface; the manager, registration, payload, delivery engine, retry policy, and dead-letter entry ship in nextpdf/enterprise only.
Public API surface
Section titled “Public API surface”| Symbol | Parameters | Default behavior | Returns | Throws or fails with | Notes |
|---|---|---|---|---|---|
WebhookManager::__construct | WebhookDelivery $delivery, ?LoggerInterface $logger = null | Creates a manager with an empty in-memory registration index | New WebhookManager | Does not throw | Registrations are indexed per tenant |
WebhookManager::register | TenantContext $tenant, WebhookRegistration $registration | Appends the registration to the calling tenant’s index | void | InvalidArgumentException when the registration tenant does not match the context tenant | Cross-tenant registration is rejected before storage |
WebhookManager::unregister | TenantContext $tenant, string $registrationId | Replaces the matching registration with a deactivated copy | bool | Does not throw; returns false when the id is not found | Soft deactivate; history is preserved |
WebhookManager::activeRegistrations | TenantContext $tenant | Filters the tenant’s registrations to active ones | list<WebhookRegistration> | Does not throw | Only the calling tenant’s registrations are visible |
WebhookManager::dispatch | TenantContext $tenant, JobEvent $event | Delivers the event to every active registration that subscribes to the event type | int (successful deliveries) | Propagates JsonException when event data is not JSON-encodable; delivery failures do not throw | A fresh 32-hex delivery id is generated per registration delivery |
WebhookRegistration::__construct | string $id, string $tenantId, string $url, array $events, string $secret, bool $active = true, ?string $description = null | Stores the supplied values verbatim | New WebhookRegistration | No declared @throws; PHP raises TypeError on mismatched argument types under strict_types | final readonly; empty $events means subscribe-to-all |
WebhookRegistration::subscribesTo | JobEventType $eventType | true when $events is empty or contains the type | bool | Does not throw | Strict identity comparison |
WebhookRegistration::deactivate | — | Returns an inactive copy | self | Does not throw | The original instance is unchanged |
WebhookPayload::fromJobEvent | JobEvent $event, string $tenantId, string $deliveryId | Copies job id, event type, data, and timestamp from the event | self | Does not throw | Static factory used by dispatch |
WebhookPayload::toJson | — | Serializes the six-field body with unescaped slashes | non-empty-string | JsonException when event data is not JSON-encodable | JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES |
WebhookPayload::toArray | — | Returns the body as an associative array | array<string, mixed> | Does not throw | Timestamp formatted as RFC 3339 extended |
WebhookPayload::signedTimestamp | — | Unix-seconds event time, clamped to zero or greater | int<0, max> | Does not throw | Emitted as X-NextPDF-Timestamp and bound into the MAC |
WebhookPayload::sign | string $secret | HMAC-SHA256 over the base string {signedTimestamp}.{jsonBody} | non-empty-string (hex) | JsonException via toJson() when the body is not encodable | Binds the timestamp header to the body cryptographically |
WebhookDelivery::__construct | ClientInterface $httpClient, RequestFactoryInterface $requestFactory, StreamFactoryInterface $streamFactory, WebhookRetryPolicy $retryPolicy = new WebhookRetryPolicy(), ?LoggerInterface $logger = null | PSR-18/PSR-17 delivery engine with an empty dead-letter queue | New WebhookDelivery | Does not throw | Default policy: 5 attempts, 1 s base, 300 s cap |
WebhookDelivery::deliver | WebhookRegistration $registration, WebhookPayload $payload | POSTs the signed payload with per-attempt SSRF egress validation and exponential backoff | bool | JsonException before the first attempt when the body is not encodable; otherwise does not throw — false means the payload was routed to the dead-letter queue | true only on a 2xx response |
WebhookDelivery::deadLetters | — | Returns all recorded entries | list<DeadLetterEntry> | Does not throw | In-memory, process-scoped |
WebhookDelivery::clearDeadLetters | — | Empties the dead-letter queue | void | Does not throw | Irreversible; export entries first if replay is required |
WebhookRetryPolicy::__construct | int $maxRetries = 5, int $baseDelaySeconds = 1, int $maxDelaySeconds = 300 | Stores the policy values | New WebhookRetryPolicy | No declared @throws; parameters are documented positive-int | $maxRetries counts total attempts |
WebhookRetryPolicy::delayForAttempt | int $attempt | baseDelaySeconds × 2^(attempt − 1), capped at maxDelaySeconds | positive-int | Does not throw | Attempt numbers are 1-based |
WebhookRetryPolicy::shouldRetry | int $currentAttempt | true while the current attempt is below the maximum | bool | Does not throw | The wait is skipped after the final attempt |
WebhookRetryPolicy::default | — | 5 attempts, 1 s base, 300 s cap | self | Does not throw | Static factory; production default |
WebhookRetryPolicy::aggressive | — | 10 attempts, 2 s base, 600 s cap | self | Does not throw | Static factory for critical endpoints |
DeadLetterEntry::__construct | string $id, string $registrationId, WebhookPayload $payload, int $attempts, string $lastError, ?int $lastHttpStatus, DateTimeImmutable $failedAt, bool $replayed = false | Stores the failure record verbatim | New DeadLetterEntry | No declared @throws; TypeError under strict_types | final readonly; null $lastHttpStatus means transport failure |
DeadLetterEntry::markReplayed | — | Returns a copy with replayed = true | self | Does not throw | Same id; the original entry is unchanged |
public function __construct( private readonly WebhookDelivery $delivery, private readonly ?LoggerInterface $logger = null,) {}
public function register(TenantContext $tenant, WebhookRegistration $registration): void
public function unregister(TenantContext $tenant, string $registrationId): bool
public function activeRegistrations(TenantContext $tenant): array
public function dispatch(TenantContext $tenant, JobEvent $event): intpublic function __construct( public string $id, public string $tenantId, public string $url, public array $events, public string $secret, public bool $active = true, public ?string $description = null,) {}
public function subscribesTo(JobEventType $eventType): bool
public function deactivate(): selfpublic static function fromJobEvent( JobEvent $event, string $tenantId, string $deliveryId,): self
public function toJson(): string
public function toArray(): array
public function signedTimestamp(): int
public function sign(string $secret): stringpublic function __construct( private readonly ClientInterface $httpClient, private readonly RequestFactoryInterface $requestFactory, private readonly StreamFactoryInterface $streamFactory, private readonly WebhookRetryPolicy $retryPolicy = new WebhookRetryPolicy(), private readonly ?LoggerInterface $logger = null,) {}
public function deliver(WebhookRegistration $registration, WebhookPayload $payload): bool
public function deadLetters(): array
public function clearDeadLetters(): voidpublic function __construct( public int $maxRetries = 5, public int $baseDelaySeconds = 1, public int $maxDelaySeconds = 300,) {}
public function delayForAttempt(int $attempt): int
public function shouldRetry(int $currentAttempt): bool
public static function default(): self
public static function aggressive(): selfpublic function __construct( public string $id, public string $registrationId, public WebhookPayload $payload, public int $attempts, public string $lastError, public ?int $lastHttpStatus, public DateTimeImmutable $failedAt, public bool $replayed = false,) {}
public function markReplayed(): selfBehavior contract
Section titled “Behavior contract”- Registrations are indexed per tenant.
register()rejects a registration whose tenant identifier does not match the calling context.unregister()is a soft deactivate: the registration is replaced with an inactive copy, preserving history while excluding it from future dispatch. dispatch()iterates only the calling tenant’s active registrations that subscribe to the dispatched event type. An empty subscribed-event list means subscribe-to-all. The return value counts successful deliveries.- Each delivery is an HTTP POST with a JSON body and five headers:
Content-Type: application/json,X-NextPDF-Signature(sha256=<hex>),X-NextPDF-Timestamp(unix seconds),X-NextPDF-Delivery-Id, andX-NextPDF-Event. - The JSON body fields are
delivery_id,job_id,event_type,data,timestamp(RFC 3339 extended), andtenant_id, serialized with unescaped slashes. Event-type values come fromJobEventTypeinnextpdf/core:progress,completed,failed,cancelled. - Signature scheme (changed in 3.1.0, breaking). The HMAC-SHA256 base string is
{signedTimestamp}.{jsonBody}, keyed by the registration secret — not the body alone. TheX-NextPDF-Timestampvalue is the MAC’s timestamp component, so a tampered or replayed timestamp header invalidates the signature. - Receiver verification: read the
X-NextPDF-TimestampheaderT; reject whenTis outside an acceptable freshness window (for example 300 s); recomputehash_hmac('sha256', T . '.' . rawBody, secret)over the raw received bytes; compare in constant time against the header value after stripping thesha256=prefix. - The body, signature, and delivery id are computed once per delivery and stay constant across retry attempts.
- SSRF egress gate. Before every attempt the destination URL passes the Core
UrlValidator::validateExternalUrl()gate: HTTPS scheme only; loopback, private, reserved, carrier-grade-NAT, cloud-metadata, and IPv4-embedded IPv6 transition ranges are blocked; hostnames are DNS-resolved (A and AAAA) and unresolvable hosts are rejected fail-closed. A blocked URL is never sent: the attempt loop aborts and the payload routes straight to the dead-letter queue with aBlocked SSRF destination:last error and a null HTTP status. - Outcome classification per attempt: 2xx is success and returns immediately; a 4xx other than 429 is terminal and goes straight to dead-letter; every other outcome — 3xx, 429, 5xx, or a transport exception — is retryable up to the policy’s total attempt count.
- Backoff is exponential: the wait before the next attempt is
baseDelaySeconds × 2^(attempt − 1), capped atmaxDelaySeconds. The wait is skipped after the final attempt. - When no attempt succeeds, a
DeadLetterEntryrecords a unique id, the registration id, the original payload, the attempt count (clamped to the policy maximum), the last error message, the last HTTP status (null on transport failure or SSRF block), and the failure timestamp. - The dead-letter queue is in-memory and scoped to the process lifetime.
markReplayed()produces a flagged copy; it does not re-send, and the queue keeps the original entry.
Edge cases & failure modes
Section titled “Edge cases & failure modes”- Empty event list. The registration receives every event type. Scope the list explicitly when the receiver must not see all events.
- Terminal 4xx versus transport failure. A 4xx rejection records a populated
lastHttpStatus; a connection failure records null. Use the null to distinguish receiver rejection from transport failure. - SSRF-blocked destination. A registration pointing at an HTTP, private, loopback, or metadata address dead-letters on the first attempt with a
Blocked SSRF destination:error and null status. No outbound request is made. Fix the URL and register again. - Legacy receivers after upgrade. A receiver still verifying the pre-3.1.0 body-only HMAC fails closed against 3.1.0 deliveries. Migrate the receiver to the
{timestamp}.{body}base string and consumeX-NextPDF-Timestamp. - Non-encodable event data.
toJson()andsign()throwJsonException, which propagates out ofdeliver()anddispatch()before any attempt is made. - Synchronous blocking.
deliver()sleeps inline between attempts. Cumulative backoff reaches 15 s under the default policy and about 17 minutes under the aggressive policy. Dispatch from a queue worker when receiver latency is untrusted. - Attempt-count clamp. The recorded attempt count never exceeds the policy maximum, even though the internal loop counter advances past it on exhaustion.
- Queue growth and durability. The dead-letter queue grows unbounded within the process and vanishes on restart. Export entries via
deadLetters()and persist them externally before callingclearDeadLetters()when durable replay is required. - Replay is operator-driven. Re-delivery means calling
deliver()again with the entry’s payload;markReplayed()only records the fact on a copy. - DNS-rebinding residual. The URL is re-validated on every attempt, which narrows but does not close the rebinding window: the PSR-18 abstraction cannot pin the connection to the validated IP. Add network-layer egress controls where this residual matters.
- Secret handling. The registration secret is a credential. The HMAC authenticates integrity and origin only — it is not confidentiality. Do not place data in the event payload that the receiver must not see.
FIPS-mode behavior
Section titled “FIPS-mode behavior”Payload signing is HMAC-SHA256 through PHP’s hash_hmac(), so it relies on the host crypto provider. In a FIPS-constrained build a non-approved primitive fails at the cryptographic boundary rather than downgrading. The webhook layer adds no cryptographic policy of its own.
Conformance
Section titled “Conformance”- Payload authentication implements HMAC, the keyed-hash message authentication code of FIPS PUB 198-1 §1, instantiated with SHA-256.
- Replay protection follows the OWASP Cheat Sheet Series webhook-security guidance: the event timestamp travels in a dedicated header and is seeded into the signature computation, so a tampered timestamp fails verification.
- Body timestamps use the RFC 3339 extended date-time format.
Development notes
Section titled “Development notes”- All classes declare
strict_types=1and arefinal;WebhookRegistration,WebhookPayload,WebhookRetryPolicy, andDeadLetterEntryarefinal readonlywith promoted public properties. - The module carries an
@sinceannotation of2.2.0; the timestamp-bound signature scheme is a documented breaking change in 3.1.0. - The delivery engine takes PSR-18/PSR-17 abstractions, so a mock HTTP client exercises the full send, retry, and dead-letter path offline. The logger defaults to null; inject a PSR-3 logger in production or failures surface only through return values.
- Receiver implementations should use
hash_equals()for the signature comparison and enforce a freshness window onX-NextPDF-Timestamp. - Recommended boundary tests: tenant-mismatch registration, empty-event-list fan-out, terminal 4xx, retry exhaustion, SSRF-blocked URL, timestamp-tampered signature rejection against a fixed vector, and dead-letter attempt-count clamping.
Publication boundary
Section titled “Publication boundary”This page documents externally observable behavior and the supported public API surface only. Internal namespace paths, helper classes, mechanism tables, runbook filenames, and ticket prefixes are out of scope.
See also
Section titled “See also”- Webhook — NextPDF Enterprise — the capability page: workflow, configuration, and worked registration examples.
- SaaS — Deep Reference — tenant identity, API keys, and quotas; the source of
TenantContext. - Metering — Deep Reference — usage metering fan-out with the same PSR-18 delivery discipline.