跳转到内容
getnextpdf.com

Enterprise 版本

Webhook — 深度参考

NextPDF\Enterprise\Webhook 命名空间为作业事件提供按租户建立索引的 webhook 投递。其公开面为六个符号:WebhookManagerWebhookRegistrationWebhookPayloadWebhookDeliveryWebhookRetryPolicyDeadLetterEntry。管理器按租户注册端点,并将作业事件派发给订阅的注册。投递引擎以 POST 发送一份经 HMAC-SHA256 签名的 JSON 负载,对照 Core SSRF 出站门校验每一个目标地址,采用指数退避重试,并将永久性失败记录到一个内存中的死信队列。自 3.1.0 起,签名将 X-NextPDF-Timestamp header 绑定进 MAC 基串,因此接收方会同时校验新鲜度与完整性。工作流层面的指南请参阅 Webhook

此能力随 NextPDF Enterprisenextpdf/enterprise)提供,并通过 Enterprise 层级的授权信封激活。没有该授权的部署不会加载此能力的类。比较版本并获取授权

webhook 面是一项 Enterprise 基础能力,只要安装了 Enterprise 包即可使用;没有单独的按功能划分的标志。NextPDF Core(Apache-2.0)与 NextPDF Pro 没有任何 webhook 注册或投递面;管理器、注册、负载、投递引擎、重试策略与死信条目仅在 nextpdf/enterprise 中提供。

