Outbound License Webhooks

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.

text
┌─────────────────┐       HTTP POST (advisory event)       ┌────────────────────────┐
│  WooNooW Store  │ ─────────────────────────────────────> │  External Consumer     │
│  (License Auth) │                                        │  (e.g., Notif.in / SaaS│
└─────────────────┘                                        └────────────────────────┘
         ▲                                                             │
         │           POST /licenses/validate (authoritative)           │
         └─────────────────────────────────────────────────────────────┘

1. Advisory, notification-only contract

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:

json
{
  "license_key": "XXXX-YYYY-ZZZZ-WWWW",
  "domain": "https://consumer-app.example.com",
  "installation_id": "550e8400-e29b-41d4-a716-446655440000"
}

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.

SettingWordPress OptionFilter KeyDescription
Target URLwoonoow_license_webhook_urlurlDestination endpoint on the external server. Sanitized using esc_url_raw.
Shared Secretwoonoow_license_webhook_secretsecretShared 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:

php
add_filter('woonoow_license_webhook_settings', function (array $settings) {
    return [
        'url'    => defined('NOTIFIN_WEBHOOK_URL') ? NOTIFIN_WEBHOOK_URL : $settings['url'],
        'secret' => defined('NOTIFIN_WEBHOOK_SECRET') ? NOTIFIN_WEBHOOK_SECRET : $settings['secret'],
    ];
});

HTTP Request Envelope & Headers

Every webhook delivery sends an HTTP POST request with a JSON body and the following delivery headers:

http
POST /webhooks/woonoow HTTP/1.1
Host: consumer-app.example.com
Content-Type: application/json
X-Woonoow-Event-Id: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
X-Woonoow-Timestamp: 1790467200
X-Woonoow-Signature: sha256=d3b07384d113edec49eaa6238ad5ff00b9576dae904b8637cb337e7ef06b4b45

Headers

HeaderFormatDescription
Content-Typeapplication/jsonWebhook payloads are JSON-encoded.
X-Woonoow-Event-IdUUID v4 stringCanonical unique identifier for the event instance. Identical across all retries of the same event.
X-Woonoow-TimestampInteger stringSite Unix epoch timestamp (time()) at the exact time this specific delivery attempt was made.
X-Woonoow-Signaturesha256=<hex>HMAC-SHA256 signature computed across the raw request body using the shared secret.

Cryptographic Signature & Replay Limitations

Understanding exactly what is—and what is not—covered by the cryptographic signature is essential for secure receiver design.

1. Signature generation

The signature is generated in LicenseWebhookDispatcher::signature():

php
$signature = 'sha256=' . hash_hmac('sha256', (string) $raw_payload, (string) $secret);
  • $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:

  1. 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.
  2. 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.
  3. 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.

json
{
  "event_id": "8f3b2075-8120-4318-971c-4bbf52044813",
  "event": "license.renewed",
  "timestamp": "2026-09-11T14:30:00Z",
  "data": {
    "license_id": 1042,
    "subscription_id": 512,
    "order_id": 8901
  },
  "authoritative_validation_url": "https://your-store.com/wp-json/woonoow/v1/licenses/validate"
}
FieldTypeDescription
event_idUUID v4Unique identifier for this renewal notification.
eventstringConstant 'license.renewed'.
timestampstringRFC3339 UTC timestamp when the event occurred on the store.
data.license_idintegerWooNooW internal database ID of the renewed license.
data.subscription_idintegerAssociated WooNooW subscription ID.
data.order_idintegerWooCommerce renewal order ID.
authoritative_validation_urlstringAdvisory 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).

json
{
  "event_id": "c1f7a1e0-6e3e-4fa0-8f92-5cb0f9f3f901",
  "event": "license.revoked",
  "timestamp": "2026-09-11T14:35:00Z",
  "data": {
    "license_id": 1042
  },
  "authoritative_validation_url": "https://your-store.com/wp-json/woonoow/v1/licenses/validate"
}
FieldTypeDescription
data.license_idintegerWooNooW 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.

json
{
  "event_id": "3d5f17d2-7ae6-4e56-b9a3-a72c1c68194a",
  "event": "subscription.status_changed",
  "timestamp": "2026-09-11T14:40:00Z",
  "data": {
    "subscription_id": 512,
    "status": "cancelled",
    "reason": "customer_request"
  },
  "authoritative_validation_url": "https://your-store.com/wp-json/woonoow/v1/licenses/validate"
}
FieldTypeDescription
data.subscription_idintegerWooNooW internal database ID of the subscription.
data.statusstringNew subscription status (e.g., 'cancelled', 'expired', 'pending-cancel', 'on-hold').
data.reasonstringMachine-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:

AttemptDelay After Previous AttemptCumulative Time from Event
1Immediate0 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:

php
[
    'event_id'      => '8f3b2075-8120-4318-971c-4bbf52044813',
    'event'         => 'license.renewed',
    'attempt'       => 1,
    'response_code' => 200,
    'delivered'     => true,
    'error'         => '',
    'attempted_at'  => '2026-09-11T14:30:01Z',
]

Receiver Implementation Reference

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):

text
[HTTP Webhook] ──> 1. INSERT processedEvents ──> 2. PUSH external queue (CRASH / OUTAGE!)
                                                             │
                                                             ▼ (Job never queued)
[Sender Retry] ──> 1. Duplicate check hits!  ───> Returns 200 OK (Event permanently lost)

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):

sql
CREATE TABLE webhook_inbox (
    trusted_store_id VARCHAR(64)  NOT NULL,
    event_id         VARCHAR(64)  NOT NULL,
    event_type       VARCHAR(64)  NOT NULL,
    payload          JSON         NOT NULL,
    status           VARCHAR(20)  NOT NULL DEFAULT 'pending', -- 'pending', 'processing', 'done', 'failed'
    attempts         INT          NOT NULL DEFAULT 0,
    next_attempt_at  TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    created_at       TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    processed_at     TIMESTAMP    NULL,
    last_error       TEXT         NULL,
    PRIMARY KEY (trusted_store_id, event_id)
);

CREATE INDEX idx_webhook_inbox_claim 
ON webhook_inbox (status, next_attempt_at, created_at);

2. Transactional Inbox Algorithm

The implementation decouples synchronous acknowledgment from asynchronous revalidation.

Phase 1: Ingestion & Durable Commit (Synchronous HTTP Handler)

text
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.

Phase 2: Worker Execution & Revalidation (Asynchronous Worker Process)

text
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.

Last updated Sep 11, 2026