Skip to content
synthreo.ai

Outbound Webhooks - Synthreo Builder

Synthreo Builder outbound webhooks - subscribe an HTTPS endpoint to run and agent lifecycle events, verify the x-synthreo-signature HMAC, and handle retries.

Outbound webhooks let Synthreo POST an event to your own HTTPS endpoint when something happens on the platform: a run starts, a run finishes, an agent is published, an agent is disabled. You configure a destination once, subscribe it to the event types you care about, and verify each delivery with an HMAC signature.

  1. Open Builder and go to Data → Webhooks.
  2. In the Outbound destinations section, choose New webhook destination.
  3. Enter the Endpoint URL, an optional Name, and select the Events to send (or subscribe to all events).
  4. Save, then copy the signing secret. It is shown once, at creation time, and cannot be retrieved afterwards. Store it as a secret in your receiving application.
  • HTTPS only. Plain HTTP endpoints are rejected.
  • The hostname must resolve to a public address. Loopback, private-range, and cloud metadata addresses are rejected, and the check is repeated immediately before every delivery, so an endpoint that later resolves to a private address stops receiving events.
  • Redirects are not followed. A 3xx response counts as a failed delivery, so publish the final URL.
  • Each attempt has a 5 second timeout. Acknowledge with a 2xx first and do the real work asynchronously.

Every delivery is a POST with Content-Type: application/json and this envelope:

{
"id": "9f1c4d0b8a7e4f2b9c3d5e6f70819aa2",
"version": "v1",
"type": "run.completed",
"event_category": "run",
"timestamp": "2026-08-04T10:30:00.123456+00:00",
"data": {
"jobKey": "7ea49160-e58a-4fde-a9ed-d442ec0d3820",
"success": true
}
}
FieldDescription
idStable event id. Every re-delivery of the same event repeats this id, so use it to deduplicate.
versionEnvelope version. Currently v1.
typeThe event type, for example run.completed.
event_categoryThe prefix of type, either run or agent.
timestampISO 8601 UTC time the event was recorded. Repeated unchanged on re-deliveries.
dataEvent-specific fields. See the tables below.

Subscribe a destination to individual types or to all events. The live list is also served by GET /v1/outbound-webhooks/catalog.

Typedata fields
run.startedjobKey
run.completedjobKey, success
run.failedjobKey, success

Run events identify the run by jobKey. They do not carry an agent id.

Typedata fields
agent.createdagentId, name
agent.publishedagentId, revisionId
agent.unpublishedagentId
agent.enabledagentId, enabled, runnable
agent.disabledagentId, enabled, runnable
agent.deletedagentId

There is no agent.updated event. The canvas autosaves continuously, so an event on every save would flood subscribers.

Two headers accompany every delivery, both lowercase:

x-synthreo-event: run.completed
x-synthreo-signature: sha256=3a1f9c02d4e6b8a1f0c7d5e39b2a6c48d1e0f7a9b3c5d2e4f6a8b0c1d3e5f7a9

x-synthreo-signature is sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the destination’s signing secret. Compute it over the exact bytes received, before any JSON parsing or re-serialization, and compare in constant time.

There is no delivery-id header and no timestamp header. Use the envelope’s id for deduplication and its timestamp for freshness checks.

Node.js:

const crypto = require('crypto');
const express = require('express');
const app = express();
// Raw body: the signature is computed over the exact bytes sent.
app.post('/webhooks/synthreo', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.headers['x-synthreo-signature'] || '';
const expected = crypto
.createHmac('sha256', process.env.SYNTHREO_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const provided = header.replace('sha256=', '');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(provided, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Acknowledge first, then process asynchronously.
res.status(200).send('OK');
handleEvent(event).catch((err) => console.error('webhook processing failed', err));
});
async function handleEvent(event) {
switch (event.type) {
case 'run.completed':
console.log(`run ${event.data.jobKey} finished`);
break;
case 'run.failed':
console.warn(`run ${event.data.jobKey} failed`);
break;
case 'agent.published':
console.log(`agent ${event.data.agentId} published revision ${event.data.revisionId}`);
break;
default:
console.log(`unhandled event ${event.type}`);
}
}

Python (Flask):

import hashlib
import hmac
import json
import os
from datetime import datetime, timedelta, timezone
from flask import Flask, abort, request
app = Flask(__name__)
@app.post('/webhooks/synthreo')
def synthreo_webhook():
raw = request.get_data() # raw bytes, not request.json
provided = request.headers.get('x-synthreo-signature', '').replace('sha256=', '')
expected = hmac.new(
os.environ['SYNTHREO_WEBHOOK_SECRET'].encode('utf-8'), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, provided):
abort(401, 'Invalid signature')
event = json.loads(raw)
# Optional replay bound. Deliberately generous: a re-delivery repeats the original
# timestamp, so an event held for a broken endpoint or re-sent by hand from the
# delivery log is legitimately old. Tighten this only if you accept losing those.
age = datetime.now(timezone.utc) - datetime.fromisoformat(event['timestamp'])
if age > timedelta(days=1):
abort(400, 'Stale event')
enqueue_for_processing(event) # return quickly and process out of band
return '', 200
def enqueue_for_processing(event):
"""Hand the event to your own worker (Celery, RQ, a queue table, a thread pool).
Deliberately trivial here: whatever you use, it must not do the real work inline.
Synthreo gives each delivery 5 seconds and retries on a timeout, so slow inline
processing turns one event into duplicates.
"""
print(f"queued {event['type']} {event['id']}")
  • Events are written to a durable outbox and delivered by a background relay, so an event is not lost when a delivery fails or a process restarts. Delivery is at least once, so your handler must tolerate duplicates.
  • Any 2xx response counts as a successful delivery.
  • Retries: up to 3 attempts per destination with exponential backoff (roughly 0.5s, then 2s) on connection errors, timeouts, and 5xx responses. A 4xx is treated as final and is not retried, so do not reject deliveries you intend to receive.
  • Deduplicate on the envelope id, before any side effect. Re-deliveries, including a manual re-send, carry the same id and timestamp as the original. Claim the id in a store with a uniqueness constraint and drop the delivery if the claim already exists, rather than deduplicating after the work has run.
  • Bound replay with a generous window, if you bound it at all. The signature plus the id claim above are the primary defences. If you also want a freshness check, remember that every re-delivery repeats the ORIGINAL timestamp: an event held while a destination was unreachable, or re-sent by hand from the delivery log days later, legitimately arrives old. A window of hours (or a day) still limits replay of a captured body without discarding those. A few-minute window will drop real deliveries.
  • Deliveries to multiple destinations are fanned out concurrently, so arrival order is not guaranteed. Do not assume run.started arrives before run.completed; use the payload rather than arrival order.

Every attempt is recorded in Monitor → Webhook Deliveries, alongside inbound webhook deliveries. Each row shows the event type, destination, HTTP status, attempt count, and any error. A logged delivery can be retried from that view, which re-POSTs the captured payload to the same destination with a fresh signature.

Blocked endpoints appear in the log too: if a destination stops resolving to a public address, the attempt is recorded as blocked and the request is never sent.

SymptomLikely cause
No deliveries at allThe destination is disabled, or it is not subscribed to that event type. Check both in Data → Webhooks.
Invalid signature in your logsThe HMAC was computed over a re-serialized body. Sign the raw bytes exactly as received.
Deliveries recorded as blockedThe endpoint is not HTTPS, or its hostname resolves to a private or loopback address.
Deliveries recorded as failed with no statusA connection error, a TLS failure, or the 5 second timeout was exceeded.
Deliveries stop after a single failureThe endpoint answered with a 4xx, which is final. Once a delivery passes signature verification, return 2xx even if you then discard the event as irrelevant. Keep 4xx for a bad signature or a malformed body, so a forged request is never acknowledged.
The same event processed twiceExpected under at-least-once delivery. Deduplicate on the envelope id.
Waiting for a job.completed eventThat event does not exist. Poll the job instead, as shown in Cognitive Diagrams API.

Related pages: