— 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.
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
| Field | Type | Notes |
|---|---|---|
site_id | UUID v4 | Issued during onboarding. Stable per WordPress install. |
job_id | string | Unique per delivery. Use for idempotency — if you’ve seen this job_id already, treat the request as a replay and skip the write. |
spec_version | string | Webhook contract version. Currently always "1.0". |
generated_at | ISO 8601 UTC | When Pass 3 finalized the graph. Independent from transport timestamp. |
graph | JSON-LD | The full @context + @graph object. Write verbatim to the plugin’s storage option. |
meta | object | Pipeline 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)
- Timestamp freshness — reject if
abs(now - timestamp) > 300seconds. Returns410 Gone. - Signature match — use a constant-time comparison (
hash_equalsin PHP,crypto.timingSafeEqualin Node). Returns401 Unauthorizedon mismatch. - Idempotency — if you’ve stored this
job_idbefore, return202 Acceptedwithout re-writing. Prevents replay attacks inside the 5-minute window. - 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.
| Status | Name | When | Retry? |
|---|---|---|---|
202 | Accepted | Graph stored successfully, or idempotent replay of a known job_id. | No |
400 | Bad Request | Missing required header or malformed JSON body. | No (fix & re-deliver manually) |
401 | Unauthorized | HMAC signature does not verify. | No (key drift — alert) |
410 | Gone | Timestamp outside the ±300s window. | No |
500 | Server Error | Database write failed, PHP error, or unhandled exception. | Yes — exponential backoff |
503 | Service Unavailable | Plugin 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:
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_SECRETinwp-config.phpor 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/graphwith 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
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.