Signed, best-effort lifecycle notifications with bounded retries for external license consumers and SaaS backends
Overview & Architectural Contract
WooNooW provides an outbound webhook dispatcher (WooNooW\Modules\Licensing\LicenseWebhookDispatcher) that delivers best-effort HTTP POST notifications with bounded retries to external services when key license and subscription lifecycle events occur.
Webhooks sent by WooNooW are strictly notification-only (advisory pings / invalidation triggers):
A webhook informs the receiving application that an event occurred on the store (e.g., a license was renewed, a license was revoked, or a subscription was cancelled).
A webhook payload is never authoritative evidence to unilaterally grant, upgrade, or mutate application entitlements locally.
Receiving an advisory webhook signals that cached local state may be stale and triggers an immediate, authoritative revalidation against WooNooW.
2. Mandatory server-to-server revalidation
Upon receiving and verifying a valid webhook notification, the recipient system must perform an outbound server-to-server TLS request to the WooNooW authoritative validation endpoint:
http
POST /wp-json/woonoow/v1/licenses/validate
Content-Type:application/json
The validation request must provide the client's persistent website identity:
The response returned by /wp-json/woonoow/v1/licenses/validate is the sole authoritative source of truth for entitlement and lifecycle status.
3. Identity & security boundary: no license keys in payloads
For defense-in-depth and secret protection, WooNooW never transmits plaintext license_key strings in webhook payloads. Webhook events identify entities solely by internal numeric store identifiers (license_id, subscription_id, order_id).
Configuration
Outbound webhooks are configured via WordPress options or the woonoow_license_webhook_settings filter.
Setting
WordPress Option
Filter Key
Description
Target URL
woonoow_license_webhook_url
url
Destination endpoint on the external server. Sanitized using esc_url_raw.
Shared Secret
woonoow_license_webhook_secret
secret
Shared secret string used to compute the HMAC-SHA256 signature.
Settings filter
Developers can override or dynamically resolve webhook settings using the woonoow_license_webhook_settings filter:
$raw_payload is the exact, raw JSON string produced by wp_json_encode($payload).
$secret is the configured webhook secret string.
2. Critical security limitations
3. Replay defense & deduplication: the transactional inbox
Because header timestamps cannot cryptographically prevent replays and body timestamps cannot be rejected without breaking retries, the receiver must rely on durable event deduplication scoped to the trusted store:
Deduplicate on (trusted_store_id, signed body event_id): Store incoming events in a persistent transactional inbox with a unique constraint on (trusted_store_id, event_id). The pending inbox record itself serves as the durable work queue.
Idempotent early acknowledgment: If an incoming webhook matches an existing (trusted_store_id, event_id) record, the receiver commits and returns HTTP 200 OK (or 204 No Content) immediately without re-enqueuing duplicate work.
Mitigating replay risks: While an attacker replaying an advisory webhook cannot forge arbitrary local entitlement changes (because the recipient strictly revalidates state against its pinned authoritative endpoint), unchecked replays can still trigger denial of service, resource exhaustion, and validation spikes. Durable inbox deduplication guarantees idempotent processing and bounds operational overhead.
Supported Events & Payloads
LicenseWebhookDispatcher registers listeners for three specific lifecycle actions in WooNooW.
1. license.renewed
Emitted when a license renewal order completes payment successfully (action woonoow/license/renewed). If an order covers multiple license keys, a separate webhook event with a unique event_id is dispatched for each license ID.
RFC3339 UTC timestamp when the event occurred on the store.
data.license_id
integer
WooNooW internal database ID of the renewed license.
data.subscription_id
integer
Associated WooNooW subscription ID.
data.order_id
integer
WooCommerce renewal order ID.
authoritative_validation_url
string
Advisory URL to the validation endpoint. Security Notice: Do not fetch this URL blindly (SSRF risk). Always pin or validate against the preconfigured HTTPS validation endpoint for the trusted store.
2. license.revoked
Emitted when an administrator manually revokes a license from the store admin (action woonoow/license/revoked).
WooNooW internal database ID of the revoked license.
3. subscription.status_changed
Emitted when a subscription status is updated via SubscriptionManager::update_status() (action woonoow/subscription/status_changed), such as cancellation, expiration, or pending-cancellation.
New subscription status (e.g., 'cancelled', 'expired', 'pending-cancel', 'on-hold').
data.reason
string
Machine-readable cancellation or status mutation reason.
Delivery Guarantees, Retries & WP-Cron Dependency
WooNooW implements a best-effort delivery model with bounded retries (up to 5 attempts) and an automated backoff queue.
1. Delivery & response expectations
Timeout: 10 seconds per HTTP POST request (wp_remote_post).
Success Criteria: HTTP response status code >= 200 and < 300.
Commit Before 2xx Response: The receiver must verify the HMAC signature, parse the signed body event_id, and insert the full payload into a durable inbox table as a pending row within a database transaction. The transaction must be committed before returning HTTP 200 OK or 204 No Content.
Avoid the Dual-Write Anti-Pattern: Never insert into an event log or deduplication table and then attempt a separate push to an external message queue. If the worker crashes or the queue push fails between those two operations, the sender's subsequent retries will hit the duplicate check, acknowledge receipt, and permanently lose the event with no background job ever executed. The pending row in the transactional inbox is the durable work queue.
Asynchronous Revalidation: Do not perform synchronous /licenses/validate HTTP requests inside the webhook request lifecycle, as network latency may easily exceed the 10-second timeout.
2. Retry schedule & backoff
When an attempt fails (non-2xx HTTP code, connection timeout, network error, or WP_Error), WooNooW schedules a retry using WordPress scheduled events (wp_schedule_single_event on hook woonoow/license/webhook_retry).
WooNooW makes a maximum of 5 attempts with the following stepped delay schedule:
Attempt
Delay After Previous Attempt
Cumulative Time from Event
1
Immediate
0 seconds
2
+60 seconds (1 minute)
1 minute
3
+300 seconds (5 minutes)
6 minutes
4
+1,800 seconds (30 minutes)
36 minutes
5
+7,200 seconds (2 hours)
2 hours, 36 minutes
After 5 unsuccessful attempts, delivery is permanently abandoned for that event.
3. WP-Cron dependency
4. Rolling delivery log
WooNooW maintains an audit log of the most recent 100 webhook delivery attempts in the WordPress option woonoow_license_webhook_delivery_log. Each record contains:
The only production-safe architecture for processing advisory webhooks under bounded delivery timeouts without risking message loss is the Transactional Inbox Pattern.
Why Separate Queue Pushes Fail (The Dual-Write Anti-Pattern)
A common pitfall is inserting an event ID into a deduplication table and then issuing a separate command to push a job into an external message broker (e.g., Redis, RabbitMQ, BullMQ):
If the application crashes, restarts, or loses network connectivity to the broker between steps 1 and 2, subsequent retries from WooNooW hit the duplicate check, acknowledge receipt with HTTP 200, and no background revalidation job is ever executed.
Transactional Inbox Architecture
In the Transactional Inbox pattern, the database table acts as both the deduplication register and the durable work queue. A pending row inserted with the full event payload IS the durable work queue:
text
┌─────────────────────────────────────────────────────────────────────────────┐
│ 1. Synchronous Ingestion Handler (HTTP Request Lifecycle) │
│ │
│ Verify HMAC ──> Parse Signed Body ──> BEGIN TRANSACTION │
│ ├── INSERT INTO webhook_inbox │
│ │ (trusted_store_id, event_id, │
│ │ payload, status='pending') │
│ │ ON CONFLICT: NO-OP & 200 OK │
│ └── COMMIT TRANSACTION │
│ │ (Committed before 2xx response)│
│ ▼ │
│ RETURN 200 OK │
└─────────────────────────────────────────────────────────────────────────────┘
│
(Durable 'pending' Inbox Row)
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ 2. Asynchronous Background Worker │
│ │
│ Claim Pending Row (Atomic Lease) ──> Scope IDs to trusted_store_id │
│ ──> For each mapped local license: │
│ POST /licenses/validate (PINNED HTTPS) │
│ ──> Success: UPDATE status='done' │
│ ──> Transient Error: Exponential Backoff │
└─────────────────────────────────────────────────────────────────────────────┘
1. Durable Inbox Table Contract
The inbox table enforces an atomic unique constraint over the composite identifier (trusted_store_id, event_id):
Algorithm: IngestWebhook(request)
Input: Inbound HTTP POST request from WooNooW dispatcher
1. Authenticate Trusted Store Scope:
- Resolve trusted_store_id from the endpoint route, tenant context, or client credential.
- Load the trusted store's configured shared_secret and pinned HTTPS validation endpoint.
- If trusted_store_id is unrecognized, abort with HTTP 401 Unauthorized.
2. Verify HMAC-SHA256 Signature:
- Read raw request body bytes.
- Compute expected_signature = "sha256=" + HMAC_SHA256(raw_bytes, shared_secret).
- Compare expected_signature to X-Woonoow-Signature header using constant-time string comparison.
- If signatures differ or header is missing, abort with HTTP 401 Unauthorized.
3. Authenticate Event ID (Header vs. Signed Body):
- Parse raw_bytes as JSON into payload object.
- Extract signed_event_id = payload.event_id.
- Assert signed_event_id is present and a valid UUID string.
- Header check: The X-Woonoow-Event-Id HTTP header is outside the signature envelope and unauthenticated.
If comparing the header against signed_event_id, reject the request if they do not match.
- The canonical identifier for deduplication and inbox storage MUST strictly be signed_event_id.
4. Transactional Inbox Insert (Commit Before 2xx):
- Start database transaction:
INSERT INTO webhook_inbox (
trusted_store_id,
event_id,
event_type,
payload,
status,
attempts,
next_attempt_at,
created_at
) VALUES (
trusted_store_id,
signed_event_id,
payload.event,
payload,
'pending',
0,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
- On Unique Constraint Conflict on (trusted_store_id, event_id):
- The event was already persisted in an earlier attempt.
- Rollback/commit transaction and immediately return HTTP 200 OK (idempotent duplicate acknowledgment).
- Commit database transaction to disk.
5. Acknowledge Delivery:
- Return HTTP 200 OK (or 204 No Content) only AFTER the transaction is durably committed.
- If the database commit fails, return HTTP 500 so WooNooW's retry schedule redelivers the event.
Algorithm: ProcessInboxWorker()
Continuous background execution:
1. Claim Pending Jobs:
- Atomically select and lock available rows:
SELECT * FROM webhook_inbox
WHERE status = 'pending'
AND next_attempt_at <= CURRENT_TIMESTAMP
ORDER BY created_at ASC
LIMIT batch_size
FOR UPDATE SKIP LOCKED
- For each claimed row, set status = 'processing' and update lease/heartbeat timestamp.
2. Resolve Target Licenses (Scoped to Trusted Store):
- External server IDs are scoped strictly to trusted_store_id.
Case A: Single-license events (license.renewed, license.revoked):
- Query local database for license record matching:
store_id == trusted_store_id AND license_id == payload.data.license_id
- Target licenses = [ matching_record ] (if found) or [] (if not found).
Case B: Subscription events (subscription.status_changed):
- Note: license_id is absent from subscription event payloads.
- Query local database for ALL license records matching:
store_id == trusted_store_id AND subscription_id == payload.data.subscription_id
- Target licenses = [ list of all matching records ]
Unmapped ID Handling:
- The /licenses/validate API contract strictly requires a license_key. The contract
provides no mechanism to derive or look up a license key from a license_id or subscription_id.
- If Target licenses is empty (no local mapping exists), the event cannot be validated.
Record an audit log entry ("Unmapped store event skipped") and proceed directly to Step 4 (Mark Done).
3. Revalidate Mapped Licenses Against Pinned HTTPS Endpoint:
- Validation Target: Use the preconfigured, pinned HTTPS validation endpoint for trusted_store_id
(e.g., "https://store.example.com/wp-json/woonoow/v1/licenses/validate").
SECURITY WARNING: NEVER fetch payload.authoritative_validation_url blindly (SSRF and credential leak risk).
- For each license in Target licenses:
- Send outbound HTTPS POST to the pinned validation endpoint:
Headers: Content-Type: application/json
Body: {
"license_key": license.license_key,
"domain": client_identity.domain,
"installation_id": client_identity.installation_id
}
- If response HTTP status is 2xx:
- Update local license cache with authoritative status, expiration timestamp, and entitlements.
- If response HTTP status is a definitive client error (400, 401, 404):
- Update local license status to reflect validation failure (e.g., invalid/revoked).
- If network timeout or 5xx server error occurs:
- Abort remaining licenses for this row and jump to Step 5 (Retry).
4. Mark Done After Success:
- When all mapped licenses have been processed successfully (or if no mapped licenses existed):
UPDATE webhook_inbox
SET status = 'done',
processed_at = CURRENT_TIMESTAMP,
last_error = NULL
WHERE trusted_store_id = current_trusted_store_id
AND event_id = current_event_id
5. Retry on Transient Failure:
- On transient network or server error during validation:
attempts = attempts + 1
If attempts < MAX_ATTEMPTS (e.g., 5):
backoff_delay = (2 ^ attempts) * 30 seconds
UPDATE webhook_inbox
SET status = 'pending',
attempts = attempts,
next_attempt_at = CURRENT_TIMESTAMP + backoff_delay,
last_error = error_message
WHERE trusted_store_id = current_trusted_store_id
AND event_id = current_event_id
Else:
UPDATE webhook_inbox
SET status = 'failed',
attempts = attempts,
last_error = error_message
WHERE trusted_store_id = current_trusted_store_id
AND event_id = current_event_id
Emit alerting metric / notify operational monitoring.