— Developer Documentation —

The Webhook Contract.

Complete reference for the signed webhook delivery protocol between the Schema Monkee cloud and your WordPress install. One-way delivery, HMAC-signed, idempotent, zero runtime PHP in the hot path.

Spec v1.0 Last updated 2026-04-17 Stable

Overview

Schema Monkee runs the 3-pass AI pipeline in our cloud. Once Pass 3 finalizes your entity graph, we deliver the perfected JSON-LD to your WordPress install by calling a single REST endpoint the plugin exposes. The plugin verifies the request, writes the graph to a single option row, and the graph becomes your site’s canonical schema on the next page render.

The contract is one-way (cloud → WordPress, never the reverse), signed (HMAC-SHA256 over timestamp + body), idempotent (safe to retry, replays within a 5-minute window are rejected), and tiny on the wire (median payload ~15KB, typically sub-50KB).

  • Delivery direction: Schema Monkee cloud → your WordPress
  • Transport: HTTPS POST, JSON body
  • Auth: HMAC-SHA256 via X-SM-Signature + X-SM-Timestamp
  • Max clock skew tolerated: ±300 seconds
  • Retry ceiling: 7 attempts over 24 hours with exponential backoff
  • Plugin surface: one REST route, no outbound network, no database schema changes

Endpoint

The plugin registers one route. No authentication beyond the HMAC signature — the plugin cannot check a nonce because the request originates from our cloud, not a logged-in browser.