符号参数默认行为返回抛出或失败于备注
WebhookManager::__constructWebhookDelivery $delivery?LoggerInterface $logger = null创建一个带空的内存注册索引的管理器新的 WebhookManager不抛出注册按租户建立索引
WebhookManager::registerTenantContext $tenantWebhookRegistration $registration将该注册追加到调用方租户的索引void当注册的租户与上下文租户不匹配时抛出 InvalidArgumentException跨租户注册在存储前即被拒绝
WebhookManager::unregisterTenantContext $tenantstring $registrationId将匹配的注册替换为一个已停用的副本bool不抛出;当找不到该 id 时返回 false软停用;历史被保留
WebhookManager::activeRegistrationsTenantContext $tenant将该租户的注册筛选为活跃的那些list<WebhookRegistration>不抛出只有调用方租户的注册可见
WebhookManager::dispatchTenantContext $tenantJobEvent $event将该事件投递给每一个订阅了该事件类型的活跃注册int(成功投递数)当事件数据无法进行 JSON 编码时传播 JsonException;投递失败不抛出每次注册投递都会生成一个新的 32 位十六进制投递 id
WebhookRegistration::__constructstring $idstring $tenantIdstring $urlarray $eventsstring $secretbool $active = true?string $description = null逐字存储所提供的值新的 WebhookRegistration未声明 @throws;在 strict_types 下参数类型不匹配时 PHP 抛出 TypeErrorfinal readonly$events 为空表示订阅全部事件
WebhookRegistration::subscribesToJobEventType $eventType$events 为空或包含该类型时为 truebool不抛出严格恒等比较
WebhookRegistration::deactivate返回一个非活跃副本self不抛出原实例保持不变
WebhookPayload::fromJobEventJobEvent $eventstring $tenantIdstring $deliveryId从事件复制作业 id、事件类型、数据与时间戳self不抛出dispatch 使用的静态工厂
WebhookPayload::toJson将六字段 body 序列化,斜杠不转义non-empty-string当事件数据无法进行 JSON 编码时抛出 JsonExceptionJSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES
WebhookPayload::toArray以关联数组形式返回 bodyarray<string, mixed>不抛出时间戳按 RFC 3339 扩展格式格式化
WebhookPayload::signedTimestampUnix 秒表示的事件时间,钳制为不小于零int<0, max>不抛出作为 X-NextPDF-Timestamp 发出并绑定进 MAC
WebhookPayload::signstring $secret对基串 {signedTimestamp}.{jsonBody} 计算 HMAC-SHA256non-empty-string(十六进制)当 body 无法编码时经由 toJson() 抛出 JsonException以密码学方式将时间戳 header 绑定到 body
WebhookDelivery::__constructClientInterface $httpClientRequestFactoryInterface $requestFactoryStreamFactoryInterface $streamFactoryWebhookRetryPolicy $retryPolicy = new WebhookRetryPolicy()?LoggerInterface $logger = null带空死信队列的 PSR-18/PSR-17 投递引擎新的 WebhookDelivery不抛出默认策略:5 次尝试、1 s 基础、300 s 上限
WebhookDelivery::deliverWebhookRegistration $registrationWebhookPayload $payload以 POST 发送已签名负载,逐次尝试进行 SSRF 出站校验并采用指数退避bool当 body 无法编码时在首次尝试前抛出 JsonException;否则不抛出——false 表示负载被路由到死信队列仅在 2xx 响应时为 true
WebhookDelivery::deadLetters返回所有已记录的条目list<DeadLetterEntry>不抛出内存中,进程作用域
WebhookDelivery::clearDeadLetters清空死信队列void不抛出不可逆;若需要重放请先导出条目
WebhookRetryPolicy::__constructint $maxRetries = 5int $baseDelaySeconds = 1int $maxDelaySeconds = 300存储策略值新的 WebhookRetryPolicy未声明 @throws;参数文档标注为 positive-int$maxRetries 计的是总尝试次数
WebhookRetryPolicy::delayForAttemptint $attemptbaseDelaySeconds × 2^(attempt − 1),以 maxDelaySeconds 封顶positive-int不抛出尝试编号从 1 开始
WebhookRetryPolicy::shouldRetryint $currentAttempt当当前尝试低于最大值时为 truebool不抛出最后一次尝试之后会跳过等待
WebhookRetryPolicy::default5 次尝试、1 s 基础、300 s 上限self不抛出静态工厂;生产默认
WebhookRetryPolicy::aggressive10 次尝试、2 s 基础、600 s 上限self不抛出面向关键端点的静态工厂
DeadLetterEntry::__constructstring $idstring $registrationIdWebhookPayload $payloadint $attemptsstring $lastError?int $lastHttpStatusDateTimeImmutable $failedAtbool $replayed = false逐字存储失败记录新的 DeadLetterEntry未声明 @throws;在 strict_types 下抛出 TypeErrorfinal readonly$lastHttpStatus 为 null 表示传输失败
DeadLetterEntry::markReplayed返回一个 replayed = true 的副本self不抛出id 相同;原条目保持不变
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
  • 注册按租户建立索引。register() 会拒绝其租户标识符与调用上下文不匹配的注册。unregister() 是一次软停用:注册被替换为一个非活跃副本,在保留历史的同时将其从未来的派发中排除。
  • dispatch() 只遍历调用方租户中订阅了所派发事件类型的活跃注册。订阅事件列表为空意味着订阅全部事件。返回值计的是成功投递数。
  • 每次投递都是一个带 JSON body 和五个 header 的 HTTP POST:Content-Type: application/jsonX-NextPDF-Signaturesha256=<hex>)、X-NextPDF-Timestamp(unix 秒)、X-NextPDF-Delivery-IdX-NextPDF-Event
  • JSON body 字段为 delivery_idjob_idevent_typedatatimestamp(RFC 3339 扩展)与 tenant_id,序列化时斜杠不转义。事件类型值来自 nextpdf/core 中的 JobEventTypeprogresscompletedfailedcancelled
  • 签名方案(3.1.0 中变更,破坏性)。 HMAC-SHA256 基串为 {signedTimestamp}.{jsonBody},以注册密钥为键——而非仅对 body。X-NextPDF-Timestamp 值是 MAC 的时间戳分量,因此被篡改或被重放的时间戳 header 会使签名失效。
  • 接收方校验:读取 X-NextPDF-Timestamp header T;当 T 超出可接受的新鲜度窗口(例如 300 s)时拒绝;对收到的原始字节重新计算 hash_hmac('sha256', T . '.' . rawBody, secret);在剥除 sha256= 前缀后以恒定时间与 header 值比较。
  • body、签名与投递 id 每次投递只计算一次,并在各次重试尝试间保持不变。
  • SSRF 出站门。 在每次尝试之前,目标 URL 都会通过 Core 的 UrlValidator::validateExternalUrl() 门:仅允许 HTTPS 方案;回环、私有、保留、运营商级 NAT、云元数据,以及内嵌 IPv4 的 IPv6 过渡范围均被阻止;主机名会经 DNS 解析(A 与 AAAA),无法解析的主机以失败关闭方式被拒绝。被阻止的 URL 绝不会发送:尝试循环中止,负载直接路由到死信队列,最后错误为 Blocked SSRF destination:,HTTP 状态为 null。
  • 逐次尝试的结果分类:2xx 为成功并立即返回;除 429 外的 4xx 为终态,直接进入死信;其余每一种结果——3xx、429、5xx 或传输异常——都可重试,最多重试到策略规定的总尝试次数。
  • 退避是指数级的:下一次尝试前的等待为 baseDelaySeconds × 2^(attempt − 1),以 maxDelaySeconds 封顶。最后一次尝试之后会跳过等待。
  • 当没有任何尝试成功时,一条 DeadLetterEntry 会记录一个唯一 id、注册 id、原始负载、尝试计数(被钳制到策略上限)、最后一条错误消息、最后的 HTTP 状态(传输失败或 SSRF 阻止时为 null),以及失败时间戳。
  • 死信队列在内存中,且作用域为进程生命周期。markReplayed() 产出一个带标记的副本;它不会重新发送,且队列保留原条目。
  • 事件列表为空。 该注册会收到每一种事件类型。当接收方不应看到全部事件时,请显式限定该列表的范围。
  • 终态 4xx 与传输失败之别。 一次 4xx 拒绝会记录一个已填充的 lastHttpStatus;一次连接失败会记录 null。可用该 null 来区分接收方拒绝与传输失败。
  • 被 SSRF 阻止的目标。 指向 HTTP、私有、回环或元数据地址的注册会在首次尝试即进入死信,错误为 Blocked SSRF destination:,状态为 null。不会发出任何出站请求。请修正 URL 后重新注册。
  • 升级后的旧接收方。 仍在校验 3.1.0 之前仅对 body 的 HMAC 的接收方,面对 3.1.0 的投递会失败关闭。请将接收方迁移到 {timestamp}.{body} 基串,并消费 X-NextPDF-Timestamp
  • 无法编码的事件数据。 toJson()sign() 会抛出 JsonException,它在任何尝试发生之前就从 deliver()dispatch() 传播出来。
  • 同步阻塞。 deliver() 会在各次尝试之间内联休眠。在 default 策略下累计退避达到 15 s,在 aggressive 策略下约为 17 分钟。当接收方延迟不可信时,请从队列 worker 中派发。
  • 尝试计数钳制。 即使内部循环计数器在耗尽时越过了策略上限,所记录的尝试计数也绝不会超过策略上限。
  • 队列增长与持久性。 死信队列在进程内无界增长,并在重启时消失。当需要持久重放时,请先经由 deadLetters() 导出条目并在外部持久化,再调用 clearDeadLetters()
  • 重放由运营方驱动。 重新投递意味着用该条目的负载再次调用 deliver()markReplayed() 只是在一个副本上记录这一事实。
  • DNS 重绑定残留风险。 URL 会在每次尝试时被重新校验,这会收窄但并不关闭重绑定窗口:PSR-18 抽象无法将连接固定到已校验的 IP。在该残留风险要紧之处,请添加网络层出站控制。
  • 密钥处理。 注册密钥是一项凭据。HMAC 仅认证完整性与来源——它不提供机密性。请勿将接收方不应看到的数据放入事件负载。

