Webhooks
Add an endpoint, pick the events it gets, check the signature on each delivery, and handle retries, redelivery, test sends and secret rotation.
A webhook is a signed HTTP POST that Syndai sends to your server when a run changes. You add an endpoint once. Every matching event from every run in the workspace then goes to it.
Add an endpoint
You can add one in the app or with the API.
- In the app. Open Settings at app.syndai.ai, then Developer, then Webhooks. You need to be an owner or admin.
- With the API. Call
POST /webhookswith a key that hascoding:webhooks:write.
curl -s -X POST https://api.syndai.ai/api/v1/external/coding/webhooks \
-H "Authorization: Bearer $SYNDAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "production-receiver",
"target_url": "https://your-server.example.com/webhooks/syndai",
"event_types": ["coding.run.completed", "coding.run.land_changed"]
}'
The reply is 201:
{
"endpoint": {
"id": "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
"customer_id": "0b8e7d6c-5a4b-4c3d-8e2f-1a0b9c8d7e6f",
"name": "production-receiver",
"target_url": "https://your-server.example.com/webhooks/syndai",
"event_types": ["coding.run.completed", "coding.run.land_changed"],
"status": "active",
"last_secret_rotated_at": "2026-10-21T12:00:00Z",
"created_at": "2026-10-21T12:00:00Z",
"updated_at": "2026-10-21T12:00:00Z"
},
"signing_secret": "whsec_..."
}
The signing_secret is shown only here. Put it in a secrets manager right away.
The target_url must be a public https URL. A URL with a user name, a password or a # part is refused with 400. Syndai also checks the address each time it sends. It will not send to a private or local IP address.
Pick events
event_types decides which events the endpoint gets.
- Leave it out, or send
[], and the endpoint gets every event. That includes event types Syndai adds later. - Send a list, and the endpoint gets only those. A new event type is never added to your list for you.
An unknown name in the list is refused with 400.
Manage endpoints
| call | scope | does |
|---|---|---|
GET /webhooks | coding:webhooks:read | lists the active endpoints |
POST /webhooks | coding:webhooks:write | adds an endpoint and returns its secret once |
DELETE /webhooks/{endpoint_id} | coding:webhooks:write | turns the endpoint off; its status becomes disabled and its delivery history stays |
POST /webhooks/{endpoint_id}/test | coding:webhooks:write | sends one test delivery now |
POST /webhooks/{endpoint_id}/rotate-secret | coding:webhooks:write | makes a new signing secret |
GET /webhook-deliveries | coding:webhooks:read | lists recent deliveries, newest first |
POST /webhook-deliveries/{delivery_id}/redeliver | coding:webhooks:redeliver | sends a delivery again |
All paths sit under https://api.syndai.ai/api/v1/external/coding. An endpoint you turned off gets nothing more. Deliveries still waiting for it are marked failed.
What a delivery looks like
Each delivery is a POST with a JSON body. The body looks like this:
{
"event_name": "coding.run.land_changed",
"event_id": "2b7c9e1a-3d4f-4a5b-8c6d-7e8f9a0b1c2d",
"global_seq": 184467,
"payload": {
"type": "coding.run.land_changed",
"event_type": "coding.run.land_changed",
"coding_run_id": "01234567-89ab-cdef-0123-456789abcdef",
"external_request_id": "deploy-1234",
"current_phase": "completed",
"payload": {
"land": { "state": "live", "live_url": "https://bakery.syndai.app" },
"previous_state": "landing"
},
"occurred_at": "2026-10-21T12:00:00+00:00",
"schema_version": "1"
},
"metadata": { "synthetic": false },
"occurred_at": "2026-10-21T12:00:00+00:00"
}
| field | what it is |
|---|---|
event_name | the event type |
event_id | the event's ID; the same on every retry and redelivery |
global_seq | where the event sits in Syndai's event log; it goes up over time but skips numbers |
payload.coding_run_id | the run the event is about |
payload.external_request_id | the ID you sent when you made the run |
payload.current_phase | the run's phase when the event happened |
payload.payload | the fields for this event type; see the list below |
metadata.synthetic | true only for a test delivery |
occurred_at | when the event happened |
The land example above is cut short. The real land object has every field that GET /runs/{id} shows under run.land.
These headers come with each delivery:
| header | value |
|---|---|
Syndai-Signature | t=<unix seconds>,v1=<hex digest> |
X-Syndai-Signature | the same value as Syndai-Signature |
X-Syndai-Timestamp | the same t as in the signature |
X-Syndai-Event | the event type |
X-Syndai-Delivery | the delivery's ID |
Syndai-Webhook-Id | the delivery's ID |
User-Agent | Syndai-Coding-Webhook/1.0 |
Content-Type | application/json |
Drop repeats
Delivery is at least once. A retry, a redelivery or a network blip can bring the same event twice. Keep the event_id values you have handled, and skip one you have seen.
Check the signature
Check the signature on every delivery before you trust the body. Here is how it is made:
- Take
tfromSyndai-Signature. It is the time of this send, in Unix seconds. - Join
t, a dot and the raw body bytes:{t}.{raw body}. - Take the HMAC-SHA256 of that, keyed with the whole signing secret. Keep the
whsec_part. Use the secret's UTF-8 bytes. - Write the result as lower-case hex. That is
v1.
Each send is signed again with a fresh t. So a retry has a new t and a new v1.
Do these four things on your side:
- Use the raw body bytes. Do not parse the JSON and dump it again first. The bytes would change.
- Compare in constant time.
- Turn away a
tthat is more than 300 seconds from your clock. Answer400. This stops someone who copied an old delivery from sending it again. - Answer with any
2xxonce you have the event. Do the slow work after that.
Node.js
const crypto = require("crypto");
function verifySyndaiSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);
const t = Number.parseInt(parts.t, 10);
if (!Number.isFinite(t) || Math.abs(Math.floor(Date.now() / 1000) - t) > 300) {
return false;
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody) // a Buffer of the exact bytes received
.digest("hex");
const got = Buffer.from(parts.v1 || "", "utf8");
const want = Buffer.from(expected, "utf8");
return got.length === want.length && crypto.timingSafeEqual(got, want);
}
With Express, read the body with express.raw({ type: "application/json" }) so req.body is a Buffer.
Python
import hashlib
import hmac
import time
def verify_syndai_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = {}
for item in header.split(","):
key, sep, value = item.partition("=")
if sep:
parts[key.strip()] = value.strip()
try:
t = int(parts.get("t", ""))
except ValueError:
return False
if abs(int(time.time()) - t) > 300:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Retries
Syndai waits up to 10 seconds for your answer. Any 2xx counts as delivered. Anything else is a failed try. That includes a 3xx, since redirects are not followed. A timeout or a dropped link is a failed try too.
After a failed try, Syndai waits and tries again. The wait doubles each time. A delivery gets at most 8 tries:
| try | wait before it |
|---|---|
| 1 | none |
| 2 | 30 seconds |
| 3 | 1 minute |
| 4 | 2 minutes |
| 5 | 4 minutes |
| 6 | 8 minutes |
| 7 | 16 minutes |
| 8 | 32 minutes |
If try 8 fails, the delivery is marked failed and no more tries are made. That is about an hour after the first try.
Some sends are not tried again. If the URL now points at a private address, the delivery fails at once. If the host name does not resolve, Syndai treats it as a short outage and retries.
A failed webhook never stops or fails a run. Your run goes on either way.
Delivery history
curl -s "https://api.syndai.ai/api/v1/external/coding/webhook-deliveries?limit=50" \
-H "Authorization: Bearer $SYNDAI_API_KEY"
You get the newest deliveries across all your endpoints. limit is 50 by default and at most 200. Each row has these fields:
| field | what it is |
|---|---|
id | the delivery's ID, as sent in X-Syndai-Delivery |
endpoint_id, target_url | where it went |
event_id, event_name | the event; event_id is null for a test |
coding_run_id | the run, or null for a test |
delivery_kind | event or test |
status | pending, delivered or failed |
attempts | how many tries so far |
response_status | the HTTP status your server sent last |
response_body_excerpt | up to 500 characters of your server's reply |
last_error | why the last try failed |
next_retry_at | when the next try is due, if one is |
delivered_at | when a 2xx came back |
Syndai removes secrets and its own signing secret from last_error and response_body_excerpt before it stores them.
Send a delivery again
curl -s -X POST \
"https://api.syndai.ai/api/v1/external/coding/webhook-deliveries/<DELIVERY_ID>/redeliver" \
-H "Authorization: Bearer $SYNDAI_API_KEY"
This puts the delivery back in line with a fresh set of 8 tries. The first try goes out within a few seconds. The body and event_id stay the same. The endpoint must still be active, or you get 400. Each redelivery is written to the workspace audit log.
Send a test
curl -s -X POST \
"https://api.syndai.ai/api/v1/external/coding/webhooks/<ENDPOINT_ID>/test" \
-H "Authorization: Bearer $SYNDAI_API_KEY"
Syndai sends one delivery right away and waits for it. The reply holds the delivery row, so you see your server's status code at once. The test is signed like any other delivery. It gets one try only, with no retries. The endpoint must be active.
The test body has no run in it:
{
"event_name": "coding.webhook.test",
"payload": {
"kind": "test",
"message": "Synthetic Syndai webhook delivery for endpoint verification."
},
"metadata": { "synthetic": true, "endpoint_id": "6f1c2d3e-4a5b-4c6d-8e7f-9a0b1c2d3e4f" },
"occurred_at": "2026-10-21T12:00:00+00:00"
}
Rotate the secret
curl -s -X POST \
"https://api.syndai.ai/api/v1/external/coding/webhooks/<ENDPOINT_ID>/rotate-secret" \
-H "Authorization: Bearer $SYNDAI_API_KEY"
The reply is 201:
{
"secret": "whsec_...",
"version": 2,
"retired_grace_until": "2026-10-22T12:00:00Z"
}
The new secret is shown only here. From now on, every send is signed with it, retries too. The old secret stops at retired_grace_until, 24 hours later.
To swap with no gap:
- Call
rotate-secretand save the new secret. - Let your server accept a match on either secret.
- Once it runs with the new one, remove the old one.
Events
Every event name starts with coding.. New event types may be added. Ignore names you do not know, and do not fail on them.
Each row below lists the fields in payload.payload.
| event | when it fires | fields |
|---|---|---|
coding.run.created | a run is made and queued | |
coding.run.phase_changed | the run moves to a new phase | |
coding.run.liveness_changed | the run's attention state changes | state, reason, pause_kind, attention_class |
coding.run.question_required | the run needs an answer from you | hitl_request_id, prompt |
coding.run.spec_review_ready | the run's spec waits for your review | criteria_count |
coding.run.route_changed | the run moved to another plan or provider | from, to, reason, resets_at |
coding.execution.attempt.started | an agent attempt starts | |
coding.execution.attempt.completed | an agent attempt ends | |
coding.run.capability.started | a governed action starts | |
coding.run.capability.finished | a governed action ends | |
coding.run.capability.governance_required | a governed action waits for approval | |
coding.run.steer_received | Syndai got a steer message for the run | |
coding.run.steer_applied | the run used that steer message | |
coding.run.steer_orphaned | the run ended before it used a steer message | |
coding.validation.completed | checks on the change finish | |
coding.review_gate.completed | a review gate records passed, failed or waived | gate_name, status, iteration, head_sha, diff_fingerprint, blocking |
coding.run.loop_breaker_fired | the same fix kept failing, so the agent was told to make that part simpler | trigger, seam, passes, summary |
coding.run.ac_contested | the agent says an acceptance item is wrong | key, reason |
coding.run.notice | something in your repo was left out of the run, with why | notice |
coding.artifact.created | the run wrote a docs bundle | artifact_type (customer_docs), paths, mode (generate or adapter) |
coding.run.preview_ready | a live preview of the run is up | service, url, expires_at, rev, and kind |
coding.pr.opened | the run opened a pull request | pr_number, pr_url, branch |
coding.pr.merged | the run's pull request was merged | pr_url, pr_number, merge_commit_sha |
coding.run.completed | the run's repository work ended in success | |
coding.run.failed | the run ended in failure | |
coding.run.cancelled | the run was stopped by you or by Syndai | |
coding.run.final_response_ready | the run's final message can be read | message_id, result_kind |
coding.run.final_response_failed | Syndai could not publish the final message | error_code, result_kind |
coding.run.land_changed | the change is held, live, failed or rolled back | land, previous_state |
coding.run.delivery_marked | someone marked a finished run | mark, days_since_done, and reason when given |
coding.webhook.test | you sent a test to this endpoint (Send a test) |
Some notes on the rows above:
- Liveness.
stateis one ofrunning,waiting_on_input,waiting_on_approval,waiting_on_review,blocked,stuckorterminal. - Preview.
serviceisapp.urlis the run's preview host, likehttps://run-<run id>.syndai.io.kindis missing for a web app. It ismobilefor an Expo or React Native Web app. The run'spreview_urlsholds the same object. - Delivery marks.
markisconfirmed,follow_uporregressed. - Final message. These two events carry no text. On
final_response_ready, callGET /runs/{id}and readfinal_response.content.result_kindispull_request,no_changesornull. - Land.
landis the same object asrun.landonGET /runs/{id}, withstate,live_urlanddeploy_status. The event fires whenland.stateturnsheld,live,failedorreverted. Steps on the way, likevalidatingorqueued, do not fire it. A second hold after a retry is a new event.
How the end events fit together
coding.run.completedmeans the agent finished. It opened a pull request, or found no change was needed.coding.pr.mergedmeans that pull request was merged.coding.run.land_changedtracks what happens next: held, live, failed or rolled back.
To learn when a change is on the web, wait for coding.run.land_changed with land.state set to live. Then read land.live_url.
See Review gates for gate events and Errors for error codes.