Skip to content
getnextpdf.com

Enterprise edition

Webhook — Deep Reference

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.

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.

SymbolParametersDefault behaviorReturnsThrows or fails withNotes
WebhookManager::__constructWebhookDelivery $delivery, ?LoggerInterface $logger = nullCreates a manager with an empty in-memory registration indexNew WebhookManagerDoes not throwRegistrations are indexed per tenant
WebhookManager::registerTenantContext $tenant, WebhookRegistration $registrationAppends the registration to the calling tenant’s indexvoidInvalidArgumentException when the registration tenant does not match the context tenantCross-tenant registration is rejected before storage
WebhookManager::unregisterTenantContext $tenant, string $registrationIdReplaces the matching registration with a deactivated copyboolDoes not throw; returns false when the id is not foundSoft deactivate; history is preserved
WebhookManager::activeRegistrationsTenantContext $tenantFilters the tenant’s registrations to active oneslist<WebhookRegistration>Does not throwOnly the calling tenant’s registrations are visible
WebhookManager::dispatchTenantContext $tenant, JobEvent $eventDelivers the event to every active registration that subscribes to the event typeint (successful deliveries)Propagates JsonException when event data is not JSON-encodable; delivery failures do not throwA fresh 32-hex delivery id is generated per registration delivery
WebhookRegistration::__constructstring $id, string $tenantId, string $url, array $events, string $secret, bool $active = true, ?string $description = nullStores the supplied values verbatimNew WebhookRegistrationNo declared @throws; PHP raises TypeError on mismatched argument types under strict_typesfinal readonly; empty $events means subscribe-to-all
WebhookRegistration::subscribesToJobEventType $eventTypetrue when $events is empty or contains the typeboolDoes not throwStrict identity comparison
WebhookRegistration::deactivateReturns an inactive copyselfDoes not throwThe original instance is unchanged
WebhookPayload::fromJobEventJobEvent $event, string $tenantId, string $deliveryIdCopies job id, event type, data, and timestamp from the eventselfDoes not throwStatic factory used by dispatch
WebhookPayload::toJsonSerializes the six-field body with unescaped slashesnon-empty-stringJsonException when event data is not JSON-encodableJSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
WebhookPayload::toArrayReturns the body as an associative arrayarray<string, mixed>Does not throwTimestamp formatted as RFC 3339 extended
WebhookPayload::signedTimestampUnix-seconds event time, clamped to zero or greaterint<0, max>Does not throwEmitted as X-NextPDF-Timestamp and bound into the MAC
WebhookPayload::signstring $secretHMAC-SHA256 over the base string {signedTimestamp}.{jsonBody}non-empty-string (hex)JsonException via toJson() when the body is not encodableBinds the timestamp header to the body cryptographically
WebhookDelivery::__constructClientInterface $httpClient, RequestFactoryInterface $requestFactory, StreamFactoryInterface $streamFactory, WebhookRetryPolicy $retryPolicy = new WebhookRetryPolicy(), ?LoggerInterface $logger = nullPSR-18/PSR-17 delivery engine with an empty dead-letter queueNew WebhookDeliveryDoes not throwDefault policy: 5 attempts, 1 s base, 300 s cap
WebhookDelivery::deliverWebhookRegistration $registration, WebhookPayload $payloadPOSTs the signed payload with per-attempt SSRF egress validation and exponential backoffboolJsonException before the first attempt when the body is not encodable; otherwise does not throw — false means the payload was routed to the dead-letter queuetrue only on a 2xx response
WebhookDelivery::deadLettersReturns all recorded entrieslist<DeadLetterEntry>Does not throwIn-memory, process-scoped
WebhookDelivery::clearDeadLettersEmpties the dead-letter queuevoidDoes not throwIrreversible; export entries first if replay is required
WebhookRetryPolicy::__constructint $maxRetries = 5, int $baseDelaySeconds = 1, int $maxDelaySeconds = 300Stores the policy valuesNew WebhookRetryPolicyNo declared @throws; parameters are documented positive-int$maxRetries counts total attempts
WebhookRetryPolicy::delayForAttemptint $attemptbaseDelaySeconds × 2^(attempt − 1), capped at maxDelaySecondspositive-intDoes not throwAttempt numbers are 1-based
WebhookRetryPolicy::shouldRetryint $currentAttempttrue while the current attempt is below the maximumboolDoes not throwThe wait is skipped after the final attempt
WebhookRetryPolicy::default5 attempts, 1 s base, 300 s capselfDoes not throwStatic factory; production default
WebhookRetryPolicy::aggressive10 attempts, 2 s base, 600 s capselfDoes not throwStatic factory for critical endpoints
DeadLetterEntry::__constructstring $id, string $registrationId, WebhookPayload $payload, int $attempts, string $lastError, ?int $lastHttpStatus, DateTimeImmutable $failedAt, bool $replayed = falseStores the failure record verbatimNew DeadLetterEntryNo declared @throws; TypeError under strict_typesfinal readonly; null $lastHttpStatus means transport failure
DeadLetterEntry::markReplayedReturns a copy with replayed = trueselfDoes not throwSame 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): int
public 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(): self
public 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): string
public 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(): void
public 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(): self
public 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(): self
  • 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, and X-NextPDF-Event.
  • The JSON body fields are delivery_id, job_id, event_type, data, timestamp (RFC 3339 extended), and tenant_id, serialized with unescaped slashes. Event-type values come from JobEventType in nextpdf/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. The X-NextPDF-Timestamp value is the MAC’s timestamp component, so a tampered or replayed timestamp header invalidates the signature.
  • Receiver verification: read the X-NextPDF-Timestamp header T; reject when T is outside an acceptable freshness window (for example 300 s); recompute hash_hmac('sha256', T . '.' . rawBody, secret) over the raw received bytes; compare in constant time against the header value after stripping the sha256= 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 a Blocked 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 at maxDelaySeconds. The wait is skipped after the final attempt.
  • When no attempt succeeds, a DeadLetterEntry records 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.
  • 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 consume X-NextPDF-Timestamp.
  • Non-encodable event data. toJson() and sign() throw JsonException, which propagates out of deliver() and dispatch() 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 calling clearDeadLetters() 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.

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.

  • 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.
  • All classes declare strict_types=1 and are final; WebhookRegistration, WebhookPayload, WebhookRetryPolicy, and DeadLetterEntry are final readonly with promoted public properties.
  • The module carries an @since annotation of 2.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 on X-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.

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.