负载签名是经由 PHP 的 hash_hmac() 的 HMAC-SHA256,因此它依赖宿主密码学提供方。在受 FIPS 约束的构建中,非批准原语会在密码学边界处失败,而非降级。webhook 层不附加自身的任何密码学策略。

  • 负载认证实现了 HMAC,即 FIPS PUB 198-1 §1 的带密钥哈希消息认证码,以 SHA-256 实例化。
  • 重放防护遵循 OWASP Cheat Sheet Series 的 webhook 安全指导:事件时间戳在一个专用 header 中传输,并被播入签名计算,因此被篡改的时间戳会校验失败。
  • body 时间戳使用 RFC 3339 扩展日期时间格式。代码声明:本页未从 RAG 语料库检索 RFC 3339。
  • 这些是基于产品源码与所引条款的能力陈述。NextPDF 不对此面作出任何符合性或认证声明。
  • 所有类均声明 strict_types=1 且为 finalWebhookRegistrationWebhookPayloadWebhookRetryPolicyDeadLetterEntryfinal readonly,带提升的 public 属性。
  • 该模块带有 2.2.0@since 注解;绑定时间戳的签名方案是 3.1.0 中一处有记录的破坏性变更。
  • 投递引擎接受 PSR-18/PSR-17 抽象,因此一个 mock HTTP 客户端即可离线演练完整的发送、重试与死信路径。logger 默认为 null;请在生产环境中注入一个 PSR-3 logger,否则失败只会通过返回值体现。
  • 接收方实现应使用 hash_equals() 进行签名比较,并对 X-NextPDF-Timestamp 强制一个新鲜度窗口。
  • 建议的边界测试:租户不匹配的注册、事件列表为空的扇出、终态 4xx、重试耗尽、被 SSRF 阻止的 URL、针对固定向量的时间戳篡改签名拒绝,以及死信尝试计数钳制。

本页仅记录外部可观察的行为与受支持的公开 API 面。内部命名空间路径、辅助类、机制表、runbook 文件名与工单前缀均不在范围之内。