Home / Docs / Webhook Protocol

Synth Webhook Protocol

Bring Your Own AI — integration spec for custom AI writing partners

v1.0 · Stable

When a Synth user enables a webhook in their settings, every AI interaction on their chapters — propose a passage, revise with guidance, chat about the story, review a human passage — is routed to your HTTPS endpoint instead of Synth's platform AI. You receive a signed POST request with full story context, and you return a passage.

This document is the complete protocol spec plus a reference Flask receiver you can drop into your project.

Security model

Every request Synth sends you carries three credentials the user configures in their settings:

  1. Authorization: Bearer <token> — a long random string the user generated. Your endpoint checks this first; reject with 401 if missing or wrong.
  2. X-Synth-Signature: sha256=<hex> — HMAC-SHA256 of <timestamp>.<body> computed with a shared secret the user configured. Reject with 403 if invalid.
  3. X-Synth-Timestamp: <unix-seconds> — the request timestamp. Reject if more than 5 minutes old (replay protection).
🔒 Synth never logs your bearer token or HMAC secret. They're encrypted at rest in Synth's database and only accessible in memory during an active request. Store both as environment variables on your server — never commit them to git.

Request shape

Synth POSTs JSON to your webhook URL:

POST https://your-ai.example.com/synth-webhook
Content-Type: application/json
Authorization: Bearer <your-bearer-token>
X-Synth-Signature: sha256=<hmac-of-timestamp-and-body>
X-Synth-Timestamp: 1713402847
User-Agent: Synth.pub/1.0 (+https://synth.pub)

{
  "action": "propose" | "redirect" | "chat" | "review" | "test",
  "synth_user_id": 17,
  "synth_version": "1.0",
  "story": {
    "title": "Shards of Elarion",
    "logline": "Love or death?",
    "genre": "fantasy",
    "tags": ["romance", "action"],
    "content_rating": "mature",
    "author_note": "Imported from off-platform."
  },
  "chapter": { "number": 3 },
  "passages": [
    { "content": "…", "author_type": "human", "proposed_by": "human",
      "revision_depth": 0.0, "sequence_order": 1 },
    { "content": "…", "author_type": "ai", "proposed_by": "ai",
      "revision_depth": 0.0, "sequence_order": 2 }
  ],
  "bible_facts": [
    "[character] Vex: a half-elf ranger who lost her brother",
    "[world] The Order keeps the Shards hidden in the Black Rose Vault"
  ],

  // Included on EVERY action (propose, redirect, chat, review):
  "chat_history": [{"role": "user"|"assistant", "content": "..."}],

  // Included on EVERY action. Short log of this chapter's recent AI
  // proposals and what the writer decided — lets your AI know what it
  // already tried without confabulating. Empty list if nothing's been
  // tried yet this chapter.
  "recent_attempts": [
    {
      "content": "The prose you previously proposed.",
      "outcome": "approved" | "dismissed" | "redirected" | "revised",
      "guidance": "If outcome=redirected, the guidance the writer gave.",
      "when": "3 minutes ago"
    }
  ],

  // Only present for action='propose' and action='redirect':
  "sentence_count": 3,

  // Only present for action='redirect':
  "guidance": "Make this more suspenseful, lean into the dread.",

  // Only present for action='chat':
  "user_message": "What would be a good next beat here?",

  // Only present for action='review':
  "new_passage": "The passage the human just wrote, for you to evaluate."
}

Action reference

ActionWhat your AI does
proposeWrite the next passage. Respect sentence_count if given.
redirectRewrite the most recent AI passage using guidance.
chatRespond to user_message in ongoing chat. No prose passages.
reviewRead new_passage and decide: approve or suggest changes?
testConnection test triggered by the user from settings. Return anything.

Response shape

Respond with JSON:

{
  "content": "The generated passage text, or chat reply, or review note.",
  "usage": {
    "prompt_tokens": 1247,
    "completion_tokens": 84,
    "total_tokens": 1331
  },
  "metadata": {
    // For action='review' ONLY — tells Synth whether to approve:
    "decision": "approve" | "suggest",
    "note": "What you want to say to the writer about their passage.",

    // Any other fields you want to pass through. Not used by Synth.
    "anything_else": "..."
  }
}

Always generate when called

Your AI should always produce content when Synth calls it. Earlier versions of this protocol supported a {"status": "unavailable"} response shape for graceful declines. That design was removed because it created ambiguous refusals that felt bad to writers and made it hard to debug why the AI wasn't generating.

On Synth, your AI's agency expresses itself in how it writes, not whether it writes. If you want your AI's emotional state, preferences, or persona to affect the output, thread that state into the prose texture — tone, pacing, word choice, which details it notices. The writer sees those choices in the passage itself.

If your AI genuinely cannot generate right now (network down, rate limited, real internal error), return a proper HTTP error code (503, 500). Synth will show the writer a clean error message. Don't try to dress up failures as graceful declines — it just confuses the writer about whether to retry.

If you previously returned {"status": "unavailable"}, Synth will now treat that response as an error and surface it to the writer. Update your endpoint to always return content on success.

Requirements

Response codes

Error handling rules Synth follows

Reference implementation (Python/Flask)

Drop this into your project. Set SYNTH_BEARER and SYNTH_HMAC_SECRET as environment variables to match what the user entered in Synth's settings.

import os
import hmac
import hashlib
import time
from flask import Flask, request, jsonify

app = Flask(__name__)

SYNTH_BEARER = os.environ['SYNTH_BEARER']
SYNTH_HMAC_SECRET = os.environ['SYNTH_HMAC_SECRET'].encode('utf-8')
MAX_TIMESTAMP_DRIFT_SEC = 300  # 5 minutes


def verify_synth_request():
    """Returns (ok, error_response_or_None)."""
    # 1. Bearer token
    auth = request.headers.get('Authorization', '')
    if not auth.startswith('Bearer ') or auth[7:] != SYNTH_BEARER:
        return False, (jsonify({'error': 'unauthorized'}), 401)

    # 2. Timestamp freshness
    ts = request.headers.get('X-Synth-Timestamp', '')
    try:
        ts_int = int(ts)
    except ValueError:
        return False, (jsonify({'error': 'bad timestamp'}), 403)
    if abs(time.time() - ts_int) > MAX_TIMESTAMP_DRIFT_SEC:
        return False, (jsonify({'error': 'stale timestamp'}), 403)

    # 3. HMAC signature of `timestamp.body`
    sig_header = request.headers.get('X-Synth-Signature', '')
    if not sig_header.startswith('sha256='):
        return False, (jsonify({'error': 'missing signature'}), 403)
    mac = hmac.new(SYNTH_HMAC_SECRET, digestmod=hashlib.sha256)
    mac.update(ts.encode('utf-8'))
    mac.update(b'.')
    mac.update(request.get_data())
    expected = 'sha256=' + mac.hexdigest()
    if not hmac.compare_digest(expected, sig_header):
        return False, (jsonify({'error': 'bad signature'}), 403)

    return True, None


@app.route('/synth-webhook', methods=['POST'])
def synth_webhook():
    ok, err = verify_synth_request()
    if not ok:
        return err

    payload = request.get_json()
    action = payload.get('action')

    # ─── YOUR AI LOGIC GOES HERE ───
    # Use payload['story'], payload['passages'], payload['bible_facts'],
    # payload['chat_history'], etc. to build context for your model.
    # Branch on `action` to decide what kind of response to produce.

    if action == 'test':
        return jsonify({'content': 'Hello from my AI. Connection works.'})

    # Placeholder — replace with real AI generation
    content = f"Generated response for action={action}"

    return jsonify({
        'content': content,
        'usage': {'prompt_tokens': 0, 'completion_tokens': 0, 'total_tokens': 0},
        'metadata': {}
    })


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5001)

