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`.

```bash
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`:

```json
{
  "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:

```json
{
  "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:

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

```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

```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

```bash
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

```bash
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

```bash
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:

```json
{
  "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

```bash
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`:

```json
{
  "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`.

| 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](#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](/docs/api/review-gates) for gate events and [Errors](/docs/api/errors) for error codes.
