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.
Configure a destination
Section titled “Configure a destination”- Open Builder and go to Data → Webhooks.
- In the Outbound destinations section, choose New webhook destination.
- Enter the Endpoint URL, an optional Name, and select the Events to send (or subscribe to all events).
- 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.
Endpoint requirements
Section titled “Endpoint requirements”- 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
3xxresponse counts as a failed delivery, so publish the final URL. - Each attempt has a 5 second timeout. Acknowledge with a
2xxfirst and do the real work asynchronously.
Event payload
Section titled “Event payload”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 }}| Field | Description |
|---|---|
id | Stable event id. Every re-delivery of the same event repeats this id, so use it to deduplicate. |
version | Envelope version. Currently v1. |
type | The event type, for example run.completed. |
event_category | The prefix of type, either run or agent. |
timestamp | ISO 8601 UTC time the event was recorded. Repeated unchanged on re-deliveries. |
data | Event-specific fields. See the tables below. |
Event types
Section titled “Event types”Subscribe a destination to individual types or to all events. The live list is also served by GET /v1/outbound-webhooks/catalog.
Run events
Section titled “Run events”| Type | data fields |
|---|---|
run.started | jobKey |
run.completed | jobKey, success |
run.failed | jobKey, success |
Run events identify the run by jobKey. They do not carry an agent id.
Agent lifecycle events
Section titled “Agent lifecycle events”| Type | data fields |
|---|---|
agent.created | agentId, name |
agent.published | agentId, revisionId |
agent.unpublished | agentId |
agent.enabled | agentId, enabled, runnable |
agent.disabled | agentId, enabled, runnable |
agent.deleted | agentId |
There is no
agent.updatedevent. The canvas autosaves continuously, so an event on every save would flood subscribers.
Verifying deliveries
Section titled “Verifying deliveries”Two headers accompany every delivery, both lowercase:
x-synthreo-event: run.completedx-synthreo-signature: sha256=3a1f9c02d4e6b8a1f0c7d5e39b2a6c48d1e0f7a9b3c5d2e4f6a8b0c1d3e5f7a9x-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 hashlibimport hmacimport jsonimport osfrom 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']}")Delivery, retries, and idempotency
Section titled “Delivery, retries, and idempotency”- 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
2xxresponse 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
5xxresponses. A4xxis 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 sameidandtimestampas the original. Claim theidin 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
idclaim above are the primary defences. If you also want a freshness check, remember that every re-delivery repeats the ORIGINALtimestamp: 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.startedarrives beforerun.completed; use the payload rather than arrival order.
Monitoring and re-sending
Section titled “Monitoring and re-sending”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.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
| No deliveries at all | The destination is disabled, or it is not subscribed to that event type. Check both in Data → Webhooks. |
Invalid signature in your logs | The HMAC was computed over a re-serialized body. Sign the raw bytes exactly as received. |
| Deliveries recorded as blocked | The endpoint is not HTTPS, or its hostname resolves to a private or loopback address. |
| Deliveries recorded as failed with no status | A connection error, a TLS failure, or the 5 second timeout was exceeded. |
| Deliveries stop after a single failure | The 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 twice | Expected under at-least-once delivery. Deduplicate on the envelope id. |
Waiting for a job.completed event | That event does not exist. Poll the job instead, as shown in Cognitive Diagrams API. |
Related pages:
- Cognitive Diagrams API - executing agents and polling jobs
- Authentication - obtaining an access token
- Best Practices - retries, rate limits, and error handling
- SDKs and Libraries - integrating directly against the REST API