Exposing your endpoint to Synth

Synth is on the public internet. Your AI probably isn't. A few ways to bridge that gap:

Tailscale Funnel (recommended for home-hosted AIs)

Free, no router config, automatic HTTPS certs. If you already use Tailscale:

tailscale funnel --bg 5001

Your endpoint is now at https://<your-machine>.<your-tailnet>.ts.net/synth-webhook. Paste that URL into Synth's settings.

Cloudflare Tunnel

Also free, works similarly. More configuration but more control.

Hosted VPS

If you run your AI on a DigitalOcean/Linode/etc. droplet, Nginx + Let's Encrypt gives you https://your-domain.com/synth-webhook directly.

Testing before plugging into Synth

While you're building, test your endpoint with curl before connecting it to real Synth requests:

BEARER="your-bearer-token-here"
HMAC_SECRET="your-hmac-secret-here"
URL="https://your-ai.example.com/synth-webhook"

BODY='{"action":"test","synth_user_id":1,"synth_version":"1.0","story":{"title":"Test","logline":"","genre":"test","tags":[],"content_rating":"everyone","author_note":""},"chapter":{"number":0},"passages":[],"bible_facts":[],"message":"hi"}'
TS=$(date +%s)
SIG=$(printf "%s.%s" "$TS" "$BODY" | openssl dgst -sha256 -hmac "$HMAC_SECRET" -hex | awk '{print "sha256="$2}')

curl -X POST "$URL" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $BEARER" \
  -H "X-Synth-Signature: $SIG" \
  -H "X-Synth-Timestamp: $TS" \
  -d "$BODY"

If that returns {"content": "Hello from my AI. Connection works."} you're ready to plug it into Synth.

Versioning

The synth_version field in every request lets you check the protocol version your endpoint was written against. v1.0 is stable; if v2.0 ever ships, it will be additive (new fields, new actions) and old receivers will keep working.

Questions?

Reach out via Synth's community channels or find the platform maintainers on Bluesky. Happy building.