SyndaiSign inStart free

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 /webhooks with a key that has coding: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

callscopedoes
GET /webhookscoding:webhooks:readlists the active endpoints
POST /webhookscoding:webhooks:writeadds an endpoint and returns its secret once
DELETE /webhooks/{endpoint_id}coding:webhooks:writeturns the endpoint off; its status becomes disabled and its delivery history stays
POST /webhooks/{endpoint_id}/testcoding:webhooks:writesends one test delivery now
POST /webhooks/{endpoint_id}/rotate-secretcoding:webhooks:writemakes a new signing secret
GET /webhook-deliveriescoding:webhooks:readlists recent deliveries, newest first
POST /webhook-deliveries/{delivery_id}/redelivercoding:webhooks:redeliversends 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"
}
fieldwhat it is
event_namethe event type
event_idthe event's ID; the same on every retry and redelivery
global_seqwhere the event sits in Syndai's event log; it goes up over time but skips numbers
payload.coding_run_idthe run the event is about
payload.external_request_idthe ID you sent when you made the run
payload.current_phasethe run's phase when the event happened
payload.payloadthe fields for this event type; see the list below
metadata.synthetictrue only for a test delivery
occurred_atwhen 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:

headervalue
Syndai-Signaturet=<unix seconds>,v1=<hex digest>
X-Syndai-Signaturethe same value as Syndai-Signature
X-Syndai-Timestampthe same t as in the signature
X-Syndai-Eventthe event type
X-Syndai-Deliverythe delivery's ID
Syndai-Webhook-Idthe delivery's ID
User-AgentSyndai-Coding-Webhook/1.0
Content-Typeapplication/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:

  1. Take t from Syndai-Signature. It is the time of this send, in Unix seconds.
  2. Join t, a dot and the raw body bytes: {t}.{raw body}.
  3. Take the HMAC-SHA256 of that, keyed with the whole signing secret. Keep the whsec_ part. Use the secret's UTF-8 bytes.
  4. 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 t that is more than 300 seconds from your clock. Answer 400. This stops someone who copied an old delivery from sending it again.
  • Answer with any 2xx once 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:

trywait before it
1none
230 seconds
31 minute
42 minutes
54 minutes
68 minutes
716 minutes
832 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:

fieldwhat it is
idthe delivery's ID, as sent in X-Syndai-Delivery
endpoint_id, target_urlwhere it went
event_id, event_namethe event; event_id is null for a test
coding_run_idthe run, or null for a test
delivery_kindevent or test
statuspending, delivered or failed
attemptshow many tries so far
response_statusthe HTTP status your server sent last
response_body_excerptup to 500 characters of your server's reply
last_errorwhy the last try failed
next_retry_atwhen the next try is due, if one is
delivered_atwhen 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:

  1. Call rotate-secret and save the new secret.
  2. Let your server accept a match on either secret.
  3. 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.

eventwhen it firesfields
coding.run.createda run is made and queued
coding.run.phase_changedthe run moves to a new phase
coding.run.liveness_changedthe run's attention state changesstate, reason, pause_kind, attention_class
coding.run.question_requiredthe run needs an answer from youhitl_request_id, prompt
coding.run.spec_review_readythe run's spec waits for your reviewcriteria_count
coding.run.route_changedthe run moved to another plan or providerfrom, to, reason, resets_at
coding.execution.attempt.startedan agent attempt starts
coding.execution.attempt.completedan agent attempt ends
coding.run.capability.starteda governed action starts
coding.run.capability.finisheda governed action ends
coding.run.capability.governance_requireda governed action waits for approval
coding.run.steer_receivedSyndai got a steer message for the run
coding.run.steer_appliedthe run used that steer message
coding.run.steer_orphanedthe run ended before it used a steer message
coding.validation.completedchecks on the change finish
coding.review_gate.completeda review gate records passed, failed or waivedgate_name, status, iteration, head_sha, diff_fingerprint, blocking
coding.run.loop_breaker_firedthe same fix kept failing, so the agent was told to make that part simplertrigger, seam, passes, summary
coding.run.ac_contestedthe agent says an acceptance item is wrongkey, reason
coding.run.noticesomething in your repo was left out of the run, with whynotice
coding.artifact.createdthe run wrote a docs bundleartifact_type (customer_docs), paths, mode (generate or adapter)
coding.run.preview_readya live preview of the run is upservice, url, expires_at, rev, and kind
coding.pr.openedthe run opened a pull requestpr_number, pr_url, branch
coding.pr.mergedthe run's pull request was mergedpr_url, pr_number, merge_commit_sha
coding.run.completedthe run's repository work ended in success
coding.run.failedthe run ended in failure
coding.run.cancelledthe run was stopped by you or by Syndai
coding.run.final_response_readythe run's final message can be readmessage_id, result_kind
coding.run.final_response_failedSyndai could not publish the final messageerror_code, result_kind
coding.run.land_changedthe change is held, live, failed or rolled backland, previous_state
coding.run.delivery_markedsomeone marked a finished runmark, days_since_done, and reason when given
coding.webhook.testyou sent a test to this endpoint (Send a test)

Some notes on the rows above:

  • Liveness. state is one of running, waiting_on_input, waiting_on_approval, waiting_on_review, blocked, stuck or terminal.
  • Preview. service is app. url is the run's preview host, like https://run-<run id>.syndai.io. kind is missing for a web app. It is mobile for an Expo or React Native Web app. The run's preview_urls holds the same object.
  • Delivery marks. mark is confirmed, follow_up or regressed.
  • Final message. These two events carry no text. On final_response_ready, call GET /runs/{id} and read final_response.content. result_kind is pull_request, no_changes or null.
  • Land. land is the same object as run.land on GET /runs/{id}, with state, live_url and deploy_status. The event fires when land.state turns held, live, failed or reverted. Steps on the way, like validating or queued, do not fire it. A second hold after a retry is a new event.

How the end events fit together

  • coding.run.completed means the agent finished. It opened a pull request, or found no change was needed.
  • coding.pr.merged means that pull request was merged.
  • coding.run.land_changed tracks 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.