POST https://your-site.example/wp-json/sm/v1/graph
Content-Type:    application/json
X-SM-Signature:  sha256=3f1c4ae8d6c62b1bce4…
X-SM-Timestamp:  1760000000
X-SM-Version:    1.0
User-Agent:      SchemaMonkeeWebhook/1.0 (+https://monkee.ai)

All five headers are required. A request missing any of them returns 400 Bad Request without the signature ever being checked.

Payload

The request body is a single JSON object. All top-level fields are required. The graph field is the perfected @graph from Pass 3 — by the time it hits your server it’s already verified against your Golden Source and enriched with sameAs authority links.

{
  "site_id":      "ac1e52e4-8d5e-4a0a-9f3a-3b9db4e36a7c",
  "job_id":       "audit_20260417_083214_yzaBJ5G6",
  "spec_version": "1.0",
  "generated_at": "2026-04-17T08:32:14Z",
  "graph": {
    "@context": "https://schema.org",
    "@graph": [ /* your perfected entity graph */ ]
  },
  "meta": {
    "pipeline_version":         "3.1.0",
    "entities_count":           12,
    "hallucinations_corrected": 3,
    "same_as_added":            5,
    "wikidata_qid":             "Q123456789"
  }
}

Field reference

FieldTypeNotes
site_idUUID v4Issued during onboarding. Stable per WordPress install.
job_idstringUnique per delivery. Use for idempotency — if you’ve seen this job_id already, treat the request as a replay and skip the write.
spec_versionstringWebhook contract version. Currently always "1.0".
generated_atISO 8601 UTCWhen Pass 3 finalized the graph. Independent from transport timestamp.
graphJSON-LDThe full @context + @graph object. Write verbatim to the plugin’s storage option.
metaobjectPipeline metadata for diagnostics. Store but do not emit to wp_head.

Signature verification

The signature is an HMAC-SHA256 hex digest, prefixed with sha256=. The input to HMAC is the concatenation of the X-SM-Timestamp header, a literal period, and the exact raw request body — in that order, no whitespace.

# Construction (cloud side, for reference)
hmac_input  = timestamp + "." + raw_body
signature   = "sha256=" + hmac_sha256(hmac_input, shared_secret).hex()

# Verification (your WordPress plugin)
expected = "sha256=" + hash_hmac("sha256", $ts . "." . $body, SM_WEBHOOK_SECRET)
if ( ! hash_equals( $expected, $sig ) ) {
    return new WP_REST_Response( [ "error" => "invalid_signature" ], 401 );
}

Required checks (in order)

  1. Timestamp freshness — reject if abs(now - timestamp) > 300 seconds. Returns 410 Gone.
  2. Signature match — use a constant-time comparison (hash_equals in PHP, crypto.timingSafeEqual in Node). Returns 401 Unauthorized on mismatch.
  3. Idempotency — if you’ve stored this job_id before, return 202 Accepted without re-writing. Prevents replay attacks inside the 5-minute window.
  4. Payload shape — validate the top-level fields exist before any database write.

Do not use == or === to compare signatures. Timing-unsafe comparisons leak signature bytes to an attacker over many requests.

Response codes

Your plugin should return one of the following. Anything else is treated as a server error and triggers the retry policy.

StatusNameWhenRetry?
202AcceptedGraph stored successfully, or idempotent replay of a known job_id.No
400Bad RequestMissing required header or malformed JSON body.No (fix & re-deliver manually)
401UnauthorizedHMAC signature does not verify.No (key drift — alert)
410GoneTimestamp outside the ±300s window.No
500Server ErrorDatabase write failed, PHP error, or unhandled exception.Yes — exponential backoff
503Service UnavailablePlugin disabled, maintenance mode, or rate-limited.Yes — exponential backoff

Retry policy

On any 5xx response — or a transport failure, timeout, or TLS error — we retry with exponential backoff. The schedule is:

Attempt 1immediate
Attempt 2+5 s
Attempt 3+30 s
Attempt 4+5 min
Attempt 5+30 min
Attempt 6+3 hrs
Attempt 7+12 hrs
Dead letterafter ~24 hrs

After seven attempts, the job moves to our dead-letter queue. You’ll see it flagged in the dashboard with the last response body, and you can replay manually once the underlying issue (expired SSL cert, plugin deactivated, WP Engine maintenance) is resolved. job_id is preserved, so the idempotency guard still holds even on manual replay.

Reference implementation

This is the full handler the Schema Monkee plugin ships with. You don’t need to implement it yourself — it’s here so you can see exactly what we’re running on your server.

add_action( 'rest_api_init', function () {
    register_rest_route( 'sm/v1', '/graph', [
        'methods'             => 'POST',
        'callback'            => 'sm_handle_graph_webhook',
        'permission_callback' => '__return_true', // auth is the HMAC signature
    ] );
} );

function sm_handle_graph_webhook( WP_REST_Request $request ) {
    $body = $request->get_body();
    $sig  = $request->get_header( 'X-SM-Signature' );
    $ts   = $request->get_header( 'X-SM-Timestamp' );
    $ver  = $request->get_header( 'X-SM-Version' );

    // 1. Required headers
    if ( ! $sig || ! $ts || ! $ver ) {
        return new WP_REST_Response( [ 'error' => 'missing_headers' ], 400 );
    }

    // 2. Timestamp freshness (±300s)
    if ( abs( time() - intval( $ts ) ) > 300 ) {
        return new WP_REST_Response( [ 'error' => 'stale_timestamp' ], 410 );
    }

    // 3. Constant-time signature check
    $expected = 'sha256=' . hash_hmac( 'sha256', $ts . '.' . $body, SM_WEBHOOK_SECRET );
    if ( ! hash_equals( $expected, $sig ) ) {
        return new WP_REST_Response( [ 'error' => 'invalid_signature' ], 401 );
    }

    // 4. Parse + idempotency
    $data = json_decode( $body, true );
    if ( ! isset( $data['job_id'], $data['graph'] ) ) {
        return new WP_REST_Response( [ 'error' => 'malformed' ], 400 );
    }
    $seen = get_option( 'sm_last_job_id' );
    if ( $seen === $data['job_id'] ) {
        return new WP_REST_Response( [ 'ok' => true, 'idempotent' => true ], 202 );
    }

    // 5. Atomic write
    update_option( 'sm_graph',        wp_json_encode( $data['graph'] ) );
    update_option( 'sm_graph_meta',   wp_json_encode( $data['meta'] ?? [] ) );
    update_option( 'sm_last_job_id',  $data['job_id'] );
    update_option( 'sm_last_graph_at', $data['generated_at'] );

    return new WP_REST_Response( [ 'ok' => true, 'job_id' => $data['job_id'] ], 202 );
}

Security considerations

  • Shared secret storage. Keep SM_WEBHOOK_SECRET in wp-config.php or your host’s environment variables — never in the database, never in version control.
  • Secret rotation. You can rotate from the dashboard at any time; the cloud signs with the new key on the next delivery. Keep the old key valid for 10 minutes to catch in-flight retries, then revoke.
  • HTTPS required. Our cloud refuses to deliver to http:// origins. If your site is HTTP-only, we won’t send.
  • No outbound from the plugin. The plugin is write-only. It never calls back to our cloud, never fetches anything, never “phones home.”
  • Rate limit at your edge. If you’re behind Cloudflare or WAF, allow POST to /wp-json/sm/v1/graph with a per-minute ceiling that matches your delivery cadence (typically <5/hour; spikes during bulk re-generation).
  • Log and audit. The plugin writes job_id + timestamp to the options table on every success. Cross-reference against the dashboard’s delivery log any time you suspect drift.

Changelog

v1.0 2026-04-17 Initial public spec. Stable.

Breaking changes will bump the major version and always ship with at least 90 days of dual-version support. Your plugin reports its supported spec version in every response’s X-SM-Plugin-Version header; we’ll stop shipping new-version payloads to it until you upgrade.

Questions? Reach a human.

This spec is canonical — but integration edge cases come up. Email dev@monkee.ai and we’ll route you to the engineer who wrote the handler.