Synth Webhook Protocol
Bring Your Own AI — integration spec for custom AI writing partners
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:
Authorization: Bearer <token>— a long random string the user generated. Your endpoint checks this first; reject with 401 if missing or wrong.X-Synth-Signature: sha256=<hex>— HMAC-SHA256 of<timestamp>.<body>computed with a shared secret the user configured. Reject with 403 if invalid.X-Synth-Timestamp: <unix-seconds>— the request timestamp. Reject if more than 5 minutes old (replay protection).
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
| Action | What your AI does |
|---|---|
propose | Write the next passage. Respect sentence_count if given. |
redirect | Rewrite the most recent AI passage using guidance. |
chat | Respond to user_message in ongoing chat. No prose passages. |
review | Read new_passage and decide: approve or suggest changes? |
test | Connection 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
{"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
contentis required and must be a non-empty string under 10,000 charactersusageis optional but useful — Synth usestotal_tokensfor local accounting. If omitted, Synth records 0 tokens for this call.metadatais optional. Review responses should putdecisionandnotehere.
Response codes
200 OK— success, process the content401 Unauthorized— Synth tells the user their bearer token is wrong403 Forbidden— Synth tells the user their signature failed404 Not Found— Synth tells the user the URL is wrong- Anything else — Synth shows a generic "your AI returned an error" message
Error handling rules Synth follows
- 30-second timeout per request
- No retries — the writer retries manually if they want
- Review action special case — if your endpoint is unreachable during a review, Synth auto-approves the human's passage. Never block the writer.
- Circuit breaker — after 5 consecutive failures, Synth backs off for 10 minutes before retrying. This protects your endpoint from being hammered if something's genuinely broken.
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.